diff --git a/.github/workflows/widget-release.yml b/.github/workflows/widget-release.yml index 503c1b421..88cd2408e 100644 --- a/.github/workflows/widget-release.yml +++ b/.github/workflows/widget-release.yml @@ -133,6 +133,7 @@ jobs: "quantem/widget/static/show4dstem.js", "quantem/widget/static/showcif.js", "quantem/widget/static/showdiffraction.js", + "quantem/widget/static/showpdf.js", "quantem/widget/static/showptycho.js", } with zipfile.ZipFile(wheel) as zf: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ea81b9f4..9a293576c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ new `rcN` heading when that rc is published to TestPyPI. ## Unreleased +- Add `ShowPDF` for pair distribution function analysis of 4D-STEM data: select + scan regions (mask, probe, line, or line scan) and tune the background fit, + k window, filters, and low-r damping on top of + `quantem.diffraction.PairDistributionFunction`. The number density is a + required input for g(r) and damping; it is never estimated. Standalone HTML + exports recompute the curves in the browser, and saved notebooks keep a static + picture unless `save_state=True`. ShowPDF needs the + `PairDistributionFunction` of quantem pull request 248, on quantem's dev + branch until its next release. Its tutorial (`docs/tutorials/showpdf.ipynb`), + with the `datasets.showpdf_ta` loader of a simulated amorphous Ta scan, joins + the docs after that release. + ## rc41 - 2026-10-09 - Show4DSTEM opens `.qem` acquisitions in the browser with no kernel: diff --git a/README.md b/README.md index b4092f41f..20c3636f0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ require a source build containing them. The guide includes setup and browser req | `Show4DSTEM` | Live virtual detectors, multi-dataset review, and WebGPU HTML export | [tutorial](https://electronmicroscopy.github.io/quantem.widget/tutorials/show4dstem.html) · [export guide](https://electronmicroscopy.github.io/quantem.widget/tutorials/show4dstem_export.html) · [API](https://electronmicroscopy.github.io/quantem.widget/api/show4dstem.html) | | `ShowPtycho` | Interactive SSB phase and aberration review | [API](https://electronmicroscopy.github.io/quantem.widget/api/showptycho.html) | | `ShowDiffraction` | Measure diffraction spots, rings, spacing, and angles | [tutorial](https://electronmicroscopy.github.io/quantem.widget/tutorials/showdiffraction.html) · [API](https://electronmicroscopy.github.io/quantem.widget/api/showdiffraction.html) | +| `ShowPDF` | Pair distribution function analysis of 4D-STEM data by scan region | [API](https://electronmicroscopy.github.io/quantem.widget/api/showpdf.html) | | `ChooseLattice` | Select an origin and lattice vectors | [API](https://electronmicroscopy.github.io/quantem.widget/api/choose-lattice.html) | | `ShowCIF` | Crystal repeats, columns, tilt, and projected potential/phase previews | [tutorial](docs/tutorials/showcif.ipynb) · [API](docs/api/showcif.md) | diff --git a/docs/_toc.yml b/docs/_toc.yml index 8179e1fb4..dd930bd67 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -75,6 +75,7 @@ parts: - file: api/show4dstem - file: api/showptycho - file: api/showdiffraction + - file: api/showpdf - file: api/choose-lattice - file: api/showcif - file: api/html-export diff --git a/docs/api/datasets.md b/docs/api/datasets.md index 42282640a..6b7c4f05d 100644 --- a/docs/api/datasets.md +++ b/docs/api/datasets.md @@ -13,6 +13,8 @@ from quantem.widget.datasets import ( show3d_gold, show4dstem_gold, showdiffraction_fe3o4, + showpdf_ta, + showpdf_ta_ground_truth, ) ``` @@ -20,9 +22,12 @@ Images and stacks come back as quantem core `Dataset2d` and `Dataset3d` (`show2d_gold`, `show3d_gold`, `showdiffraction_fe3o4`), and the 4D-STEM scan as every 4D-STEM file reads (`read_4dstem`): a `quantem.gpu.io.Dataset4dstemGPU` when quantem.gpu is installed and a GPU is present, else a quantem core -`Dataset4dstem` (`show4dstem_gold`). On an Intel Mac or Windows on ARM, which -cannot install quantem core, the widget's stand-in carries the same `array`, -`name`, `sampling`, `units`, `metadata`, `shape`, `ndim` and `dtype`. +`Dataset4dstem` (`show4dstem_gold`). `showpdf_ta` returns a quantem core +`Dataset4dstem` with its detector calibrated in 1/Å, and +`showpdf_ta_ground_truth` the `(r, g)` arrays of its atomic model's g(r). On +an Intel Mac or Windows on ARM, which cannot install quantem core, the +widget's stand-in carries the same `array`, `name`, `sampling`, `units`, +`metadata`, `shape`, `ndim` and `dtype`. `show1d_ducky` and `gold_session` return folders. All size selectors use the same language: @@ -83,6 +88,11 @@ example files at the top level of the shared dataset repository. `show2d_gold` and `show3d_gold` intentionally share `widget-tutorials/shared/gold-haadf/full` so the same HAADF source image is not duplicated per widget. +`showpdf_ta()` and `showpdf_ta_ground_truth()` read `4dstem/ta_amorphous_sim/` +(`data.npy`, `meta.json` and `ground_truth_gr.npz`), in the dataset's newer +`/` layout instead of under `widget-tutorials/`, at the dataset +commit that added that folder; pass `revision` to read another. + ## Reference ```{eval-rst} diff --git a/docs/api/html-export.md b/docs/api/html-export.md index eadf2f711..6c1fb20c0 100644 --- a/docs/api/html-export.md +++ b/docs/api/html-export.md @@ -4,7 +4,7 @@ 1. **Widget-level HTML**: `widget.export_html(...)` writes one standalone widget viewer. Use this when a single Show1D / Show2D / Show3D / Show3DSlices / - Show4DSTEM / ShowDiffraction view is the artifact. + Show4DSTEM / ShowDiffraction / ShowPDF view is the artifact. 2. **Notebook-level HTML**: `jupyter nbconvert --to html notebook.ipynb` exports a whole notebook with its saved widget state (add `--execute` to rerun it first). Use this for reports and @@ -46,6 +46,7 @@ back to the `.ipynb` or `.html` file. | Show3DSlices | `export_html(path=None, *, title=None, encoding="full")` | | Show4DSTEM | `export_html(path=None, *, title=None, dtype="uint8", det_bin=1, scan_bin=1, export_kind="interactive", dataset_scope="unhidden")` | | ShowDiffraction | `export_html(path=None, *, title=None)` | +| ShowPDF | `export_html(path=None, *, title=None, mode="single", encoding="full", downsample=None)` | | ShowPtycho | `export(path)` writes a folder: the widget page over exact bright-field counts; the browser builds transient BF-indexed reducers in WebGPU | `encoding="uint8"` (or `quantized=True`) stores display-scaled data; `downsample` @@ -80,6 +81,7 @@ it writes the file by calling `export_html(...)`. | Show3DSlices | `full`, `uint8` | none | `uint8` packs against the global range | | Show4DSTEM | `dtype="uint8"`, `"uint16"` | `det_bin`, `scan_bin`: `1`, `2`, `4`, `8` | mean bin; `uint8` clips counts above 255 and says how many; `export_kind="interactive"` packs the counts into the page and refuses above 2 GiB, naming the least binning that fits; `export_kind="report"` writes static PNG virtual-image pages with no raw 4D | | ShowDiffraction | `full` (float32) | none | exact float32 frames | +| ShowPDF | `full`, `uint8` | none | `full` stores the float32 curves; `uint8` adds every scan position's radial profile (8 bits per k bin) so mask, probe and line selections recompute in the browser | ## Single-file exports diff --git a/docs/api/index.md b/docs/api/index.md index 986d7e497..3a5ab8745 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -49,6 +49,7 @@ readers and test agents. | [Show4DSTEM](show4dstem) | `quantem.widget.Show4DSTEM` dispatcher | state JSON, PNG, interactive WebGPU HTML; large exports use a companion data directory | | [ShowPtycho](showptycho) | `quantem.widget.ShowPtycho` | ptychography state JSON, WebGPU folder export with BF-indexed `G(k)` payload | | [ShowDiffraction](showdiffraction) | `quantem.widget.showdiffraction.ShowDiffraction` | state JSON, PNG, interactive HTML | +| [ShowPDF](showpdf) | `quantem.widget.showpdf.ShowPDF` | state JSON, results zip (CSV), PNG/PDF/TIFF curves, interactive HTML (`encoding="full"` / `encoding="uint8"`) | | [ChooseLattice](choose-lattice) | `quantem.widget.choose_lattice.ChooseLattice` | state JSON (`save_state=True`) | | [ShowCIF](showcif) | `quantem.widget.ShowCIF` | Interactive HTML with embedded coordinates; CDN widget manager requires network | diff --git a/docs/api/showpdf.md b/docs/api/showpdf.md new file mode 100644 index 000000000..0c6030f8e --- /dev/null +++ b/docs/api/showpdf.md @@ -0,0 +1,365 @@ +# ShowPDF + +`ShowPDF` is an interactive front end to +`quantem.diffraction.PairDistributionFunction` for electron pair distribution +function (ePDF) analysis of 4D-STEM data. + +The left panel shows a navigation image of the scan, on which you select scan +positions: a painted mask, a square probe, or a line band. The right panel +shows one curve of that selection: the azimuthal mean I(k) with its fitted +background B(k), the windowed reduced structure factor F(k), the reduced PDF +G(r), or the PDF g(r). + +In a notebook, every curve comes from the library's `calculate_Gr` and +`calculate_gr`. When a setting changes in an exported HTML page, the page +recomputes the curves in the browser, repeating the library's steps in +JavaScript. + +```python +from quantem.widget import ShowPDF + +w = ShowPDF( + dataset, # Dataset4dstem, array, or PairDistributionFunction + k_min_fit=0.3, # 1/Å + density_value=0.0528, # atoms/ų, for g(r) and the damping + damp_origin_oscillations=True, + plot_mode="gr", +) +w +``` + +## Requirements + +ShowPDF needs quantem's `PairDistributionFunction` from +electronmicroscopy/quantem pull request 248 (`calculate_Gr` with `density` and +`max_iter`). Until the next quantem release, it is on quantem's +[dev branch](https://github.com/electronmicroscopy/quantem/tree/dev). +Importing ShowPDF works without it; constructing one raises an `ImportError` +that names the requirement. + +quantem core does not install on an Intel Mac (it needs torch 2.7 and +NumPy 2) or on Windows on ARM (it needs hdf5plugin 6), so ShowPDF is not +available there. See [Installation](../install.md). + +## Input + +`data` is a `Dataset4dstem`, a 4D array +`(scan_row, scan_col, det_row, det_col)`, a single 2D pattern (a `Dataset2d` +or 2D array, analyzed as a 1 × 1 scan), or a `PairDistributionFunction`. + +Calibrate the diffraction axes in 1/Å. k is 1/d (the 2π enters in the sine +transform); an uncalibrated dataset gives k in detector pixels. + +Any other input goes through `PairDistributionFunction.from_data`, with the +constructor's `find_origin`, `origin_row`, `origin_col`, `num_annular_bins`, +`radial_min`, `radial_max`, `radial_step`, `two_fold_rotation_symmetry` and +`device` (the CPU unless given). For other `from_data` options, such as +`ellipse_params`, build the `PairDistributionFunction` yourself and pass it +in. ShowPDF computes with the object you pass and replaces its results. + +The navigation image is a bright-field virtual image of the data unless +`nav_image` is given. Its scale bar uses the dataset's scan calibration, or +`pixel_size` (Å). + +`set_data(data)` swaps in a new dataset. It keeps the parameters where they +still fit the new k range and resets the mask, probe, and line. + +## Number density + +ShowPDF never estimates the number density ρ₀; set `density_value` in +atoms/ų. From a mass density ρ in g/cm³ and a mean atomic mass M in g/mol, +ρ₀ = 0.6022 ρ / M. A structure model gives it too. + +G(r) does not need the density. g(r) = 1 + G(r) / (4π r ρ₀) and the low-r +correction (`damp_origin_oscillations`) do. While `density_value` is 0 or +less, g(r) stays empty, the damping is skipped, and the status line asks for +the density when either is requested. + +## Parameters + +The widget passes these to the library's `calculate_Gr` (k in 1/Å, r in Å): + +| Trait | `calculate_Gr` argument | Default | Meaning | +|---|---|---|---| +| `k_min_fit`, `k_max_fit` | `k_min_fit`, `k_max_fit` | the data's k range | Range of the background fit B(k). | +| `k_min_window`, `k_max_window` | `k_min_window`, `k_max_window` | 0 | Range of the window on F(k). An end at or below 0 follows the fit range, as `None` does in the library. | +| `k_lowpass`, `k_highpass` | `k_lowpass`, `k_highpass` | 0 (off) | Widths of Gaussian filters on F(k). Both together make a band-pass, and a highpass above the lowpass is an error. | +| `r_max`, `r_step` | `r_max`, `r_step` | 20.0, 0.02 | The r grid: from 0 in steps of `r_step`, below `r_max`. | +| `damp_origin_oscillations` | `damp_origin_oscillations` | `False` | Low-r correction: corrects S(k) so that G(r) follows −4π ρ₀ r on [0, `r_cut`]. It runs only once `density_value` is set. | +| `r_cut` | `r_cut` | 0.8 | Upper end of the correction. Keep it below the nearest-neighbour distance. | +| `max_iter` | `max_iter` | 6 | Maximum number of iterations of the correction. | +| `density_value` | `density` | 0 (not set) | Number density ρ₀ in atoms/ų, also passed to `calculate_gr`. | +| the selection | `mask_realspace` | the full scan | The scan positions averaged into I(k). | + +The window is zero outside its range. It rises from 0 as sin² over the lowest +tenth of the range, and it is multiplied by the Lorch factor +sin(πk/k_max) / (πk/k_max), which falls to 0 at the window's upper end k_max. + +The k window slider shows the window in effect. Moving one of its ends sets +that end, which then stops following the fit range; an end you leave in place +keeps its setting. Set `k_min_window` or `k_max_window` to 0 to make that end +follow the fit range again. + +With damping on, the F(k) and G(r) plots show the corrected curves that the +transform used, and g(r) comes from the corrected G(r). g(r) is clamped at 0 +(`set_pdf_positive=True`). + +## Status line + +The status line next to the title shows these hints: + +| Status | When | +|---|---| +| "Set the density to apply damping and compute g(r)." | Damping is on and the density is not set. | +| "Set the density to compute g(r)." | g(r) is shown, damping is off, and the density is not set. | +| "The k window contains no k points; set its lower end below its upper end, more than one k step apart." | The window has no positive weight. | + +The window hint takes precedence over the density hints, and an error message +(starting with "Error:", "g(r) error:" or "Line-scan error:") over both. + +A window has no positive weight when its lower end is at or above its upper +end (after an end at or below 0 resolves to the fit range), or when no k +point lies strictly between its ends. F(k) and G(r) are then zero, to float32 +rounding, and g(r) is 1. + +When a computation fails, the curve plot keeps its last curves if only a +parameter changed. After a new selection or dataset, and in a line scan, it +is empty. + +## Selecting scan positions + +The mask, probe, and line buttons set `analysis_mode`. The count under the +image shows how many scan positions the curves average. + +### Mask + +Paint on the image with the ▭ (Rectangle), ○ (Circle), or ✎ (Freeform) +tool. In + mode, the default, painting excludes positions (shown red); in − +mode, painting includes them again. + +Switching between + and − resets the whole mask: to all positions included +for +, and to all excluded for −. To analyze one region, switch to − and paint +it. Clear includes every position, and Invert inverts the mask and swaps + and +−. + +From Python, `set_mask(mask)` takes a boolean `(scan_row, scan_col)` array in +which True includes a position, and switches to mask mode; `clear_mask()` +clears the mask. A mask that includes every position, or none, averages the +full scan. + +### Probe + +The probe is a square of 2 · `probe_size` − 1 positions per side, centred on +`(probe_row, probe_col)`, the scan centre by default. Click or drag on the +image to move it; the curves follow. + +### Line + +Drag on the image to draw a line from A to B, drag an end to move it, or drag +the line to shift it. From Python, +`set_line(row0, col0, row1, col1, width=None)` sets it in `(row, col)` scan +coordinates and switches to line mode; `clear_line()` removes it. + +The band holds the positions within `line_width` / 2 of the line, with flat +ends. `line_width` is an odd number of scan pixels (1, 3, 5, ...): away from +the scan edge, a horizontal or vertical band of width w covers exactly w rows +or columns. The Width slider offers the odd widths up to 99 and extends to a +wider one set from Python. + +The ends of the sampled line snap to the nearest scan positions (halves round +up), and a line that leaves the scan is clipped at its edge. With ⟂ +(`line_perpendicular`), the widget samples the perpendicular bisector of the +drawn line instead (same midpoint and length, turned 90°), so a line traced +along a feature scans across it. + +`line_mode="averaged"` (avg) gives one PDF from the whole band; +`line_mode="linescan"` (scan) gives one PDF per position along the line. + +## Line scans + +A line scan has a position at every scan pixel along the line's longer axis, +evenly spaced from A to B, up to `linescan_max_bins` positions (256 by +default). + +Each scan position in the band is averaged into its nearest line-scan +position. The two end positions cover half a step, so they average fewer scan +positions. Each position's curve is what `calculate_Gr` gives for a mask of +its scan positions. + +The stack holds the curve of the current plot mode, so changing the plot mode +recomputes it; a g(r) stack needs the density. + +`linescan_view="heatmap"` shows every position, with A at the bottom and a +colorbar. `"waterfall"` stacks an evenly spaced subset of the positions that +ends at B, as close to `waterfall_count` curves (10 by default) as even +spacing allows. Each curve has the color of its position, with a matching dot +on the scan line. + +## Saving results + +| Method | Writes | +|---|---| +| `save_results(path, overwrite=False)` | A zip of the curves and settings (files below). `.zip` is added when missing, and an existing file raises `FileExistsError` unless `overwrite=True`. | +| `save_image(path, plot_mode=None)` | One curve as PNG, PDF, or TIFF, chosen by the file extension (or `format=`). It raises `ValueError` for a line scan, which has a curve per position, and for g(r) without a density. | +| `save(path)` | The settings, the selection, and the painted mask as JSON, which `ShowPDF(data, state=path)` restores. | +| `export_html(path=None, encoding="full")` | A standalone HTML viewer (HTML export, below). | + +The results zip holds: + +| File | Contents | +|---|---| +| `README.txt` | What each file holds and how to load it | +| `metadata.txt` | Mode, selection, and parameters, as plain text | +| `curves_k.csv` | Columns k, Ik, bg, Fk, where bg is B(k) | +| `curves_r.csv` | Columns r, Gr, gr; gr is empty without a density | +| `mask.npy` | The boolean scan mask, in mask mode only | +| `linescan_position.csv` | Line scans: each position's index and distance from A in scan pixels | +| `linescan_Ik.csv`, `linescan_Ik_bg.csv`, `linescan_Fk.csv`, `linescan_Gr.csv`, `linescan_gr.csv` | Line scans: an axis row (k or r), then one row per position; `linescan_gr.csv` only with a density | + +F(k) and G(r) are the curves the transform used, corrected when the damping +ran. Missing values are empty CSV cells. In a line scan, `curves_k.csv` and +`curves_r.csv` hold the average of the whole band, and saving before a line +is drawn raises `RuntimeError`. + +`state=` also takes the dict of `state_dict()`, and `load_state_dict` restores +one into a live widget. Keys it does not know are ignored, a mask saved for +another scan shape is dropped, and a rejected value leaves the widget +unchanged. + +A saved NaN or infinite `density_value` loads as not set (0). A saved even +`line_width` loads as the next odd width, with a warning, and a width below 1 +loads as 1. + +## Saved notebooks + +A saved notebook keeps the widget's settings and curves and a static picture of +it: the scan image beside the shown curve, or the line-scan stack. It leaves out +the scan image itself, the mask and the line-scan stack (`save_state=False`, the +default), so the file stays small at any scan size. Reopened without a kernel, +the widget shows the picture; rerunning the cell brings the interactive widget +back. With `save_state=True` the notebook keeps everything, and a reopened +notebook shows the full widget, although only a kernel, or an exported page, +recomputes its curves. `export_html` always embeds the scan image, mask and +stack. + +## HTML export + +`export_html(path=None, *, title=None, encoding="full")` writes one HTML file +that opens in a browser without a Python kernel; the Export HTML button builds +the same file and downloads it. Only the synced traits are embedded, never the +raw 4D-STEM data. The page loads the Jupyter widget manager from public CDNs, +so opening it needs a network connection. + +| `encoding` | Export HTML menu item | In the exported page | +|---|---|---| +| `"full"` | HTML exact float32 curves, fixed selection | The k-space, damping, and density controls recompute the curves from the exported I(k). The mask, probe, and line are fixed. | +| `"uint8"` | HTML + quantized uint8 profiles, live selection | Also embeds every scan position's radial profile at 8 bits per k bin, so the mask, probe, and line stay interactive too. | + +A `"full"` export of a line scan locks its parameter and plot-mode controls, +because a new stack needs the profiles. A `"full"` export made when no curves +were computed for the shown selection (its compute failed) keeps its parameter +controls locked too and shows the baked error. + +A `"uint8"` export opens with the exact exported curves. Once the selection, +a parameter, or the plot mode changes, it recomputes them from the 8-bit +profiles. + +The default file name is `_<rows>x<cols>_exact.html` in the working +directory, or `_quantized.html` for `"uint8"`. `mode` accepts only `"single"` +and `downsample` only `None` or 1. + +## Display options + +| Trait | Default | Effect | +|---|---|---| +| `plot_mode` | `"Gr"` | The curve shown: `"Ik"`, `"Fk"`, `"Gr"`, or `"gr"` | +| `show_background` | `True` | Draws B(k) on the I(k) plot | +| `cmap` | `"gray"` | Colormap of the navigation image; the menu offers gray, inferno, viridis, and magma | +| `log_scale` | `False` | Logarithmic navigation image (the I(k) Log switch is separate and not saved) | +| `auto_contrast` | `True` | Navigation image contrast from the 2nd to the 98th percentile, else from the minimum to the maximum | +| `show_stats` | `True` | Shows the included-position count under the image and the value readout under the plot | +| `show_controls` | `True` | Shows the parameter controls and the brush, probe, line, and Cmap controls | + +With `show_controls=False`, these stay: the mask, probe, and line buttons, the +mask tools (▭ ○ ✎, + / −, Clear, Invert), the plot-mode buttons, the plot +toolbar (Log, Background, Heatmap / Waterfall, N, Reset), and Export HTML. A +badge next to the title then lists the damping, k filters, or perpendicular +line sampling in effect, for example "Damped · Lowpass 0.020". + +ShowPDF does not take the shared `ui_mode`, `show_title`, or +`controls_collapsed` names of the [Viewer UI controls](viewer-ui). + +## Errors and limits + +The float parameters (`k_min_fit`, `k_max_fit`, `k_min_window`, +`k_max_window`, `k_lowpass`, `k_highpass`, `r_max`, `r_step`, `r_cut`, +`density_value`) and the line ends (`line_row0`, `line_col0`, `line_row1`, +`line_col1`) must be finite numbers, and the line ends must lie within ±1e9 +scan pixels. Any other value raises `TraitError` from the constructor, an +assignment, or `load_state_dict`, and `ValueError` from `set_line`. An +integer too large for a float (such as `10**400`) raises `OverflowError` from +the constructor or an assignment. + +The integer options (`max_iter`, `probe_row`, `probe_col`, `probe_size`, +`line_width`, `linescan_max_bins`, `waterfall_count`) must be whole numbers; +2.0 is accepted, 2.5 is not truncated. `line_width` must also be odd, +`linescan_max_bins` and `waterfall_count` at least 1, and `linescan_view` +"heatmap" or "waterfall". Other values raise `TraitError` from the +constructor, an assignment, or `load_state_dict`. A keyword that is neither a +parameter nor a trait raises `TypeError`. + +A NaN or infinite `pixel_size` raises `ValueError`, as does a dataset whose k +axis is not finite (or too large for float32); check the diffraction +calibration. `export_html` raises `ValueError` for a NaN or infinite float +trait, which is not valid JSON in the page, and writes no file. + +## Reference + +```{eval-rst} +.. autoclass:: quantem.widget.showpdf.ShowPDF + :members: + :show-inheritance: +``` + +## Interactive controls + +Each control mutates the listed synced trait; a view-only control changes +only the display. A UI-test agent acts on the control, then asserts the trait +changed and the canvas repainted (non-zero, no console error, no NaN frame). + +| Control | Trait | Expected effect | +|---|---|---| +| mask / probe / line buttons | `analysis_mode` | Switches how scan positions are selected; the curves recompute | +| ▭ ○ ✎ buttons (Rectangle, Circle, Freeform), then a drag on the image | `mask_tool`, `mask_b64`, `mask_version` | Paints the mask; the curves recompute on release | +| + / − button (key X) | `mask_b64`, `mask_version` | Switches between excluding and including paint, resetting the whole mask | +| Clear (key C), Invert (key I) | `mask_b64`, `mask_version` | Includes every position, or inverts the mask (the button also swaps + and −) | +| Brush slider | `mask_brush_size` | Freeform brush of 2·size − 1 positions per side, 1 to 20 | +| Probe click or drag | `probe_row`, `probe_col` | Moves the probe; the curves follow | +| Probe slider (on release) | `probe_size` | Probe square of 2·size − 1 positions per side, 1 to 100 | +| Line drag: draw, move an end, or move the line | `line_row0`…`line_col1`, `line_active`, `line_version` | Sets the line; an averaged line recomputes during the drag, a line scan on release | +| Width slider (on release) | `line_width` | Odd band widths from 1 to 99, or up to a wider one set from Python | +| ⟂ button | `line_perpendicular` | Samples along the perpendicular bisector of the drawn line | +| avg / scan buttons | `line_mode` | One averaged PDF, or a line scan | +| Clear button (line mode) | `line_active`, `line_row0`…`line_col1`, `line_version` | Removes the line | +| Cmap menu | `cmap` | Navigation image colormap | +| Image wheel, middle or Alt drag | view only | Zooms (0.5× to 32×) or pans the navigation image | +| I(k) / F(k) / G(r) / g(r) buttons (keys 1–3 for the first three) | `plot_mode` | Selects the curve; a line scan recomputes its stack | +| Log switch | view only | Logarithmic I(k) axis, on by default (I(k) mode) | +| Background switch | `show_background` | Shows or hides B(k) (I(k) mode) | +| Heatmap / Waterfall buttons, N field | `linescan_view`, `waterfall_count` | Line-scan rendering and number of stacked curves (line-scan mode) | +| Reset button (key R also resets the image zoom) | view only | Refits the plot axes | +| Curve plot drag, wheel, double-click | view only | Pans, zooms about the pointer, or refits the plot | +| Line-scan plot click, drag, double-click | view only | Adds a guide at constant r or k, moves it, or removes all guides; a click on a guide removes it | +| k fit slider | `k_min_fit`, `k_max_fit` | Refits the background; dashed lines mark the range on I(k) | +| k window slider | `k_min_window`, `k_max_window` | Moves the window on F(k); dashed lines mark it on F(k) | +| r max slider | `r_max` | End of the r grid, 1 to 50 Å | +| k lowpass, k highpass sliders | `k_lowpass`, `k_highpass` | F(k) filter widths, from 0 (off) to 0.1 1/Å | +| Damp switch | `damp_origin_oscillations` | Low-r correction (needs the density); shows the r_cut and iterations sliders | +| r_cut slider | `r_cut` | Upper end of the correction, on the r grid up to 5 Å or r max | +| iterations slider | `max_iter` | Maximum iterations of the correction, 1 to 30 | +| Density field (empty = not set) | `density_value` | g(r) and the damping; shown in g(r) mode or with damping on | +| Export HTML → HTML exact float32 curves, fixed selection / HTML + quantized uint8 profiles, live selection | `export_request`, `export_payload` | Downloads a standalone viewer (`encoding="full"` / `"uint8"`) | + +```{seealso} +The shared HTML-export contract is documented in [html-export](html-export). +``` diff --git a/docs/developer/save-state-and-notebook-size.md b/docs/developer/save-state-and-notebook-size.md index d54eb7a8e..edfcfc747 100644 --- a/docs/developer/save-state-and-notebook-size.md +++ b/docs/developer/save-state-and-notebook-size.md @@ -58,6 +58,7 @@ Six pieces, all in the widget class: | Show2D | `frame_bytes`, `panel_stack_bytes`, `export_payload`, `_detail_bytes` | the N panels in `_data` | | Show3D | `_offline_stack`, `_offline_float_stack`, `export_payload` | evenly-spaced slices of the z-stack | | Show4DSTEM | `_offline_stack`, `export_payload` | the virtual (BF/ADF) image | +| ShowPDF | `nav_image_bytes`, `mask_b64`, `mask_bytes`, `linescan_bytes`, `export_payload` | the scan image beside the shown curve or line-scan stack | ## Executing notebooks small (the other half) diff --git a/docs/install.md b/docs/install.md index adbaae048..e46fb1912 100644 --- a/docs/install.md +++ b/docs/install.md @@ -32,8 +32,10 @@ Requires Python 3.11 or newer. - `[cpu]` (and a plain install) brings torch 2.2 or newer, NumPy, h5py, hdf5plugin and [quantem](https://github.com/electronmicroscopy/quantem) core (hdf5plugin and quantem core not on Windows on ARM, quantem core not on an - Intel Mac, see below): every widget, and `Show4DSTEM` on in-memory arrays, - tensors on any device and files read densely into memory. + Intel Mac, see below): every widget (ShowPDF also needs quantem core with the + `PairDistributionFunction` of quantem pull request 248, see [ShowPDF](api/showpdf)), + and `Show4DSTEM` on in-memory arrays, tensors on any device and files read densely + into memory. - `[cuda]` adds [quantem.gpu](https://github.com/bobleesj/quantem.gpu) with CuPy; `[mps]` adds it with Metal and MLX. A 4D-STEM file then stays encoded on the GPU and loads in seconds. A wrong pick installs nothing harmful (`[cuda]` diff --git a/docs/intro.md b/docs/intro.md index 23d15731d..b11d5bac2 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -81,6 +81,7 @@ acquisitions stay encoded on the GPU at full detector resolution (about 0.1 to | `Show4DSTEM` | 4D-STEM: live virtual detectors, multi-master review, and WebGPU HTML export | [tutorial](tutorials/show4dstem) · [export](tutorials/show4dstem_export) · [API](api/show4dstem) | | `ShowPtycho` | Ptychography aberration review: phase, FFT, BF-count tradeoffs, and WebGPU folder export | [API](api/showptycho) | | `ShowDiffraction` | 2D/3D diffraction d-spacing: Bragg spots, rings, center finding, k calibration | [tutorial](tutorials/showdiffraction) · [API](api/showdiffraction) | +| `ShowPDF` | Pair distribution function analysis (I(k), F(k), G(r), g(r)) of masked, probe, or line regions of a 4D-STEM scan | [API](api/showpdf) | | `ShowCIF` | Unit cells, specimen tilt, and projected potential/phase previews | [tutorial](tutorials/showcif.ipynb) · [API](api/showcif.md) | | `ChooseLattice` | Pick an origin and two lattice vectors on a 2D image | [tutorial](tutorials/choose_lattice) · [API](api/choose-lattice) | diff --git a/docs/maintainer/widget-performance.md b/docs/maintainer/widget-performance.md index d75056448..1ad7a982a 100644 --- a/docs/maintainer/widget-performance.md +++ b/docs/maintainer/widget-performance.md @@ -180,6 +180,7 @@ evidence. | Show3DSlices | Browse volume slices and orthogonal views with synchronized crosshair/plane controls. | Slice sliders, crosshair movement, oblique line endpoint/body drags, side-plane redraw, oblique FFT redraw during line drag, FFT return-scrub cache hits for slice/oblique sliders, histogram controls, FFT/log/smooth toggles, and export reopen. | Browser smoke plus focused visual story when slice/crosshair/oblique-line behavior changes. | | Show4DSTEM | Inspect diffraction patterns and virtual images from real 4D-STEM datasets without loading unnecessary data. | Scan-position movement, detector drag, BF/ABF/ADF updates, compare pages, folder sessions over encoded acquisitions, and export reopen. | `tests/browser/test_show4dstem_live.py` drives a live kernel on the gold dataset; `scripts/e2e_fresh.py` checks a fresh wheel on a real master. Folder watching additionally needs a live-arrival run. Lightweight CI checks only the protocol. | | ShowDiffraction | Inspect diffraction-like 2D patterns when a full 4D-STEM session is not needed. | Zoom/pan, histogram/contrast, peak/FFT-style overlays when present, and export reopen. | Lightweight export/browser smoke; use Show4DSTEM heavy signoff for full detector workflows. | +| ShowPDF | Compute pair distribution functions for scan regions of an amorphous 4D-STEM dataset and tune the reduction. | Mask paint, probe drag, line drag, slider commits (each reruns the background fit and transform), line-scan stack rebuilds, and the exported file's in-browser recompute. | Focused pytest plus the NumPy parity Vitest; the `tests/browser` tier for exported HTML when export or the in-browser recompute changes. | ## Folder-watching performance contract diff --git a/docs/tutorials/showpdf.ipynb b/docs/tutorials/showpdf.ipynb new file mode 100644 index 000000000..9684ac97e --- /dev/null +++ b/docs/tutorials/showpdf.ipynb @@ -0,0 +1,226 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2505dba3", + "metadata": {}, + "source": [ + "# ShowPDF\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/showpdf.ipynb)\n", + "\n", + "`ShowPDF` computes the electron pair distribution function (ePDF) of a 4D-STEM scan with quantem's\n", + "`PairDistributionFunction`. On the scan image you select the positions to average; the curve panel\n", + "shows I(k) with its fitted background, F(k), G(r) or g(r) of that selection, and updates as you\n", + "change the settings.\n", + "\n", + "This tutorial uses a simulated 4D-STEM scan of an amorphous tantalum model\n", + "([Ding, Asta, and Ritchie (2017), DOI 10.1073/pnas.1705723114](https://doi.org/10.1073/pnas.1705723114)),\n", + "so the result can be compared with the model's own g(r). ShowPDF needs `PairDistributionFunction`\n", + "from quantem pull request 248, which is on quantem's dev branch until the next quantem release." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b8f3eeb0", + "metadata": {}, + "outputs": [], + "source": [ + "# @title Install QuantEM { display-mode: \"form\" }\n", + "if get_ipython().__class__.__module__.startswith(\"google.colab\"):\n", + " !wget -q https://raw.githubusercontent.com/electronmicroscopy/quantem.widget/main/scripts/install_colab.py -O install_quantem.py\n", + " %run install_quantem.py\n", + "\n", + "from quantem.widget import ShowPDF" + ] + }, + { + "cell_type": "markdown", + "id": "148dd1b1", + "metadata": {}, + "source": [ + "## Load the simulated scan\n", + "\n", + "`showpdf_ta` downloads the scan (about 150 MB, cached after the first call) and returns it\n", + "calibrated: a 20 × 20 scan of 307 × 307 patterns, with k in 1/Å (k = 1/d).\n", + "`showpdf_ta_ground_truth` returns the g(r) of the atomic model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5ecf77df", + "metadata": {}, + "outputs": [], + "source": [ + "from quantem.widget.datasets import showpdf_ta, showpdf_ta_ground_truth\n", + "\n", + "scan = showpdf_ta()\n", + "r_model, g_model = showpdf_ta_ground_truth()" + ] + }, + { + "cell_type": "markdown", + "id": "b0bcee9c", + "metadata": {}, + "source": [ + "## Number density\n", + "\n", + "ShowPDF never estimates the number density ρ₀; g(r) and the low-r correction of G(r) need it.\n", + "The simulated model holds 1,024,000 atoms in a cube of side 268.6053 Å." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a914893a", + "metadata": {}, + "outputs": [], + "source": [ + "rho0 = 1_024_000 / 268.6053**3 # atoms/ų\n", + "rho0" + ] + }, + { + "cell_type": "markdown", + "id": "0507cfa9", + "metadata": {}, + "source": [ + "## The whole scan\n", + "\n", + "With nothing selected, ShowPDF averages every scan position. The background fit starts at\n", + "k = 0.05 1/Å, and G(r) runs to 20 Å.\n", + "\n", + "The low-r correction makes G(r) follow −4πρ₀r from 0 to `r_cut`, a range with no atom pairs, so\n", + "`r_cut` must stay below the nearest-neighbour distance. The model's g(r) is zero below 2.4 Å,\n", + "so this tutorial uses 2.0 Å; the default, 0.8 Å, leaves a false peak near 1 Å here.\n", + "\n", + "The buttons above the curve switch between I(k), F(k), G(r) and g(r), and the sliders below it\n", + "change the fit range, the k window, the filters and the correction." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a7792b6", + "metadata": {}, + "outputs": [], + "source": [ + "widget = ShowPDF(\n", + " scan,\n", + " k_min_fit=0.05,\n", + " r_max=20.0,\n", + " density_value=rho0,\n", + " damp_origin_oscillations=True,\n", + " r_cut=2.0,\n", + " plot_mode=\"gr\",\n", + ")\n", + "widget" + ] + }, + { + "cell_type": "markdown", + "id": "8cfd328f", + "metadata": {}, + "source": [ + "## Compare with the atomic model\n", + "\n", + "`widget.pdf` is the `PairDistributionFunction` behind the widget, holding the curves it shows.\n", + "The peaks of the whole-scan g(r) sit where the model's do. They are lower and broader, partly\n", + "because the patterns end at k = 2.26 1/Å, where the window takes F(k) to zero." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4b30499d", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 3.5))\n", + "ax.plot(r_model, g_model, color=\"black\", linewidth=3, label=\"atomic model\")\n", + "ax.plot(widget.pdf.r, widget.pdf.pdf, color=\"tab:red\", label=\"ShowPDF, whole scan\")\n", + "ax.set_xlabel(\"r (Å)\")\n", + "ax.set_ylabel(\"g(r)\")\n", + "ax.legend()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "3837d9ae", + "metadata": {}, + "source": [ + "## A line scan\n", + "\n", + "Draw a line on the scan image, or set one with `set_line`, and switch from avg to scan\n", + "(`line_mode=\"linescan\"`): ShowPDF computes one curve per position along the line and shows them\n", + "as a heatmap or as stacked curves (`linescan_view=\"waterfall\"`). The band around the line is\n", + "`width` scan pixels wide, an odd number." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d378698a", + "metadata": {}, + "outputs": [], + "source": [ + "line_scan = ShowPDF(\n", + " scan,\n", + " k_min_fit=0.05,\n", + " r_max=20.0,\n", + " density_value=rho0,\n", + " damp_origin_oscillations=True,\n", + " r_cut=2.0,\n", + " plot_mode=\"gr\",\n", + " line_mode=\"linescan\",\n", + " linescan_view=\"waterfall\",\n", + ")\n", + "line_scan.set_line(3, 2, 15, 11, width=3)\n", + "line_scan" + ] + }, + { + "cell_type": "markdown", + "id": "3e52606a", + "metadata": {}, + "source": [ + "## Save and share\n", + "\n", + "`summary()` prints the selection and every setting. `save_results` writes the curves as CSV\n", + "files with the settings in one zip, and `save_image` saves the shown curve as an image.\n", + "`export_html` writes a standalone page; with `encoding=\"uint8\"`, changing the selection or a\n", + "setting in that page recomputes the curves, without Python." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dfa68470", + "metadata": {}, + "outputs": [], + "source": [ + "widget.summary()\n", + "widget.save_results(\"ta_pdf_results.zip\", overwrite=True)\n", + "widget.save_image(\"ta_gr.png\")\n", + "widget.export_html(\"ta_pdf.html\", encoding=\"uint8\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "quantem-widget", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/js/showpdf/index.tsx b/js/showpdf/index.tsx new file mode 100644 index 000000000..eb0ca7131 --- /dev/null +++ b/js/showpdf/index.tsx @@ -0,0 +1,3390 @@ +import * as React from "react"; +import { createRender, useModelState, useModel } from "@anywidget/react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import Button from "@mui/material/Button"; +import Switch from "@mui/material/Switch"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import Menu from "@mui/material/Menu"; +import Slider, { type SliderProps } from "@mui/material/Slider"; +import CircularProgress from "@mui/material/CircularProgress"; +import { alpha } from "@mui/material/styles"; +import { useTheme } from "../theme"; +import { useHideStaticFallback } from "../staticFallback"; +import { COLORMAPS, renderToOffscreen, renderToOffscreenReuse } from "../display/colormaps"; +import { findDataRange, percentileClip, applyLogScale } from "../display/stats"; +import { + downloadBlob, + extractBytes, + extractFloat32, + formatNumber, + preserveRestoredWidgetModelsOnSave, +} from "../format"; +import { exportTitleSlug, formatEstimatedHtmlSize, formatSavedBytes } from "../shared/exportFormat"; +import { computeGr, dampOriginOscillations, computeGofR, kWindow, resolveWindowBounds } from "./pdf_compute"; +import { + type LineEnds, + effectiveLineEnds, + lineBandColumns, + lineBandIncludes, + linescanSampleCount, + linescanSampleIndex, + sampleFraction, + sampledBinIndices, +} from "./linescan"; +import { committedFitEnd, committedWindowEnd } from "./kwindow"; +import { roundToNiceValue } from "../figure"; +import { drawNavScaleBar } from "./scalebar"; +import { rCutSlider } from "./rcut"; + +// ============================================================================ +// Constants +// ============================================================================ +const DPR = Math.max(1, window.devicePixelRatio || 1); +const SPACING = { XS: 4, SM: 8, MD: 12, LG: 16 }; +const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; +const MONO = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace"; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 32; +const CMAP_OPTIONS = ["gray", "inferno", "viridis", "magma"] as const; + +// Canvas sizes (CSS px); the nav image is NAV_SIZE wide. +const PLOT_W = 520; +const PLOT_H = 360; +const NAV_SIZE = PLOT_H; + +// Plot margins and ticks (CSS px). +const MARGIN_TOP = 12; +const MARGIN_RIGHT = 16; +const MARGIN_BOTTOM = 56; +const MARGIN_LEFT_MIN = 72; +const AXIS_TICK_PX = 4; +const TICK_LABEL_W = 50; +const PLOT_AREA_W = PLOT_W - MARGIN_LEFT_MIN - MARGIN_RIGHT; +const PLOT_AREA_H = PLOT_H - MARGIN_TOP - MARGIN_BOTTOM; + +const TRACE_COLORS = { + ik: "#4fc3f7", + bg: "#ef5350", + fk: "#66bb6a", + gr: "#4fc3f7", + pdf: "#ba68c8", +}; + +// Status hints, the same text as _DENSITY_HINT_DAMPING, _DENSITY_HINT_GR and _WINDOW_HINT in showpdf.py. +const DENSITY_HINT_DAMPING = "Set the density to apply damping and compute g(r)."; +const DENSITY_HINT_GR = "Set the density to compute g(r)."; +// An empty k window makes F(k) and G(r) zero up to a float32 residue at k max (g(r) about 1). +const WINDOW_HINT = "The k window contains no k points; set its lower end below its upper end, more than one k step apart."; +// Status lines of a failed compute. +const ERROR_STATUS = /^(Error|Line-scan error|g\(r\) error)/; + +// Options of the button groups: value, then label (and the mask tools' tooltip). +const ANALYSIS_MODES = ["mask", "probe", "line"] as const; +const MASK_TOOLS = [ + ["rectangle", "▭", "Rectangle"], + ["circle", "○", "Circle"], + ["freeform", "✎", "Freeform"], +] as const; +const LINE_MODES = [["averaged", "avg"], ["linescan", "scan"]] as const; +const PLOT_MODES = [["Ik", "I(k)"], ["Fk", "F(k)"], ["Gr", "G(r)"], ["gr", "g(r)"]] as const; +const LINESCAN_VIEWS = [["heatmap", "Heatmap"], ["waterfall", "Waterfall"]] as const; + +// ============================================================================ +// Style tokens +// ============================================================================ +const compactBtn = { fontSize: 12, minWidth: 36, px: 1, py: 0.25, textTransform: "none" as const }; +const switchSmall = { + "& .MuiSwitch-thumb": { width: 12, height: 12 }, + "& .MuiSwitch-switchBase": { padding: "4px" }, +}; +const sliderSmall = { "& .MuiSlider-thumb": { width: 10, height: 10 } }; +const parameterSliderSx = { flex: 1, "& .MuiSlider-thumb": { width: 10, height: 10 } }; +const numericSx = { fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" } as const; +// Keeps a label with its control when a row wraps. +const controlPairSx = { + display: "inline-flex", alignItems: "center", gap: `${SPACING.SM}px`, flexShrink: 0, +} as const; + +// ============================================================================ +// Types +// ============================================================================ +// Layout of the drawn line-scan plot, to map the pointer to r/k, position and value. +type HeatmapGeometry = { + rLo: number; // r/k at the left and right edges + rHi: number; + pwH: number; // plot width without the colorbar's room + lenUnits: number; // line length, in Å when calibrated + N: number; // samples along the line + M: number; // points per curve + data: Float32Array; // the N x M stack + waterfall: boolean; + wfPad: number; // waterfall padding at top and bottom + yLo: number; // heatmap position range + yHi: number; + sel: number[]; // samples drawn in the waterfall +}; + +// Image of a line's band for the band described by `key`; image null when the band is empty. +type BandOverlay = { key: string; image: HTMLCanvasElement | null; cLo: number; rLo: number }; + +// Pointer position and axis ranges when a plot pan started. +type PlotPanStart = { mx: number; my: number; xMin: number; xMax: number; yMin: number; yMax: number }; + +// Whether the scan position (r, c) is selected. +type ScanPredicate = (r: number, c: number) => boolean; + +// Values a control just committed, which this render's trait values do not include yet. +type OfflineOver = Partial<{ + kMinFit: number; kMaxFit: number; kMinWindow: number; kMaxWindow: number; + kLowpass: number; kHighpass: number; rMax: number; rCut: number; maxIter: number; + damp: boolean; densityValue: number; lineWidth: number; +}>; + +// ============================================================================ +// Helpers +// ============================================================================ +// --- Trait bytes --- +// Float64 trait bytes (the exported exact k axis); null when empty. +function extractFloat64(dataView: DataView): Float64Array | null { + const bytes = extractBytes(dataView); + if (bytes.length === 0 || bytes.length % 8 !== 0) return null; + return new Float64Array(bytes.slice().buffer); +} + +// Float32 trait bytes of an array. +function packF32(arr: ArrayLike<number>): DataView { + const f = new Float32Array(arr.length); + for (let i = 0; i < arr.length; i++) f[i] = arr[i]; + return new DataView(f.buffer); +} + +// Byte length of a trait value that may be unset. +function byteLength(v?: DataView | null): number { + return v?.byteLength ?? 0; +} + +// --- Mask --- +function fillRectMask( + mask: Uint8Array, w: number, h: number, + r0: number, c0: number, r1: number, c1: number, value: number, +) { + const minR = Math.max(0, Math.min(r0, r1)); + const maxR = Math.min(h - 1, Math.max(r0, r1)); + const minC = Math.max(0, Math.min(c0, c1)); + const maxC = Math.min(w - 1, Math.max(c0, c1)); + for (let r = minR; r <= maxR; r++) + for (let c = minC; c <= maxC; c++) + mask[r * w + c] = value; +} + +function fillCircleMask( + mask: Uint8Array, w: number, h: number, + cr: number, cc: number, radius: number, value: number, +) { + const r2 = radius * radius; + const rMin = Math.max(0, Math.floor(cr - radius)); + const rMax = Math.min(h - 1, Math.ceil(cr + radius)); + const cMin = Math.max(0, Math.floor(cc - radius)); + const cMax = Math.min(w - 1, Math.ceil(cc + radius)); + for (let r = rMin; r <= rMax; r++) + for (let c = cMin; c <= cMax; c++) + if ((r - cr) ** 2 + (c - cc) ** 2 <= r2) + mask[r * w + c] = value; +} + +// Mask decoded from mask_b64 (one byte per scan position); null when it does not fit the scan. +function decodeMask(maskB64: string, n: number): Uint8Array | null { + if (!maskB64) return null; + const bin = atob(maskB64); + if (bin.length !== n) return null; + const mask = new Uint8Array(n); + for (let i = 0; i < n; i++) mask[i] = bin.charCodeAt(i); + return mask; +} + +// Mask encoded for mask_b64, one byte per scan position (Bytes traits do not sync JS to Python +// in anywidget). +function encodeMask(mask: Uint8Array): string { + let binary = ""; + for (let i = 0; i < mask.length; i++) binary += String.fromCharCode(mask[i]); + return btoa(binary); +} + +// --- Scan and line geometry --- +// Scan pixel size in Å; a denormal or absurd one (some datasets carry ~1e-321) counts as uncalibrated. +function isCalibrated(unit: string, pixelSize: number): boolean { + return unit === "Å" && Number.isFinite(pixelSize) && pixelSize > 1e-6; +} + +// Length of a line in scan px. +function lineLength(line: LineEnds): number { + return Math.hypot(line.r1 - line.r0, line.c1 - line.c0); +} + +// Undirected orientation of a line in degrees: 0 horizontal, 90 vertical. +function lineOrientationDeg(line: LineEnds): number { + const angle = Math.atan2(-(line.r1 - line.r0), line.c1 - line.c0) * 180 / Math.PI; + return (angle % 180 + 180) % 180; +} + +// Pointer position relative to the element's top-left corner (CSS px). +function pointerOffset(el: HTMLElement, e: React.MouseEvent): { x: number; y: number } { + const rect = el.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; +} + +// Distance from the point (px, py) to the segment from (ax, ay) to (bx, by). +function distanceToSegment(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { + const dx = bx - ax, dy = by - ay; + const lenSq = dx * dx + dy * dy; + let t = lenSq > 0 ? ((px - ax) * dx + (py - ay) * dy) / lenSq : 0; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); +} + +// A shift of the segment with ends a and b (in [0, n - 1]), limited so both ends stay in range. +function clampShift(shift: number, a: number, b: number, n: number): number { + return Math.max(-Math.min(a, b), Math.min(n - 1 - Math.max(a, b), shift)); +} + +// --- Curves, controls and status --- +// The k window in effect: a window end <= 0 follows the fit range, as in calculate_Gr. +function windowInEffect( + kMinWindow: number, kMaxWindow: number, kMinFit: number, kMaxFit: number, +): [number, number] { + return [kMinWindow > 0 ? kMinWindow : kMinFit, kMaxWindow > 0 ? kMaxWindow : kMaxFit]; +} + +// Linear interpolation of the curve (xa ascending, ya) at x, held at its end values outside it. +function interpolate(xa: Float32Array, ya: Float32Array, x: number): number { + const n = Math.min(xa.length, ya.length); + if (x <= xa[0]) return ya[0]; + if (x >= xa[n - 1]) return ya[n - 1]; + let lo = 0; + let hi = n - 1; + while (hi - lo > 1) { + const m = (lo + hi) >> 1; + if (xa[m] <= x) lo = m; + else hi = m; + } + const span = xa[hi] - xa[lo]; + const t = span !== 0 ? (x - xa[lo]) / span : 0; + return ya[lo] + t * (ya[hi] - ya[lo]); +} + +// The plot's x axis is r for G(r) and g(r), k for I(k) and F(k). +function isRAxis(plotMode: string): boolean { + return plotMode === "Gr" || plotMode === "gr"; +} + +// The plotted curve's name (axis title) and symbol (readout). +function curveLabels(plotMode: string): { name: string; symbol: string } { + if (plotMode === "Ik") return { name: "I(k)", symbol: "I" }; + if (plotMode === "Fk") return { name: "F(k)", symbol: "F" }; + if (plotMode === "gr") return { name: "g(r)", symbol: "g" }; + return { name: "G(r)", symbol: "G" }; +} + +// Density input text: empty while unset (0), so the "required" placeholder shows. +function fmtDensity(v: number): string { + return v > 0 ? String(Number(v.toPrecision(4))) : ""; +} + +// Status hint while the density is unset and an output needs it. +function densityHint(density: number, damp: boolean, plotMode: string): string { + if (density > 0) return ""; + if (damp) return DENSITY_HINT_DAMPING; + if (plotMode === "gr") return DENSITY_HINT_GR; + return ""; +} + +// Enter leaves a number field, which commits it. +function blurOnEnter(e: React.KeyboardEvent<HTMLInputElement>) { + if (e.key === "Enter") e.currentTarget.blur(); +} + +// --- Offline recompute --- +// Profile row (or column) of scan row (or column) i, with `ratio` scan positions per profile +// position (1 unless the export binned the scan). +function profileIndex(i: number, ratio: number, count: number): number { + return ratio === 1 ? i : Math.min(count - 1, Math.floor(i / ratio)); +} + +// Add the quantized profile at `base`, dequantized per k bin as lo + q / 255 * (hi - lo), to acc. +function addProfile(acc: Float64Array, q: Uint8Array, base: number, lo: Float32Array, hi: Float32Array) { + for (let k = 0; k < acc.length; k++) acc[k] += lo[k] + (q[base + k] / 255) * (hi[k] - lo[k]); +} + +// --- Canvas drawing --- +function snap(v: number): number { + return Math.round(v) + 0.5; +} + +function computeTicks(min: number, max: number, maxTicks = 8): number[] { + const range = max - min; + if (range <= 0 || !isFinite(range)) return [min, max]; + const step = roundToNiceValue(range / maxTicks); + if (step <= 0) return [min, max]; + const start = Math.ceil(min / step) * step; + const ticks: number[] = []; + for (let v = start; v <= max + step * 0.001; v += step) { + if (v >= min - step * 0.001) ticks.push(v); + } + return ticks.length > 0 ? ticks : [min, max]; +} + +// Stroke one straight segment with the current style. +function strokeSegment(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number) { + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); +} + +// CSS color of the LUT entry at `offset` (its red byte; green and blue follow). +function lutRgb(lut: Uint8Array, offset: number): string { + return `rgb(${lut[offset]},${lut[offset + 1]},${lut[offset + 2]})`; +} + +// LUT offset of the waterfall color of sample `bin` of N: turbo by position along the line, its +// dark ends trimmed on a dark theme so the A and B curves stay visible. +function waterfallColorOffset(bin: number, N: number, isDark: boolean): number { + const t0 = isDark ? 0.06 : 0.0; + const t1 = isDark ? 0.94 : 1.0; + const t = N === 1 ? 0.5 : t0 + (bin / (N - 1)) * (t1 - t0); + return Math.round(t * 255) * 3; +} + +// ============================================================================ +// Nav overlay drawing (canvas px) +// ============================================================================ +// Zoom and pan about the canvas center, as the nav image is drawn. +function applyNavTransform( + ctx: CanvasRenderingContext2D, w: number, h: number, panX: number, panY: number, zoom: number, +) { + ctx.translate(w / 2 + panX * DPR, h / 2 + panY * DPR); + ctx.scale(zoom, zoom); + ctx.translate(-w / 2, -h / 2); +} + +// Probe footprint: the square of scan positions used, filled, and its inscribed circle. +function drawProbe( + ctx: CanvasRenderingContext2D, probe: { row: number; col: number }, size: number, + scX: number, scY: number, zoom: number, +) { + const half = Math.max(0, size - 1); + const side = 2 * half + 1; + const sx = (probe.col - half) * scX; + const sy = (probe.row - half) * scY; + const sw = side * scX; + const sh = side * scY; + ctx.fillStyle = "rgba(100,200,255,0.18)"; + ctx.fillRect(sx, sy, sw, sh); + ctx.strokeStyle = "rgba(100,200,255,1.0)"; + ctx.lineWidth = 2 / zoom; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.ellipse(sx + sw / 2, sy + sh / 2, sw / 2, sh / 2, 0, 0, Math.PI * 2); + ctx.stroke(); +} + +// Image of the scan positions a line's band selects, one pixel per position, over the band's +// bounding box; each row tests only the columns that can be in the band. +function renderBandImage( + line: LineEnds, width: number, scanRows: number, scanCols: number, +): Omit<BandOverlay, "key"> { + const inBand = lineBandIncludes(line, width); + if (!inBand) return { image: null, cLo: 0, rLo: 0 }; + const pad = width / 2 + 1; // past the band's half width + const rLo = Math.max(0, Math.floor(Math.min(line.r0, line.r1) - pad)); + const rHi = Math.min(scanRows - 1, Math.ceil(Math.max(line.r0, line.r1) + pad)); + const cLo = Math.max(0, Math.floor(Math.min(line.c0, line.c1) - pad)); + const cHi = Math.min(scanCols - 1, Math.ceil(Math.max(line.c0, line.c1) + pad)); + const bw = cHi - cLo + 1; + const bh = rHi - rLo + 1; + const image = document.createElement("canvas"); + image.width = bw; + image.height = bh; + const bctx = image.getContext("2d")!; + const pixels = bctx.createImageData(bw, bh); + for (let r = rLo; r <= rHi; r++) { + const [lo, hi] = lineBandColumns(line, width, r, scanCols); + for (let c = Math.max(lo, cLo); c <= Math.min(hi, cHi); c++) { + if (!inBand(r, c)) continue; + const i = ((r - rLo) * bw + (c - cLo)) * 4; + // rgba(100,200,255,0.20) + pixels.data[i] = 100; + pixels.data[i + 1] = 200; + pixels.data[i + 2] = 255; + pixels.data[i + 3] = 51; + } + } + bctx.putImageData(pixels, 0, 0); + return { image, cLo, rLo }; +} + +// The drawn line when ⟂ is on (dimmed, dashed), with square drag handles at its ends. +function drawReferenceLine( + ctx: CanvasRenderingContext2D, ax: number, ay: number, bx: number, by: number, zoom: number, +) { + ctx.strokeStyle = "rgba(180,180,180,0.75)"; + ctx.lineWidth = 1.5 / zoom; + ctx.setLineDash([4 / zoom, 3 / zoom]); + strokeSegment(ctx, ax, ay, bx, by); + ctx.setLineDash([]); + const half = 3.5 / zoom; + ctx.fillStyle = "rgba(200,200,200,0.95)"; + for (const [x, y] of [[ax, ay], [bx, by]]) { + ctx.beginPath(); + ctx.rect(x - half, y - half, half * 2, half * 2); + ctx.fill(); + } +} + +// The scan line from A to B, with an arrowhead at B so its direction is unambiguous. +function drawScanLine( + ctx: CanvasRenderingContext2D, ax: number, ay: number, bx: number, by: number, zoom: number, +) { + ctx.strokeStyle = "rgba(100,200,255,1.0)"; + ctx.lineWidth = 2 / zoom; + ctx.lineCap = "butt"; + ctx.setLineDash([]); + strokeSegment(ctx, ax, ay, bx, by); + const segLen = Math.hypot(bx - ax, by - ay); + if (segLen > 0) { + const ux = (bx - ax) / segLen, uy = (by - ay) / segLen; + const ah = 9 / zoom; + const px = -uy, py = ux; // perpendicular + ctx.fillStyle = "rgba(255,110,110,1)"; + ctx.beginPath(); + ctx.moveTo(bx, by); + ctx.lineTo(bx - ah * ux + ah * 0.5 * px, by - ah * uy + ah * 0.5 * py); + ctx.lineTo(bx - ah * ux - ah * 0.5 * px, by - ah * uy - ah * 0.5 * py); + ctx.closePath(); + ctx.fill(); + } +} + +// Labels A (green) and B (red) at the scan line's start and end, with dots unless the +// waterfall's sampling markers take their place. +function drawLineEnds( + ctx: CanvasRenderingContext2D, ax: number, ay: number, bx: number, by: number, zoom: number, + withDots: boolean, +) { + const hr = 4 / zoom; + const ends = [ + [ax, ay, "rgba(100,225,140,1)", "A"], + [bx, by, "rgba(255,110,110,1)", "B"], + ] as const; + for (const [ex, ey, color, label] of ends) { + ctx.fillStyle = color; + if (withDots) { + ctx.beginPath(); + ctx.ellipse(ex, ey, hr, hr, 0, 0, Math.PI * 2); + ctx.fill(); + } + ctx.font = `${12 / zoom}px ${FONT}`; + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + ctx.fillText(label, ex + hr + 1 / zoom, ey - hr); + } +} + +// Image of the mask, one pixel per scan position: excluded positions translucent red. +function renderMaskImage(mask: Uint8Array, cols: number, rows: number): HTMLCanvasElement { + const image = document.createElement("canvas"); + image.width = cols; + image.height = rows; + const mctx = image.getContext("2d")!; + const pixels = mctx.createImageData(cols, rows); + for (let i = 0; i < mask.length; i++) { + if (mask[i] === 0) { + pixels.data[i * 4] = 255; + pixels.data[i * 4 + 1] = 0; + pixels.data[i * 4 + 2] = 0; + pixels.data[i * 4 + 3] = 110; + } + } + mctx.putImageData(pixels, 0, 0); + return image; +} + +// ============================================================================ +// Plot drawing (CSS px) +// ============================================================================ +// The finite values among the first n of data. +function finiteValues(data: Float32Array, n: number): Float32Array { + let count = 0; + for (let i = 0; i < n; i++) if (isFinite(data[i])) count++; + const finite = new Float32Array(count); + let j = 0; + for (let i = 0; i < n; i++) { + const v = data[i]; + if (isFinite(v)) finite[j++] = v; + } + return finite; +} + +// Message shown in the plot while there is no line-scan stack to draw. +function linescanPlaceholder(computing: boolean, hasLine: boolean): string { + if (computing) return "Computing line-scan…"; + if (hasLine) return "No line-scan curves to show (see the status line)"; + return "Draw a line to compute the line-scan"; +} + +// A gray message centered at (x, y), for a plot with nothing to draw. +function drawPlotMessage(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, isDark: boolean) { + ctx.fillStyle = isDark ? "#888" : "#777"; + ctx.font = `14px ${FONT}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x, y); +} + +// The left and bottom axis lines of a plot area, on pixel centers. +function strokeAxes(ctx: CanvasRenderingContext2D, left: number, top: number, width: number, height: number) { + ctx.beginPath(); + ctx.moveTo(snap(left), top); + ctx.lineTo(snap(left), snap(top + height)); + ctx.lineTo(left + width, snap(top + height)); + ctx.stroke(); +} + +// Vertical colorbar of `lut` from vmin (bottom) to vmax (top), with the end and middle values. +function drawColorbar( + ctx: CanvasRenderingContext2D, lut: Uint8Array, vmin: number, vmax: number, + barX: number, barY: number, barH: number, isDark: boolean, +) { + const barW = 12; + for (let row = 0; row < barH; row++) { + const t = 1 - row / Math.max(1, barH - 1); + ctx.fillStyle = lutRgb(lut, Math.round(t * 255) * 3); + ctx.fillRect(barX, barY + row, barW, 1); + } + ctx.strokeStyle = isDark ? "#666" : "#999"; + ctx.lineWidth = 1; + ctx.strokeRect(snap(barX), snap(barY), barW, barH); + ctx.fillStyle = isDark ? "#aaa" : "#555"; + ctx.font = `10px ${MONO}`; + ctx.textAlign = "left"; + const labelX = barX + barW + 3; + ctx.textBaseline = "top"; + ctx.fillText(formatNumber(vmax), labelX, barY); + ctx.textBaseline = "middle"; + ctx.fillText(formatNumber((vmin + vmax) / 2), labelX, barY + barH / 2); + ctx.textBaseline = "bottom"; + ctx.fillText(formatNumber(vmin), labelX, barY + barH); +} + +// A value label boxed beside the point (cx, cy), moved to its left at the plot's right edge. +function drawValueLabel( + ctx: CanvasRenderingContext2D, label: string, cx: number, cy: number, right: number, + opacity: number, isDark: boolean, +) { + const top = MARGIN_TOP; + const bottom = MARGIN_TOP + PLOT_AREA_H; + ctx.font = `12px ${MONO}`; + const tw = ctx.measureText(label).width + 8; + let bx = cx + 10; + if (bx + tw > right) bx = cx - tw - 10; + const by = Math.max(top, Math.min(bottom - 18, cy - 22)); + ctx.fillStyle = isDark ? `rgba(30,30,30,${opacity})` : `rgba(255,255,255,${opacity})`; + ctx.fillRect(bx, by, tw, 18); + ctx.strokeStyle = isDark ? "#555" : "#ccc"; + ctx.lineWidth = 1; + ctx.strokeRect(bx, by, tw, 18); + ctx.fillStyle = isDark ? "#eee" : "#333"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText(label, bx + 4, by + 9); +} + +// The value in `values` nearest to `target` (the first of equally near ones). +function nearestValue(values: number[], target: number): number { + let best = values[0]; + for (const v of values) { + if (Math.abs(v - target) < Math.abs(best - target)) best = v; + } + return best; +} + +// r/k at canvas x on the line-scan plot. +function heatmapRAt(g: HeatmapGeometry, sx: number): number { + return g.rLo + ((sx - MARGIN_LEFT_MIN) / g.pwH) * (g.rHi - g.rLo); +} + +// Position along the line at canvas y on the line-scan plot; A (0) is at the bottom, and the +// waterfall spreads the line over the band inside its padding. +function heatmapPositionAt(g: HeatmapGeometry, sy: number): number { + const mT = MARGIN_TOP; + const ph = PLOT_AREA_H; + if (g.waterfall) return (((mT + ph - g.wfPad) - sy) / Math.max(1, ph - 2 * g.wfPad)) * g.lenUnits; + return g.yLo + (1 - (sy - mT) / ph) * (g.yHi - g.yLo); +} + +// Hover readout of the line-scan plot at canvas (sx, sy): r/k and the value of the nearest sample +// (in the waterfall, the nearest drawn curve), reported at that sample's own position. +function heatmapValueAt(g: HeatmapGeometry, sx: number, sy: number) { + const r = heatmapRAt(g, sx); + const pos = heatmapPositionAt(g, sy); + const rFrac = (r - g.rLo) / ((g.rHi - g.rLo) || 1); + const j = Math.max(0, Math.min(g.M - 1, Math.round(rFrac * (g.M - 1)))); + let bin = linescanSampleIndex(pos / (g.lenUnits || 1), g.N); + if (g.waterfall && g.sel.length) bin = nearestValue(g.sel, bin); + const val = g.data[bin * g.M + j]; + return { pos: sampleFraction(bin, g.N) * g.lenUnits, r, val }; +} + +// ============================================================================ +// Slider with guarded commits +// ============================================================================ +// CommitSlider passes MUI's commit on only after an onChange since the last commit or cancelled +// press, cancels the compatibility mouse events of every touch press, and ignores a change that +// only moves a single value set outside [min, max] to MUI's clamp of it. MUI commits its last +// onChange value at the end of every interaction, also one that changed nothing (a click that +// moves no thumb, a key at the end of the range), and that value can predate a change made +// underneath the slider (by Python, set_data, or the fit range for the k window). +function CommitSlider({ onChange, onChangeCommitted, ...props }: SliderProps) { + const changedRef = React.useRef(false); // onChange since the last commit or cancelled press + const pressRef = React.useRef(props.value); // the value shown when the press started + const sentRef = React.useRef(props.value); // the last value passed to onChange + const [generation, setGeneration] = React.useState(0); // remounts the slider after a cancel + const { value, min = 0, max = 100 } = props; + + // After a tap the browser repeats the press as mouse events, which MUI would take as a second + // press committing a second value. A pen keeps its mouse events, the only ones MUI gets from + // a pen in Chromium. + const handlePointerDown = (e: React.PointerEvent) => { + pressRef.current = value; + if (e.pointerType === "touch") e.preventDefault(); + }; + + // A cancelled touch press never commits (MUI ignores touchcancel): show the value it started + // from again, unless the value changed underneath, and remount the slider, since MUI keeps + // listening for the cancelled touch id. + const handlePointerCancel = (e: React.PointerEvent) => { + if (changedRef.current && value === sentRef.current) { + onChange?.(e.nativeEvent, pressRef.current as number | number[], 0); + } + changedRef.current = false; + setGeneration((g) => g + 1); + }; + + const handleChange = (e: Event, v: number | number[], thumb: number) => { + // A value Python set beyond the bound (r max 60) shows at the bound; a key towards the bound + // must not set it there, and ignoring the change keeps the readout showing the value. + const outOfRange = typeof value === "number" && (value < min || value > max); + if (outOfRange && v === Math.max(min, Math.min(value, max))) return; + changedRef.current = true; + sentRef.current = v; + onChange?.(e, v, thumb); + }; + + const handleChangeCommitted = (e: React.SyntheticEvent | Event, v: number | number[]) => { + if (!changedRef.current) return; + changedRef.current = false; + onChangeCommitted?.(e, v); + }; + + return ( + <Slider + key={generation} + {...props} + onPointerDown={handlePointerDown} + onPointerCancel={handlePointerCancel} + onChange={handleChange} + onChangeCommitted={handleChangeCommitted} + /> + ); +} + +// ============================================================================ +// Main component +// ============================================================================ +function ShowPDFWidget() { + const model = useModel(); + React.useEffect(() => preserveRestoredWidgetModelsOnSave(model), [model]); + // Standalone export (no kernel): the controls recompute the curves in JS. Exports render light, + // as the other widgets' exports do. + const [offline] = useModelState<boolean>("offline"); + const { themeInfo, colors } = useTheme(!!offline); + const isDark = themeInfo.theme === "dark"; + + // --- Model state --- + const [title] = useModelState<string>("title"); + const [scanRows] = useModelState<number>("scan_rows"); + const [scanCols] = useModelState<number>("scan_cols"); + // Standalone HTML export: the button sends a request, Python builds the file, the browser downloads it. + const [, setExportRequest] = useModelState<string>("export_request"); + const [exportStatus] = useModelState<string>("export_status"); + const [exportEnabled] = useModelState<boolean>("export_enabled"); + const [exportPayload] = useModelState<DataView>("export_payload"); + const [exportPayloadId] = useModelState<string>("export_payload_id"); + const [exportPayloadFilename] = useModelState<string>("export_filename"); + const [navImageBytes] = useModelState<DataView>("nav_image_bytes"); + const [navPixelSize] = useModelState<number>("nav_pixel_size"); + const [navUnit] = useModelState<string>("nav_unit"); + const [maskPixelCount, setMaskPixelCount] = useModelState<number>("mask_pixel_count"); + const [maskFraction, setMaskFraction] = useModelState<number>("mask_fraction"); + const [maskB64] = useModelState<string>("mask_b64"); + const [maskVersion, setMaskVersion] = useModelState<number>("mask_version"); + const [maskTool, setMaskTool] = useModelState<string>("mask_tool"); + const [maskBrushSize, setMaskBrushSize] = useModelState<number>("mask_brush_size"); + const [ikXBytes, setIkXBytes] = useModelState<DataView>("ik_x_bytes"); + const [ikYBytes, setIkYBytes] = useModelState<DataView>("ik_y_bytes"); + const [ikBgYBytes, setIkBgYBytes] = useModelState<DataView>("ik_bg_y_bytes"); + const [fkXBytes, setFkXBytes] = useModelState<DataView>("fk_x_bytes"); + const [fkYBytes, setFkYBytes] = useModelState<DataView>("fk_y_bytes"); + const [grXBytes, setGrXBytes] = useModelState<DataView>("gr_x_bytes"); + const [grYBytes, setGrYBytes] = useModelState<DataView>("gr_y_bytes"); + const [pdfXBytes, setPdfXBytes] = useModelState<DataView>("pdf_x_bytes"); + const [pdfYBytes, setPdfYBytes] = useModelState<DataView>("pdf_y_bytes"); + const [kMinFit, setKMinFit] = useModelState<number>("k_min_fit"); + const [kMaxFit, setKMaxFit] = useModelState<number>("k_max_fit"); + const [kMinWindow, setKMinWindow] = useModelState<number>("k_min_window"); + const [kMaxWindow, setKMaxWindow] = useModelState<number>("k_max_window"); + const [rMax, setRMax] = useModelState<number>("r_max"); + const [rStep] = useModelState<number>("r_step"); + const [kLowpass, setKLowpass] = useModelState<number>("k_lowpass"); + const [kHighpass, setKHighpass] = useModelState<number>("k_highpass"); + const [dampOrigin, setDampOrigin] = useModelState<boolean>("damp_origin_oscillations"); + // Quantized radial profile of every scan position (exports only), to recompute mask, probe and line. + const [offlineRecompute] = useModelState<boolean>("offline_recompute"); + const [offlineNk] = useModelState<number>("offline_nk"); + const [offlineRows] = useModelState<number>("offline_rows"); + const [offlineCols] = useModelState<number>("offline_cols"); + const [offlineProfiles] = useModelState<DataView>("offline_profiles"); + const [offlineProfilesLo] = useModelState<DataView>("offline_profiles_lo"); + const [offlineProfilesHi] = useModelState<DataView>("offline_profiles_hi"); + const [offlineQq] = useModelState<DataView>("offline_qq"); + const [rCut, setRCut] = useModelState<number>("r_cut"); + const [maxIter, setMaxIter] = useModelState<number>("max_iter"); + // Number density (atoms/ų) for g(r) and damping; 0 = not set (never estimated). + const [densityValue, setDensityValue] = useModelState<number>("density_value"); + const [kMinAvail] = useModelState<number>("k_min_available"); + const [kMaxAvail] = useModelState<number>("k_max_available"); + const [plotMode, setPlotMode] = useModelState<string>("plot_mode"); + const [showBackground, setShowBackground] = useModelState<boolean>("show_background"); + const [cmap, setCmap] = useModelState<string>("cmap"); + const [logScale] = useModelState<boolean>("log_scale"); + const [autoContrast] = useModelState<boolean>("auto_contrast"); + const [showStats] = useModelState<boolean>("show_stats"); + const [showControls] = useModelState<boolean>("show_controls"); + const [computing] = useModelState<boolean>("computing"); + const [statusMessage] = useModelState<string>("status_message"); + const [analysisMode, setAnalysisMode] = useModelState<string>("analysis_mode"); + // An export without the per-position profiles cannot recompute a new mask, probe or line + // (the 4D data is not in the file), so those controls are locked. + const lockMask = !!offline && !offlineRecompute; + const [probeRow, setProbeRow] = useModelState<number>("probe_row"); + const [probeCol, setProbeCol] = useModelState<number>("probe_col"); + const [probeSize, setProbeSize] = useModelState<number>("probe_size"); + const [lineRow0] = useModelState<number>("line_row0"); + const [lineCol0] = useModelState<number>("line_col0"); + const [lineRow1] = useModelState<number>("line_row1"); + const [lineCol1] = useModelState<number>("line_col1"); + const [lineWidth, setLineWidth] = useModelState<number>("line_width"); + const [lineActive] = useModelState<boolean>("line_active"); + const [lineMode, setLineMode] = useModelState<string>("line_mode"); + // The plot shows the line-scan stack (heatmap or waterfall) instead of one curve. + const isLinescan = analysisMode === "line" && lineMode === "linescan"; + // A curves-only export cannot rebuild a line-scan stack, and without an I(k) (its compute had + // failed) it cannot recompute anything, so its parameter controls are locked too. + const lockParameters = lockMask && (isLinescan || extractBytes(ikYBytes).length === 0); + const [linePerp, setLinePerp] = useModelState<boolean>("line_perpendicular"); + const [lineScanView, setLineScanView] = useModelState<string>("linescan_view"); + const [waterfallCount, setWaterfallCount] = useModelState<number>("waterfall_count"); + const [linescanBytes, setLinescanBytes] = useModelState<DataView>("linescan_bytes"); + const [nLinescan, setNLinescan] = useModelState<number>("n_linescan"); + const [linescanNPoints, setLinescanNPoints] = useModelState<number>("linescan_n_points"); + const [linescanAxisBytes, setLinescanAxisBytes] = useModelState<DataView>("linescan_axis_bytes"); + const [linescanMaxBins] = useModelState<number>("linescan_max_bins"); + + // --- Local state --- + const navH = Math.round(NAV_SIZE * (scanRows / Math.max(scanCols, 1))); + const [navZoom, setNavZoom] = React.useState(1); + const [navPanX, setNavPanX] = React.useState(0); + const [navPanY, setNavPanY] = React.useState(0); + const [maskAction, setMaskAction] = React.useState<"add" | "subtract">("add"); + const [maskRenderVersion, setMaskRenderVersion] = React.useState(0); + // Corners of the mask shape being dragged (scan coords). + const [shapePreview, setShapePreview] = React.useState<LineEnds | null>(null); + const [plotXMin, setPlotXMin] = React.useState(0); + const [plotXMax, setPlotXMax] = React.useState(10); + const [plotYMin, setPlotYMin] = React.useState(-1); + const [plotYMax, setPlotYMax] = React.useState(1); + const [ikLogScale, setIkLogScale] = React.useState(true); + const [cursorData, setCursorData] = React.useState<{ x: number; y: number } | null>(null); + // Heatmap hover readout: position along the line, r/k, and the stack value there. + const [hmHover, setHmHover] = React.useState<{ pos: number; r: number; val: number } | null>(null); + const [localProbe, setLocalProbe] = React.useState({ row: probeRow, col: probeCol }); + const probeGenRef = React.useRef(0); + const isDraggingProbeRef = React.useRef(false); + // Line ends (scan coords): a local copy of the model's, for smooth dragging. + const [localLine, setLocalLine] = React.useState<LineEnds>({ + r0: lineRow0, c0: lineCol0, r1: lineRow1, c1: lineCol1, + }); + const localLineRef = React.useRef(localLine); + const lineGenRef = React.useRef(0); + const lineVersionRef = React.useRef(0); + const [localLineWidth, setLocalLineWidth] = React.useState(lineWidth); + // Largest line width seen in this view: the Width slider's range keeps it, so narrowing a width + // above 99 (set from Python) does not shrink the range. + const [maxLineWidthSeen, setMaxLineWidthSeen] = React.useState(lineWidth); + if (lineWidth > maxLineWidthSeen) setMaxLineWidthSeen(lineWidth); + const isDrawingLineRef = React.useRef(false); + const isDraggingLineBodyRef = React.useRef(false); + const draggingEndpointRef = React.useRef<0 | 1 | null>(null); + const lineDrawStartRef = React.useRef<{ row: number; col: number } | null>(null); + const lineBodyStartRef = React.useRef<{ imgX: number; imgY: number; line: LineEnds } | null>(null); + // Heatmap guides (vertical lines at constant r/k), and the plot layout to map a click to r/k. + const [hmGuides, setHmGuides] = React.useState<number[]>([]); + const heatmapGeomRef = React.useRef<HeatmapGeometry | null>(null); + const draggingGuideRef = React.useRef<{ index: number; moved: boolean; added: boolean } | null>(null); + // Control values while they are edited; the effects below copy the model's values in. + const [localKFit, setLocalKFit] = React.useState<[number, number]>([kMinFit, kMaxFit]); + const [localKWin, setLocalKWin] = React.useState(windowInEffect(kMinWindow, kMaxWindow, kMinFit, kMaxFit)); + const [localRMax, setLocalRMax] = React.useState(rMax); + const [localKLowpass, setLocalKLowpass] = React.useState(kLowpass); + const [localKHighpass, setLocalKHighpass] = React.useState(kHighpass); + const [localRCut, setLocalRCut] = React.useState(rCut); + const [localMaxIter, setLocalMaxIter] = React.useState(maxIter); + const [localDensity, setLocalDensity] = React.useState(fmtDensity(densityValue)); + // Typed into since the density field got focus or the model's density replaced its text. + const densityEditedRef = React.useRef(false); + const [localProbeSize, setLocalProbeSize] = React.useState(probeSize); + const [localWfCount, setLocalWfCount] = React.useState(String(waterfallCount)); + const [dataVersion, setDataVersion] = React.useState(0); + // Bumped when the offscreen nav image is redrawn; a counter of its own, since that redraw + // depends on dataVersion. + const [navRenderVersion, setNavRenderVersion] = React.useState(0); + + React.useEffect(() => { setLocalKFit([kMinFit, kMaxFit]); }, [kMinFit, kMaxFit]); + React.useEffect(() => { + setLocalKWin(windowInEffect(kMinWindow, kMaxWindow, kMinFit, kMaxFit)); + }, [kMinWindow, kMaxWindow, kMinFit, kMaxFit]); + React.useEffect(() => { setLocalRMax(rMax); }, [rMax]); + React.useEffect(() => { setLocalKLowpass(kLowpass); }, [kLowpass]); + React.useEffect(() => { setLocalKHighpass(kHighpass); }, [kHighpass]); + React.useEffect(() => { setLocalRCut(rCut); }, [rCut]); + React.useEffect(() => { setLocalMaxIter(maxIter); }, [maxIter]); + // The model's density replaces the field's text, and with it any edit. + React.useEffect(() => { + setLocalDensity(fmtDensity(densityValue)); + densityEditedRef.current = false; + }, [densityValue]); + React.useEffect(() => { setLocalProbeSize(probeSize); }, [probeSize]); + React.useEffect(() => { setLocalWfCount(String(waterfallCount)); }, [waterfallCount]); + React.useEffect(() => { setLocalProbe({ row: probeRow, col: probeCol }); }, [probeRow, probeCol]); + React.useEffect(() => { + const nl = { r0: lineRow0, c0: lineCol0, r1: lineRow1, c1: lineCol1 }; + localLineRef.current = nl; + setLocalLine(nl); + }, [lineRow0, lineCol0, lineRow1, lineCol1]); + React.useEffect(() => { setLocalLineWidth(lineWidth); }, [lineWidth]); + // The line actually sampled: the drawn line or, when line_perpendicular is on, its perpendicular + // bisector, with both ends snapped to scan positions (as ShowPDF._effective_line). + function effectiveLine(l: LineEnds) { + return effectiveLineEnds(l, linePerp, scanRows, scanCols); + } + // Length (scan px) and orientation readout of the line actually sampled, as drawn on the scan. + const sampledLine = effectiveLine(localLine); + const lineLenPx = lineLength(sampledLine); + const lineOrientDeg = lineOrientationDeg(sampledLine); + + // --- Refs --- + // The user zoomed or panned the plot, so new curves keep the view instead of refitting it. + const userZoomedRef = React.useRef(false); + const navCanvasRef = React.useRef<HTMLCanvasElement>(null); + const navUiRef = React.useRef<HTMLCanvasElement>(null); // HiDPI overlay for the scale bar + const navOverlayRef = React.useRef<HTMLCanvasElement>(null); + const navOffscreenRef = React.useRef<HTMLCanvasElement | null>(null); + const bandOverlayRef = React.useRef<BandOverlay | null>(null); + const navImgDataRef = React.useRef<ImageData | null>(null); + const rawNavRef = React.useRef<Float32Array | null>(null); + const maskRef = React.useRef<Uint8Array | null>(null); + const plotCanvasRef = React.useRef<HTMLCanvasElement>(null); + const ikXRef = React.useRef<Float32Array | null>(null); + const ikYRef = React.useRef<Float32Array | null>(null); + const ikBgRef = React.useRef<Float32Array | null>(null); + const fkXRef = React.useRef<Float32Array | null>(null); + const fkYRef = React.useRef<Float32Array | null>(null); + const grXRef = React.useRef<Float32Array | null>(null); + const grYRef = React.useRef<Float32Array | null>(null); + const pdfXRef = React.useRef<Float32Array | null>(null); + const pdfYRef = React.useRef<Float32Array | null>(null); + const isPanningRef = React.useRef(false); + const panStartRef = React.useRef<{ x: number; y: number; px: number; py: number } | null>(null); + const isPaintingRef = React.useRef(false); + const shapeStartRef = React.useRef<{ row: number; col: number } | null>(null); + const isDraggingShapeRef = React.useRef(false); + const isPlotPanRef = React.useRef(false); + const plotPanStartRef = React.useRef<PlotPanStart | null>(null); + + // ========================================================================= + // Nav image and its overlay + // ========================================================================= + // Parse the nav image; size the offscreen canvas and the mask to the scan. + React.useEffect(() => { + if (!navImageBytes || navImageBytes.byteLength < 4) return; + const raw = extractFloat32(navImageBytes); + if (!raw) return; + rawNavRef.current = raw; + const offscreen = navOffscreenRef.current; + if (!offscreen || offscreen.width !== scanCols || offscreen.height !== scanRows) { + const oc = document.createElement("canvas"); + oc.width = scanCols; + oc.height = scanRows; + navOffscreenRef.current = oc; + navImgDataRef.current = new ImageData(scanCols, scanRows); + } + if (!maskRef.current || maskRef.current.length !== scanRows * scanCols) { + maskRef.current = new Uint8Array(scanRows * scanCols).fill(1); + } + setDataVersion((v) => v + 1); + }, [navImageBytes, scanRows, scanCols]); + + // The mask from the model: mask_b64 is the mask Python analyzes, so the overlay shows it after + // this view paints and after Python changes it (set_mask, clear_mask, set_data, load_state_dict). + React.useEffect(() => { + const n = scanRows * scanCols; + const decoded = decodeMask(maskB64, n); + if (decoded) maskRef.current = decoded; + else if (n > 0) maskRef.current = new Uint8Array(n).fill(1); + setMaskRenderVersion((v) => v + 1); + }, [maskB64, scanRows, scanCols]); + + // Render the nav image to the offscreen canvas (expensive). + React.useEffect(() => { + const raw = rawNavRef.current; + const oc = navOffscreenRef.current; + const imgData = navImgDataRef.current; + if (!raw || !oc || !imgData) return; + const lut = COLORMAPS[cmap] || COLORMAPS.inferno; + const processed = logScale ? applyLogScale(raw) : raw; + let vmin: number, vmax: number; + if (autoContrast) { + ({ vmin, vmax } = percentileClip(processed, 2, 98)); + } else { + const range = findDataRange(processed); + vmin = range.min; + vmax = range.max; + } + renderToOffscreenReuse(processed, lut, vmin, vmax, oc, imgData); + setNavRenderVersion((v) => v + 1); + }, [cmap, logScale, autoContrast, dataVersion]); + + // Draw the offscreen image, zoomed and panned (cheap). + React.useLayoutEffect(() => { + const cvs = navCanvasRef.current; + const oc = navOffscreenRef.current; + if (!cvs || !oc || oc.width === 0) return; + const w = NAV_SIZE * DPR; + const h = navH * DPR; + cvs.width = w; + cvs.height = h; + const ctx = cvs.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, w, h); + ctx.save(); + applyNavTransform(ctx, w, h, navPanX, navPanY, navZoom); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(oc, 0, 0, scanCols, scanRows, 0, 0, w, h); + ctx.restore(); + }, [navRenderVersion, navZoom, navPanX, navPanY, navH, scanCols, scanRows]); + + // Scale bar, on the HiDPI overlay. + React.useEffect(() => { + const cvs = navUiRef.current; + if (!cvs) return; + cvs.width = NAV_SIZE * DPR; + cvs.height = navH * DPR; + // In nm (1 nm = 10 Å) when the scan is calibrated, otherwise in pixels. + const calibrated = isCalibrated(navUnit, navPixelSize); + const pxSize = calibrated ? navPixelSize / 10 : (navPixelSize || 1); + const unit = calibrated ? "nm" : "px"; + drawNavScaleBar(cvs, DPR, navZoom, pxSize, unit, scanCols); + }, [navZoom, navPanX, navPanY, navPixelSize, navUnit, scanCols, scanRows, navH]); + + // Overlay: the probe, the line, or the mask with the shape being dragged. + React.useLayoutEffect(() => { + const cvs = navOverlayRef.current; + if (!cvs) return; + const w = NAV_SIZE * DPR; + const h = navH * DPR; + cvs.width = w; + cvs.height = h; + const ctx = cvs.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, w, h); + const scX = (NAV_SIZE * DPR) / Math.max(scanCols, 1); // canvas px per scan px + const scY = (navH * DPR) / Math.max(scanRows, 1); + // The band image (up to scan size) is kept only while a line band is drawn. + if (analysisMode !== "line") bandOverlayRef.current = null; + + if (analysisMode === "probe") { + ctx.save(); + applyNavTransform(ctx, w, h, navPanX, navPanY, navZoom); + drawProbe(ctx, localProbe, localProbeSize, scX, scY, navZoom); + ctx.restore(); + return; + } + + if (analysisMode === "line") { + // The drawn line (its ends are the drag handles) and the scan line (its bisector when ⟂). + const ll = localLine; + const noLine = ll.r0 === ll.r1 && ll.c0 === ll.c1; + if (!lineActive && !isDrawingLineRef.current && noLine) { + bandOverlayRef.current = null; + return; + } + const sl = effectiveLine(ll); + const refAx = (ll.c0 + 0.5) * scX, refAy = (ll.r0 + 0.5) * scY; + const refBx = (ll.c1 + 0.5) * scX, refBy = (ll.r1 + 0.5) * scY; + const ax = (sl.c0 + 0.5) * scX, ay = (sl.r0 + 0.5) * scY; // scan start (A) + const bx = (sl.c1 + 0.5) * scX, by = (sl.r1 + 0.5) * scY; // scan end (B) + ctx.save(); + applyNavTransform(ctx, w, h, navPanX, navPanY, navZoom); + // Without ⟂ the drawn line is the scan line. + if (linePerp) drawReferenceLine(ctx, refAx, refAy, refBx, refBy, navZoom); + // The band image is cached per band, so pan and zoom redraws reuse it. + const bandKey = `${sl.r0},${sl.c0},${sl.r1},${sl.c1},${localLineWidth},${scanRows},${scanCols}`; + if (bandOverlayRef.current?.key !== bandKey) { + bandOverlayRef.current = { key: bandKey, ...renderBandImage(sl, localLineWidth, scanRows, scanCols) }; + } + const band = bandOverlayRef.current; + if (band.image) { + // Unsmoothed, so each selected scan position is a whole cell, as in the mask overlay. + const { image, cLo, rLo } = band; + ctx.imageSmoothingEnabled = false; + ctx.drawImage( + image, 0, 0, image.width, image.height, + cLo * scX, rLo * scY, image.width * scX, image.height * scY, + ); + } + drawScanLine(ctx, ax, ay, bx, by, navZoom); + // In the waterfall the only dots are its sampling markers, so they match the curve colors. + const wfMarkers = lineMode === "linescan" && lineScanView === "waterfall" && nLinescan > 0; + drawLineEnds(ctx, ax, ay, bx, by, navZoom, !wfMarkers); + if (wfMarkers) { + // A dot where each stacked curve is taken, in its curve's color; a thin white ring keeps + // it legible on the image. + const lut = COLORMAPS.turbo || COLORMAPS.viridis; + const mr = 4 / navZoom; + for (const bin of sampledBinIndices(nLinescan, Math.max(1, waterfallCount))) { + const f = sampleFraction(bin, nLinescan); + const mx = ax + f * (bx - ax); + const my = ay + f * (by - ay); + ctx.beginPath(); + ctx.ellipse(mx, my, mr, mr, 0, 0, Math.PI * 2); + ctx.fillStyle = lutRgb(lut, waterfallColorOffset(bin, nLinescan, isDark)); + ctx.fill(); + ctx.lineWidth = 1.2 / navZoom; + ctx.strokeStyle = "rgba(255,255,255,0.9)"; + ctx.stroke(); + } + } + ctx.restore(); + return; + } + + // Mask: excluded positions translucent red, and the outline of a shape being dragged. + const mask = maskRef.current; + if (!mask || mask.length === 0) return; + const maskImage = renderMaskImage(mask, scanCols, scanRows); + ctx.save(); + applyNavTransform(ctx, w, h, navPanX, navPanY, navZoom); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(maskImage, 0, 0, scanCols, scanRows, 0, 0, w, h); + ctx.restore(); + if (shapePreview && isDraggingShapeRef.current) { + const { r0, c0, r1, c1 } = shapePreview; + ctx.save(); + applyNavTransform(ctx, w, h, navPanX, navPanY, navZoom); + ctx.strokeStyle = maskAction === "add" ? "rgba(100,200,255,0.9)" : "rgba(255,100,100,0.9)"; + ctx.lineWidth = 2 / navZoom; + ctx.setLineDash([4 / navZoom, 4 / navZoom]); + const sx = Math.min(c0, c1) * scX; + const sy = Math.min(r0, r1) * scY; + const sw = Math.abs(c1 - c0) * scX; + const sh = Math.abs(r1 - r0) * scY; + if (maskTool === "circle") { + ctx.beginPath(); + ctx.ellipse((c0 + c1) / 2 * scX, (r0 + r1) / 2 * scY, sw / 2, sh / 2, 0, 0, Math.PI * 2); + ctx.stroke(); + } else { + ctx.strokeRect(sx, sy, sw, sh); + } + ctx.restore(); + } + }, [ + maskRenderVersion, navZoom, navPanX, navPanY, navH, scanRows, scanCols, shapePreview, maskAction, + maskTool, analysisMode, localProbe, localProbeSize, localLine, localLineWidth, lineActive, linePerp, + lineMode, lineScanView, waterfallCount, nLinescan, isDark, + ]); + + // ========================================================================= + // Plot + // ========================================================================= + // Parse the curves and fit the axes. A layout effect declared before the plot's draw effect, + // so the plot draws the curves just received. + const grEmptyRef = React.useRef(true); + React.useLayoutEffect(() => { + ikXRef.current = ikXBytes ? extractFloat32(ikXBytes) : null; + ikYRef.current = ikYBytes ? extractFloat32(ikYBytes) : null; + ikBgRef.current = ikBgYBytes ? extractFloat32(ikBgYBytes) : null; + fkXRef.current = fkXBytes ? extractFloat32(fkXBytes) : null; + fkYRef.current = fkYBytes ? extractFloat32(fkYBytes) : null; + grXRef.current = grXBytes ? extractFloat32(grXBytes) : null; + grYRef.current = grYBytes ? extractFloat32(grYBytes) : null; + pdfXRef.current = pdfXBytes ? extractFloat32(pdfXBytes) : null; + pdfYRef.current = pdfYBytes ? extractFloat32(pdfYBytes) : null; + // g(r) arriving in the empty g(r) plot (the density was just set) is fitted, even if that + // plot was zoomed. + const grEmpty = !pdfXRef.current || pdfXRef.current.length === 0; + if (plotMode === "gr" && grEmptyRef.current && !grEmpty) userZoomedRef.current = false; + grEmptyRef.current = grEmpty; + if (!userZoomedRef.current) autoFitPlot(); + }, [ikXBytes, ikYBytes, ikBgYBytes, fkXBytes, fkYBytes, grXBytes, grYBytes, pdfXBytes, pdfYBytes]); + + React.useEffect(() => { refitPlot(); }, [plotMode]); + // r/k units change with plot mode, so existing guide positions become invalid. + React.useEffect(() => { setHmGuides([]); }, [plotMode]); + + // The x and y arrays of the curve the plot mode shows. + function shownCurve(): [Float32Array | null, Float32Array | null] { + if (plotMode === "Ik") return [ikXRef.current, ikYRef.current]; + if (plotMode === "Fk") return [fkXRef.current, fkYRef.current]; + if (plotMode === "gr") return [pdfXRef.current, pdfYRef.current]; + return [grXRef.current, grYRef.current]; + } + + // Fit the axes to the shown curve. logOverride lets the Log switch refit with its new value, + // which ikLogScale does not hold yet in the switch's handler. + function autoFitPlot(logOverride?: boolean) { + const [xArr, yArr] = shownCurve(); + if (plotMode === "gr" && (!xArr || xArr.length === 0)) { + // No g(r) (the density is unset): the r grid of G(r) and a neutral g(r) range. + const r = grXRef.current; + if (r && r.length > 0) { + const rR = findDataRange(r); + setPlotXMin(rR.min); + setPlotXMax(rR.max); + } + setPlotYMin(0); + setPlotYMax(2); + return; + } + if (!xArr || !yArr || xArr.length === 0) return; + const xR = findDataRange(xArr); + const useLog = plotMode === "Ik" && (logOverride ?? ikLogScale); + const yR = findDataRange(yArr); + setPlotXMin(xR.min); + setPlotXMax(xR.max); + if (useLog) { + const logMax = yR.max > 0 ? Math.log10(yR.max) : 0; + // About 6 decades below the max when the data reach 0 or below (no log), never inverted. + let logMin = yR.min > 0 ? Math.log10(yR.min) : logMax - 6; + if (!(logMin < logMax)) logMin = logMax - 1; + const logPad = (logMax - logMin) * 0.05 || 0.1; + setPlotYMin(logMin - logPad); + setPlotYMax(logMax + logPad); + } else { + const yPad = (yR.max - yR.min) * 0.05 || 0.1; + setPlotYMin(yR.min - yPad); + setPlotYMax(yR.max + yPad); + } + } + + // Forget the user's zoom and fit the axes to the shown curve. + function refitPlot(logOverride?: boolean) { + userZoomedRef.current = false; + autoFitPlot(logOverride); + } + + // Value of the shown curve at x (linear interpolation). + function curveValueAt(x: number): number | null { + const [xa, ya] = shownCurve(); + if (!xa || !ya || xa.length === 0) return null; + return interpolate(xa, ya, x); + } + + // Line-scan plot: a heatmap of r/k (x) against position along the line (y), colored by the + // curve value, or the stacked curves of the waterfall. + function drawLinescanPlot(ctx: CanvasRenderingContext2D) { + const mL = MARGIN_LEFT_MIN; + const mT = MARGIN_TOP; + const ph = PLOT_AREA_H; + const waterfall = lineScanView === "waterfall"; + // The heatmap keeps room on the right for its value colorbar. The waterfall has none: its + // colors encode position, which the markers on the scan line show. + const colorbarRoom = waterfall ? 0 : 52; + const pwH = PLOT_AREA_W - colorbarRoom; + ctx.fillStyle = isDark ? "#1a1a1a" : "#f8f8f8"; + ctx.fillRect(0, 0, PLOT_W, PLOT_H); + const N = nLinescan; + const M = linescanNPoints; + const ready = N > 0 && M > 0 && !!linescanBytes && linescanBytes.byteLength >= N * M * 4; + if (!ready) { + heatmapGeomRef.current = null; + const hasLine = lineActive && (localLine.r0 !== localLine.r1 || localLine.c0 !== localLine.c1); + drawPlotMessage(ctx, linescanPlaceholder(computing, hasLine), mL + pwH / 2, mT + ph / 2, isDark); + return; + } + const data = extractFloat32(linescanBytes); + if (!data) return; + const hasAxis = !!linescanAxisBytes && linescanAxisBytes.byteLength >= M * 4; + const axis = hasAxis ? extractFloat32(linescanAxisBytes) : null; + const { vmin, vmax } = percentileClip(finiteValues(data, N * M), 1, 99); + const lut = COLORMAPS.viridis || COLORMAPS.inferno; + // Length of the line actually sampled (its ends snapped to scan positions). + const lenScan = lineLength(effectiveLine(localLine)); + const calibrated = isCalibrated(navUnit, navPixelSize); + const lenUnits = calibrated ? lenScan * navPixelSize : lenScan; + const rLo = axis ? axis[0] : 0; + const rHi = axis ? axis[M - 1] : 1; + const hx = (rv: number) => mL + ((rv - rLo) / ((rHi - rLo) || 1)) * pwH; // r/k → x + // The waterfall pads top and bottom, so the outermost curves and their deflection do not clip. + const selW = waterfall ? sampledBinIndices(N, Math.max(1, waterfallCount)) : []; + const Kw = Math.max(1, selW.length); + const wfPad = waterfall ? Math.min(0.24 * ph, Math.max(8, (2.2 * ph) / (Kw + 4.4))) : 0; + const yBot = mT + ph - wfPad; + const yInner = ph - 2 * wfPad; + // Heatmap rows are centered on the samples (evenly spaced from A at 0 to the tip B, see + // linescan.ts), so the position axis reaches half a step beyond both ends. + const posOf = (bin: number) => sampleFraction(bin, N) * lenUnits; + const step = N > 1 ? posOf(1) - posOf(0) : lenUnits; + const yLo = N > 1 ? posOf(0) - step / 2 : 0; + const yHi = N > 1 ? posOf(N - 1) + step / 2 : lenUnits; + // position → y + const hy = (pos: number) => { + if (waterfall) return yBot - (pos / (lenUnits || 1)) * yInner; + return mT + ph - ((pos - yLo) / ((yHi - yLo) || 1)) * ph; + }; + heatmapGeomRef.current = { rLo, rHi, pwH, lenUnits, N, M, data, waterfall, wfPad, yLo, yHi, sel: selW }; + + if (waterfall) { + // One curve per drawn sample, in its position's color. The amplitude scale is the 2-98 + // percentile range of the drawn curves, not their min/max, so a big first peak does not + // wash out the smaller oscillations. + const svals: number[] = []; + for (const bin of selW) { + for (let j = 0; j < M; j++) { + const v = data[bin * M + j]; + if (isFinite(v)) svals.push(v); + } + } + const pr = svals.length ? percentileClip(Float32Array.from(svals), 2, 98) : { vmin: 0, vmax: 1 }; + const pRange = (pr.vmax - pr.vmin) || 1; + const vmid = (pr.vmin + pr.vmax) / 2; + const lane = yInner / Kw; + const amp = (2.2 * lane) / pRange; // stretch small features + const maxDefl = 2.0 * lane; // soft-saturation scale for tall peaks + // turbo separates adjacent curves by hue. + const wlut = COLORMAPS.turbo || lut; + // r/k of point j: from the axis, or spread evenly over [rLo, rHi] without one. + const rAt = (j: number) => { + if (axis) return axis[j]; + return M > 1 ? rLo + (j / (M - 1)) * (rHi - rLo) : rLo; + }; + ctx.save(); + ctx.beginPath(); + ctx.rect(mL, mT, pwH, ph); + ctx.clip(); + // Back (top) to front (bottom). + for (let s = Kw - 1; s >= 0; s--) { + const bin = selW[s]; + const baseY = hy(posOf(bin)); + ctx.beginPath(); + for (let j = 0; j < M; j++) { + const xv = hx(rAt(j)); + const v = data[bin * M + j]; + const dyRaw = (isFinite(v) ? v - vmid : 0) * amp; + // Soft saturation: linear for small features, tall peaks rounded off within the lane. + const dy = maxDefl * Math.tanh(dyRaw / maxDefl); + const yv = baseY - dy; + if (j === 0) ctx.moveTo(xv, yv); + else ctx.lineTo(xv, yv); + } + ctx.strokeStyle = lutRgb(wlut, waterfallColorOffset(bin, N, isDark)); + ctx.lineWidth = 1.4; + ctx.stroke(); + } + ctx.restore(); + } else { + // Heatmap image, M (r/k, left to right) x N; row 0 is the end B, so position increases upward. + const img = new Float32Array(M * N); + for (let bin = 0; bin < N; bin++) { + for (let j = 0; j < M; j++) { + const v = data[bin * M + j]; + img[(N - 1 - bin) * M + j] = isFinite(v) ? v : vmin; + } + } + const off = renderToOffscreen(img, M, N, lut, vmin, vmax); + if (off) { + ctx.imageSmoothingEnabled = true; + ctx.drawImage(off, 0, 0, M, N, mL, mT, pwH, ph); + } + } + + // Guides: vertical lines at constant r/k, to check how straight features are. + for (const gv of hmGuides) { + const cx = snap(hx(gv)); + if (cx < mL || cx > mL + pwH) continue; + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.8)"; + ctx.lineWidth = 1; + ctx.setLineDash([5, 4]); + strokeSegment(ctx, cx, mT, cx, mT + ph); + ctx.setLineDash([]); + ctx.fillStyle = isDark ? "#eee" : "#222"; + ctx.font = `10px ${MONO}`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(formatNumber(gv), cx, mT + 2); + } + + // Axes, ticks (x: r/k, y: position) and titles. + ctx.strokeStyle = isDark ? "#666" : "#999"; + ctx.lineWidth = 1; + ctx.setLineDash([]); + strokeAxes(ctx, mL, mT, pwH, ph); + ctx.fillStyle = isDark ? "#aaa" : "#555"; + ctx.font = `13px ${FONT}`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (const tv of computeTicks(rLo, rHi, Math.max(3, Math.floor(pwH / TICK_LABEL_W)))) { + const cx = snap(hx(tv)); + if (cx < mL - 1 || cx > mL + pwH + 1) continue; + strokeSegment(ctx, cx, mT + ph, cx, mT + ph + AXIS_TICK_PX); + ctx.fillText(formatNumber(tv), cx, mT + ph + AXIS_TICK_PX + 2); + } + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (const tv of computeTicks(0, lenUnits, Math.max(3, Math.floor(ph / 40)))) { + const cy = snap(hy(tv)); + if (cy < mT - 1 || cy > mT + ph + 1) continue; + strokeSegment(ctx, mL, cy, mL - AXIS_TICK_PX, cy); + ctx.fillText(formatNumber(tv), mL - AXIS_TICK_PX - 2, cy); + } + ctx.font = `14px ${FONT}`; + ctx.fillStyle = isDark ? "#ccc" : "#333"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(isRAxis(plotMode) ? "r (Å)" : "k (Å⁻¹)", mL + pwH / 2, mT + ph + AXIS_TICK_PX + 26); + ctx.save(); + ctx.translate(14, mT + ph / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(calibrated ? "position (Å)" : "position (px)", 0, 0); + ctx.restore(); + + // B and A, tying the plot to the ends of the scan line. + ctx.font = `11px ${MONO}`; + ctx.textAlign = "left"; + ctx.fillStyle = "rgba(255,110,110,1)"; + ctx.textBaseline = "top"; + ctx.fillText("B", mL + 4, mT + 3); + ctx.fillStyle = "rgba(100,225,140,1)"; + ctx.textBaseline = "bottom"; + ctx.fillText("A", mL + 4, mT + ph - 3); + if (!waterfall) drawColorbar(ctx, lut, vmin, vmax, mL + pwH + 14, mT, ph, isDark); + + // Hover crosshair and readout: r/k, position → value. + if (hmHover) { + const cx = snap(hx(hmHover.r)); + const cy = snap(hy(hmHover.pos)); + if (cx >= mL && cx <= mL + pwH && cy >= mT && cy <= mT + ph) { + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)"; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + strokeSegment(ctx, cx, mT, cx, mT + ph); + strokeSegment(ctx, mL, cy, mL + pwH, cy); + ctx.setLineDash([]); + const r = formatNumber(hmHover.r); + const pos = formatNumber(hmHover.pos); + const val = formatNumber(hmHover.val); + drawValueLabel(ctx, `${r}, ${pos} → ${val}`, cx, cy, mL + pwH, 0.92, isDark); + } + } + if (computing) { + ctx.fillStyle = isDark ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.3)"; + ctx.fillRect(mL, mT, pwH, ph); + } + } + + // The plot mode's curve, with its grid, axes, fit or window range, crosshair and readout. + function drawCurvePlot(ctx: CanvasRenderingContext2D) { + const mL = MARGIN_LEFT_MIN; + const mT = MARGIN_TOP; + const pw = PLOT_AREA_W; + const ph = PLOT_AREA_H; + const xMin = plotXMin, xMax = plotXMax, yMin = plotYMin, yMax = plotYMax; + const xRange = xMax - xMin || 1; + const yRange = yMax - yMin || 1; + const d2cx = (dx: number) => mL + ((dx - xMin) / xRange) * pw; + const useLogY = plotMode === "Ik" && ikLogScale; + // Axis value → canvas y (yMin and yMax are already log10 values on a log axis). + const d2cy = (dy: number) => mT + ph - ((dy - yMin) / yRange) * ph; + // Data value → canvas y, through log10 on a log axis. + const d2cyData = (dy: number) => { + let v = dy; + if (useLogY) v = dy > 0 ? Math.log10(dy) : yMin; + return mT + ph - ((v - yMin) / yRange) * ph; + }; + + ctx.fillStyle = isDark ? "#1a1a1a" : "#f8f8f8"; + ctx.fillRect(0, 0, PLOT_W, PLOT_H); + // Grid + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; + ctx.lineWidth = 1; + ctx.setLineDash([2, 3]); + const xTicks = computeTicks(xMin, xMax, Math.max(3, Math.floor(pw / TICK_LABEL_W))); + for (const tv of xTicks) { + const cx = snap(d2cx(tv)); + strokeSegment(ctx, cx, mT, cx, mT + ph); + } + const yTicks = computeTicks(yMin, yMax, Math.max(3, Math.floor(ph / 40))); + for (const tv of yTicks) { + const cy = snap(d2cy(tv)); + strokeSegment(ctx, mL, cy, mL + pw, cy); + } + ctx.setLineDash([]); + // Axes and ticks + ctx.strokeStyle = isDark ? "#666" : "#999"; + ctx.lineWidth = 1; + strokeAxes(ctx, mL, mT, pw, ph); + ctx.fillStyle = isDark ? "#aaa" : "#555"; + ctx.font = `13px ${FONT}`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (const tv of xTicks) { + const cx = snap(d2cx(tv)); + strokeSegment(ctx, cx, mT + ph, cx, mT + ph + AXIS_TICK_PX); + ctx.fillText(formatNumber(tv), cx, mT + ph + AXIS_TICK_PX + 2); + } + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + let yTickW = 0; // widest y tick label + for (const tv of yTicks) { + const cy = snap(d2cy(tv)); + strokeSegment(ctx, mL, cy, mL - AXIS_TICK_PX, cy); + const text = useLogY ? formatNumber(Math.pow(10, tv)) : formatNumber(tv); + ctx.fillText(text, mL - AXIS_TICK_PX - 2, cy); + yTickW = Math.max(yTickW, ctx.measureText(text).width); + } + // Axis titles; the y title moves left of wide tick labels (log I(k): "100.00"). + ctx.font = `14px ${FONT}`; + ctx.fillStyle = isDark ? "#ccc" : "#333"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(isRAxis(plotMode) ? "r (Å)" : "k (Å⁻¹)", mL + pw / 2, mT + ph + AXIS_TICK_PX + 26); + const yTitleX = Math.max(2, Math.min(14, mL - AXIS_TICK_PX - 2 - yTickW - 18)); + ctx.save(); + ctx.translate(yTitleX, mT + ph / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(curveLabels(plotMode).name, 0, 0); + ctx.restore(); + + // Traces, clipped to the plot area. + ctx.save(); + ctx.beginPath(); + ctx.rect(mL, mT, pw, ph); + ctx.clip(); + const drawTrace = (xD: Float32Array | null, yD: Float32Array | null, color: string, dashed = false) => { + if (!xD || !yD) return; + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + if (dashed) ctx.setLineDash([4, 3]); + else ctx.setLineDash([]); + ctx.beginPath(); + let started = false; + const len = Math.min(xD.length, yD.length); + for (let i = 0; i < len; i++) { + if (!isFinite(yD[i])) continue; + const cx = d2cx(xD[i]); + const cy = d2cyData(yD[i]); + if (!started) { + ctx.moveTo(cx, cy); + started = true; + } else { + ctx.lineTo(cx, cy); + } + } + ctx.stroke(); + ctx.setLineDash([]); + }; + // Dashed vertical lines at the ends of the fit or window range, labelled. + const drawKRange = (k0: number, k1: number, color: string, label0: string, label1: string) => { + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 2.5; + ctx.setLineDash([14, 6]); + ctx.fillStyle = color; + ctx.font = `11px ${MONO}`; + ctx.textBaseline = "top"; + for (const [kv, lbl, align] of [[k0, label0, "left"], [k1, label1, "right"]] as const) { + if (!isFinite(kv)) continue; + const cx = snap(d2cx(kv)); + if (cx < mL || cx > mL + pw) continue; + strokeSegment(ctx, cx, mT, cx, mT + ph); + ctx.textAlign = align; + const tx = align === "left" ? cx + 3 : cx - 3; + ctx.fillText(lbl, tx, mT + 2); + } + ctx.restore(); + }; + // Dashed horizontal line at axis value y (g(r) = 1, G(r) = 0). + const drawLevel = (y: number) => { + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)"; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + const cy = d2cy(y); + strokeSegment(ctx, mL, cy, mL + pw, cy); + ctx.setLineDash([]); + }; + if (plotMode === "Ik") { + drawTrace(ikXRef.current, ikYRef.current, TRACE_COLORS.ik); + if (showBackground) drawTrace(ikXRef.current, ikBgRef.current, TRACE_COLORS.bg, true); + drawKRange(kMinFit, kMaxFit, "#ffb300", "k fit", ""); + } else if (plotMode === "Fk") { + drawTrace(fkXRef.current, fkYRef.current, TRACE_COLORS.fk); + const [winLo, winHi] = windowInEffect(kMinWindow, kMaxWindow, kMinFit, kMaxFit); + drawKRange(winLo, winHi, "#ffb300", "k window", ""); + } else if (plotMode === "gr") { + if (pdfXRef.current && pdfXRef.current.length > 0) { + drawLevel(1); + drawTrace(pdfXRef.current, pdfYRef.current, TRACE_COLORS.pdf); + } else if (!(densityValue > 0)) { + // No g(r) without a density: say so in the plot, not only in the status line. + drawPlotMessage(ctx, DENSITY_HINT_GR, mL + pw / 2, mT + ph / 2, isDark); + } + } else { + drawLevel(0); + drawTrace(grXRef.current, grYRef.current, TRACE_COLORS.gr); + } + ctx.restore(); + + // Crosshair; the readout snaps to the shown curve's value at the cursor's x. + if (cursorData) { + const cval = curveValueAt(cursorData.x); + const cx = d2cx(cursorData.x); + if (cval !== null && isFinite(cval) && cx >= mL && cx <= mL + pw) { + const cy = d2cyData(cval); + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.25)" : "rgba(0,0,0,0.25)"; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + strokeSegment(ctx, snap(cx), mT, snap(cx), mT + ph); + ctx.setLineDash([]); + if (cy >= mT && cy <= mT + ph) { + ctx.fillStyle = isDark ? "#eee" : "#333"; + ctx.beginPath(); + ctx.ellipse(cx, cy, 3, 3, 0, 0, Math.PI * 2); + ctx.fill(); + } + const label = `${formatNumber(cursorData.x)}, ${formatNumber(cval)}`; + drawValueLabel(ctx, label, cx, cy, mL + pw, 0.9, isDark); + } + } + if (computing) { + ctx.fillStyle = isDark ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.3)"; + ctx.fillRect(mL, mT, pw, ph); + } + } + + // Draw the plot: the line-scan stack, or the plot mode's curve. The dependencies are the + // values the two draw functions read. + React.useLayoutEffect(() => { + const cvs = plotCanvasRef.current; + if (!cvs) return; + cvs.width = PLOT_W * DPR; + cvs.height = PLOT_H * DPR; + const ctx = cvs.getContext("2d"); + if (!ctx) return; + ctx.scale(DPR, DPR); + if (analysisMode === "line" && lineMode === "linescan") drawLinescanPlot(ctx); + else drawCurvePlot(ctx); + }, [ + plotXMin, plotXMax, plotYMin, plotYMax, plotMode, showBackground, ikLogScale, cursorData, computing, + isDark, ikXBytes, ikYBytes, ikBgYBytes, fkXBytes, fkYBytes, grXBytes, grYBytes, pdfXBytes, pdfYBytes, + kMinFit, kMaxFit, kMinWindow, kMaxWindow, densityValue, + analysisMode, lineMode, lineScanView, waterfallCount, nLinescan, linescanNPoints, linescanBytes, + linescanAxisBytes, localLine, linePerp, navPixelSize, navUnit, lineActive, hmGuides, hmHover, + ]); + + // ========================================================================= + // Nav: coordinates, mask, probe and line edits + // ========================================================================= + // Pointer position in un-zoomed canvas (image) coordinates, where the overlay draws. + function screenToImgXY(e: React.MouseEvent) { + const cvs = navCanvasRef.current; + if (!cvs) return { imgX: 0, imgY: 0 }; + const { x: sx, y: sy } = pointerOffset(cvs, e); + const cx = NAV_SIZE / 2, cy = navH / 2; + return { imgX: (sx - cx - navPanX) / navZoom + cx, imgY: (sy - cy - navPanY) / navZoom + cy }; + } + + // The scan pixel under the pointer (pixel r covers [r, r + 1); positions are drawn at r + 0.5). + function screenToImage(e: React.MouseEvent) { + if (!navCanvasRef.current) return { row: 0, col: 0 }; + const { imgX, imgY } = screenToImgXY(e); + return { + row: Math.max(0, Math.min(scanRows - 1, Math.floor((imgY / navH) * scanRows))), + col: Math.max(0, Math.min(scanCols - 1, Math.floor((imgX / NAV_SIZE) * scanCols))), + }; + } + + // Image coordinates of a scan position's pixel center, as the overlay draws it. + function scanToImgXY(row: number, col: number) { + return { + imgX: (col + 0.5) / Math.max(scanCols, 1) * NAV_SIZE, + imgY: (row + 0.5) / Math.max(scanRows, 1) * navH, + }; + } + + const maskVersionRef = React.useRef(maskVersion ?? 0); + // Send the mask to Python, which recomputes; an export with the profiles recomputes in JS. + function syncMaskToPython() { + if (!maskRef.current) return; + if (offlineReady) { + recomputeOfflineAll({}); + return; + } + model.set("mask_b64", encodeMask(maskRef.current)); + model.save_changes(); + // Bump mask_version to trigger the Python observer, from the model's value: another view of + // the same widget may have bumped it already. + maskVersionRef.current = ((model.get("mask_version") as number) ?? maskVersionRef.current) + 1; + setMaskVersion(maskVersionRef.current); + } + + // Mask edits of the keys and buttons; each redraws the overlay and syncs the mask. + function clearMask() { + if (!maskRef.current) return; + maskRef.current.fill(1); + setMaskRenderVersion((v) => v + 1); + syncMaskToPython(); + } + + function invertMask() { + const mask = maskRef.current; + if (!mask) return; + for (let i = 0; i < mask.length; i++) mask[i] = mask[i] ? 0 : 1; + setMaskRenderVersion((v) => v + 1); + syncMaskToPython(); + } + + // The Invert button also swaps add and subtract (the I key inverts only). + const handleInvertClick = () => { + if (!maskRef.current) return; + invertMask(); + setMaskAction((a) => (a === "add" ? "subtract" : "add")); + }; + + // Switching between add and subtract resets the whole mask: all positions included before + // adding (painting excludes), all excluded before subtracting (painting re-includes). + function switchMaskAction() { + setMaskAction((a) => { + if (maskRef.current) { + maskRef.current.fill(a === "add" ? 0 : 1); + setMaskRenderVersion((v) => v + 1); + syncMaskToPython(); + } + return a === "add" ? "subtract" : "add"; + }); + } + + // "+" adds to the mask (excludes positions from the analysis: 0); "−" re-includes them (1). + const maskPaintValue = maskAction === "add" ? 0 : 1; + + // Paint the freeform brush (a square of 2·size−1 positions) at a scan position. + function paintBrush(mask: Uint8Array, row: number, col: number) { + const half = maskBrushSize - 1; + fillRectMask(mask, scanCols, scanRows, row - half, col - half, row + half, col + half, maskPaintValue); + setMaskRenderVersion((v) => v + 1); + } + + // Live probe move, coalesced to at most one model update per display frame. + function syncProbeRAF(row: number, col: number) { + const r = Math.max(0, Math.min(scanRows - 1, row)); + const c = Math.max(0, Math.min(scanCols - 1, col)); + setLocalProbe({ row: r, col: c }); + const gen = ++probeGenRef.current; + requestAnimationFrame(() => { + if (gen !== probeGenRef.current) return; + if (r !== probeRow) setProbeRow(r); + if (c !== probeCol) setProbeCol(c); + }); + } + + // Set the local line, clamped to the scan, and return it. + function applyLineLocal(line: LineEnds) { + const clampR = (v: number) => Math.max(0, Math.min(scanRows - 1, v)); + const clampC = (v: number) => Math.max(0, Math.min(scanCols - 1, v)); + const nl = { r0: clampR(line.r0), c0: clampC(line.c0), r1: clampR(line.r1), c1: clampC(line.c1) }; + localLineRef.current = nl; + setLocalLine(nl); + return nl; + } + + // Send the line's ends with a single line_version bump, so Python recomputes once. + function commitLine(nl: LineEnds) { + model.set("line_row0", nl.r0); + model.set("line_col0", nl.c0); + model.set("line_row1", nl.r1); + model.set("line_col1", nl.c1); + if (!lineActive) model.set("line_active", true); + lineVersionRef.current = ((model.get("line_version") as number) ?? lineVersionRef.current) + 1; + model.set("line_version", lineVersionRef.current); + model.save_changes(); + // No kernel: recompute the line or line-scan in JS. + if (offlineReady) recomputeOfflineAll({}); + } + + // During a drag the overlay follows every move. The averaged line also commits live, at most + // once per frame; the line-scan's heavy recompute waits for mouse-up. + function dragLine(line: LineEnds) { + const nl = applyLineLocal(line); + if (lineMode !== "linescan") { + const gen = ++lineGenRef.current; + requestAnimationFrame(() => { + if (gen === lineGenRef.current) commitLine(nl); + }); + } + } + + function clearLine() { + isDrawingLineRef.current = false; + isDraggingLineBodyRef.current = false; + draggingEndpointRef.current = null; + const nl = { r0: 0, c0: 0, r1: 0, c1: 0 }; + localLineRef.current = nl; + setLocalLine(nl); + model.set("line_row0", 0); + model.set("line_col0", 0); + model.set("line_row1", 0); + model.set("line_col1", 0); + model.set("line_active", false); + lineVersionRef.current = ((model.get("line_version") as number) ?? 0) + 1; + model.set("line_version", lineVersionRef.current); + model.save_changes(); + } + + // A press in line mode grabs an end or the body of the line, or starts drawing a new line. + function startLineGesture(e: React.MouseEvent, row: number, col: number) { + const hit = 8 / navZoom; // hit radius in image px + const { imgX, imgY } = screenToImgXY(e); + if (lineActive) { + const e0 = scanToImgXY(localLineRef.current.r0, localLineRef.current.c0); + const e1 = scanToImgXY(localLineRef.current.r1, localLineRef.current.c1); + const d0 = Math.hypot(imgX - e0.imgX, imgY - e0.imgY); + const d1 = Math.hypot(imgX - e1.imgX, imgY - e1.imgY); + if (d0 < hit || d1 < hit) { + draggingEndpointRef.current = d0 <= d1 ? 0 : 1; + return; + } + if (distanceToSegment(imgX, imgY, e0.imgX, e0.imgY, e1.imgX, e1.imgY) < hit) { + isDraggingLineBodyRef.current = true; + lineBodyStartRef.current = { imgX, imgY, line: { ...localLineRef.current } }; + return; + } + } + isDrawingLineRef.current = true; + lineDrawStartRef.current = { row, col }; + dragLine({ r0: row, c0: col, r1: row, c1: col }); + } + + // ========================================================================= + // Nav mouse handlers + // ========================================================================= + const handleNavMouseDown = (e: React.MouseEvent) => { + if (!maskRef.current) return; + e.preventDefault(); + // Middle button, or Alt with the left button, pans. + if (e.button === 1 || (e.button === 0 && e.altKey)) { + isPanningRef.current = true; + panStartRef.current = { x: e.clientX, y: e.clientY, px: navPanX, py: navPanY }; + return; + } + if (lockMask) return; // a locked selection blocks painting and the probe and line drags + const { row, col } = screenToImage(e); + if (analysisMode === "line") { + startLineGesture(e, row, col); + return; + } + if (analysisMode === "probe") { + isDraggingProbeRef.current = true; + syncProbeRAF(row, col); + return; + } + if (maskTool === "freeform") { + isPaintingRef.current = true; + paintBrush(maskRef.current, row, col); + } else { + isDraggingShapeRef.current = true; + shapeStartRef.current = { row, col }; + setShapePreview({ r0: row, c0: col, r1: row, c1: col }); + } + }; + + const handleNavMouseMove = (e: React.MouseEvent) => { + if (isPanningRef.current && panStartRef.current) { + setNavPanX(panStartRef.current.px + e.clientX - panStartRef.current.x); + setNavPanY(panStartRef.current.py + e.clientY - panStartRef.current.y); + return; + } + const { row, col } = screenToImage(e); + if (isDraggingProbeRef.current) { + syncProbeRAF(row, col); + return; + } + if (isDrawingLineRef.current && lineDrawStartRef.current) { + const s = lineDrawStartRef.current; + dragLine({ r0: s.row, c0: s.col, r1: row, c1: col }); + return; + } + if (draggingEndpointRef.current !== null) { + const moved = draggingEndpointRef.current === 0 ? { r0: row, c0: col } : { r1: row, c1: col }; + dragLine({ ...localLineRef.current, ...moved }); + return; + } + if (isDraggingLineBodyRef.current && lineBodyStartRef.current) { + const { imgX, imgY } = screenToImgXY(e); + const start = lineBodyStartRef.current; + // Move in whole scan positions, so the drawn ends stay on the scan grid, and stop at the + // scan edge; the shift is limited, not each end, so the line keeps its length and direction. + const L0 = start.line; + const dRow = clampShift(Math.round((imgY - start.imgY) / navH * scanRows), L0.r0, L0.r1, scanRows); + const dCol = clampShift(Math.round((imgX - start.imgX) / NAV_SIZE * scanCols), L0.c0, L0.c1, scanCols); + dragLine({ r0: L0.r0 + dRow, c0: L0.c0 + dCol, r1: L0.r1 + dRow, c1: L0.c1 + dCol }); + return; + } + if (isPaintingRef.current && maskRef.current) paintBrush(maskRef.current, row, col); + if (isDraggingShapeRef.current && shapeStartRef.current) { + setShapePreview({ r0: shapeStartRef.current.row, c0: shapeStartRef.current.col, r1: row, c1: col }); + } + }; + + const handleNavMouseUp = () => { + if (isPanningRef.current) { + isPanningRef.current = false; + panStartRef.current = null; + return; + } + if (isDraggingProbeRef.current) { + isDraggingProbeRef.current = false; + return; + } + if (isDrawingLineRef.current) { + isDrawingLineRef.current = false; + lineDrawStartRef.current = null; + commitLine(localLineRef.current); + return; + } + if (draggingEndpointRef.current !== null) { + draggingEndpointRef.current = null; + commitLine(localLineRef.current); + return; + } + if (isDraggingLineBodyRef.current) { + isDraggingLineBodyRef.current = false; + lineBodyStartRef.current = null; + commitLine(localLineRef.current); + return; + } + if (isPaintingRef.current) { + isPaintingRef.current = false; + syncMaskToPython(); + return; + } + if (isDraggingShapeRef.current && shapeStartRef.current && maskRef.current && shapePreview) { + const { r0, c0, r1, c1 } = shapePreview; + if (maskTool === "circle") { + const centerRow = (r0 + r1) / 2; + const centerCol = (c0 + c1) / 2; + const radius = Math.max(Math.abs(c1 - c0), Math.abs(r1 - r0)) / 2; + fillCircleMask(maskRef.current, scanCols, scanRows, centerRow, centerCol, radius, maskPaintValue); + } else { + fillRectMask(maskRef.current, scanCols, scanRows, r0, c0, r1, c1, maskPaintValue); + } + setMaskRenderVersion((v) => v + 1); + syncMaskToPython(); + } + isDraggingShapeRef.current = false; + shapeStartRef.current = null; + setShapePreview(null); + }; + + // Leaving the image ends painting, keeping the strokes so far, and the shape and probe drags. + const handleNavMouseLeave = () => { + if (isPaintingRef.current) syncMaskToPython(); + isPaintingRef.current = false; + isDraggingShapeRef.current = false; + isDraggingProbeRef.current = false; + setShapePreview(null); + }; + + const handleNavWheel = (e: React.WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + setNavZoom((z) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * (e.deltaY > 0 ? 0.9 : 1.1)))); + }; + + // ========================================================================= + // Plot mouse handlers + // ========================================================================= + // Pointer position in the data coordinates of the 1D plot. + function plotScreenToData(e: React.MouseEvent) { + const cvs = plotCanvasRef.current; + if (!cvs) return null; + const { x: sx, y: sy } = pointerOffset(cvs, e); + return { + x: plotXMin + ((sx - MARGIN_LEFT_MIN) / PLOT_AREA_W) * (plotXMax - plotXMin), + y: plotYMin + ((MARGIN_TOP + PLOT_AREA_H - sy) / PLOT_AREA_H) * (plotYMax - plotYMin), + }; + } + + // Pointer position on the line-scan plot with that plot's layout; null before it is drawn. + function heatmapPointer(e: React.MouseEvent) { + const g = heatmapGeomRef.current; + const cvs = plotCanvasRef.current; + if (!g || !cvs) return null; + const { x: sx, y: sy } = pointerOffset(cvs, e); + const insideX = sx >= MARGIN_LEFT_MIN && sx <= MARGIN_LEFT_MIN + g.pwH; + const insideY = sy >= MARGIN_TOP && sy <= MARGIN_TOP + PLOT_AREA_H; + return { g, sx, sy, inside: insideX && insideY }; + } + + const handlePlotMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + // Line-scan plot: a click on empty space adds a guide, a click on a guide removes it (at + // mouse-up), and a drag moves it. + if (isLinescan) { + const p = heatmapPointer(e); + if (p && p.inside) { + const r = heatmapRAt(p.g, p.sx); + const tolR = ((p.g.rHi - p.g.rLo) / p.g.pwH) * 6; + const near = hmGuides.findIndex((v) => Math.abs(v - r) <= tolR); + if (near >= 0) { + draggingGuideRef.current = { index: near, moved: false, added: false }; + } else { + draggingGuideRef.current = { index: hmGuides.length, moved: false, added: true }; + setHmGuides((prev) => [...prev, r]); + } + } + return; + } + isPlotPanRef.current = true; + plotPanStartRef.current = { + mx: e.clientX, my: e.clientY, xMin: plotXMin, xMax: plotXMax, yMin: plotYMin, yMax: plotYMax, + }; + }; + + const handlePlotMouseMove = (e: React.MouseEvent) => { + if (draggingGuideRef.current) { + const p = heatmapPointer(e); + if (p) { + const r = Math.max(p.g.rLo, Math.min(p.g.rHi, heatmapRAt(p.g, p.sx))); + const idx = draggingGuideRef.current.index; + draggingGuideRef.current.moved = true; + setHmGuides((prev) => { + const guides = [...prev]; + if (idx < guides.length) guides[idx] = r; + return guides; + }); + } + return; + } + // Line-scan plot: r/k, position and the stack value under the pointer. + if (isLinescan) { + const p = heatmapPointer(e); + if (p) setHmHover(p.inside ? heatmapValueAt(p.g, p.sx, p.sy) : null); + return; + } + const start = plotPanStartRef.current; + if (isPlotPanRef.current && start) { + const dx = e.clientX - start.mx; + const dy = e.clientY - start.my; + const dxD = -(dx / PLOT_AREA_W) * (start.xMax - start.xMin); + const dyD = (dy / PLOT_AREA_H) * (start.yMax - start.yMin); + setPlotXMin(start.xMin + dxD); + setPlotXMax(start.xMax + dxD); + setPlotYMin(start.yMin + dyD); + setPlotYMax(start.yMax + dyD); + userZoomedRef.current = true; + return; + } + const d = plotScreenToData(e); + if (d) setCursorData(d); + }; + + const handlePlotMouseUp = () => { + const d = draggingGuideRef.current; + if (d) { + draggingGuideRef.current = null; + // A click (no drag) on an existing guide removes it; a click on empty space added one. + if (!d.moved && !d.added) setHmGuides((prev) => prev.filter((_, i) => i !== d.index)); + return; + } + isPlotPanRef.current = false; + plotPanStartRef.current = null; + }; + + const handlePlotMouseLeave = () => { + isPlotPanRef.current = false; + draggingGuideRef.current = null; + setCursorData(null); + setHmHover(null); + }; + + // A double click removes the line-scan plot's guides, or refits the 1D plot. + const handlePlotDoubleClick = () => { + if (isLinescan) { + setHmGuides([]); + return; + } + refitPlot(); + }; + + // Zoom about the pointer. + const handlePlotWheel = (e: React.WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + const d = plotScreenToData(e); + if (!d) return; + const f = e.deltaY > 0 ? 1.1 : 1 / 1.1; + setPlotXMin((p) => d.x - (d.x - p) * f); + setPlotXMax((p) => d.x + (p - d.x) * f); + setPlotYMin((p) => d.y - (d.y - p) * f); + setPlotYMax((p) => d.y + (p - d.y) * f); + userZoomedRef.current = true; + }; + + // ========================================================================= + // Keyboard + // ========================================================================= + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + switch (e.key.toLowerCase()) { + case "r": + // Reset the scan zoom and refit the plot. + setNavZoom(1); + setNavPanX(0); + setNavPanY(0); + refitPlot(); + break; + case "x": + if (!lockMask) switchMaskAction(); + break; + case "c": + if (!lockMask) clearMask(); + break; + case "i": + if (!lockMask) invertMask(); + break; + case "1": + if (!lockParameters) setPlotMode("Ik"); + break; + case "2": + if (!lockParameters) setPlotMode("Fk"); + break; + case "3": + if (!lockParameters) setPlotMode("Gr"); + break; + } + }; + + // The wheel zooms the image and the plot, so it must not scroll the page; that needs + // non-passive listeners, which React's onWheel is not. + React.useEffect(() => { + const preventScroll = (e: WheelEvent) => e.preventDefault(); + const opts: AddEventListenerOptions = { passive: false }; + const navBox = navCanvasRef.current?.parentElement; + const plotBox = plotCanvasRef.current?.parentElement; + navBox?.addEventListener("wheel", preventScroll, opts); + plotBox?.addEventListener("wheel", preventScroll, opts); + return () => { + navBox?.removeEventListener("wheel", preventScroll); + plotBox?.removeEventListener("wheel", preventScroll); + }; + }, []); + + // ========================================================================= + // Standalone HTML export + // ========================================================================= + const pendingExportRef = React.useRef<{ id: string; filename: string; profiles: boolean } | null>(null); + const [exportBusy, setExportBusy] = React.useState(false); + const [exportAnchor, setExportAnchor] = React.useState<HTMLElement | null>(null); + const [localExportStatus, setLocalExportStatus] = React.useState(""); + + // Estimated HTML size of each export option, from the bytes it embeds. + const nkExport = byteLength(ikXBytes) / 4; // ik_x_bytes is the float32 k axis + const baseExportBytes = byteLength(navImageBytes) + + byteLength(ikXBytes) + byteLength(ikYBytes) + byteLength(ikBgYBytes) + + byteLength(fkXBytes) + byteLength(fkYBytes) + + byteLength(grXBytes) + byteLength(grYBytes) + + byteLength(pdfXBytes) + byteLength(pdfYBytes) + + byteLength(linescanBytes) + byteLength(linescanAxisBytes) + + nkExport * 8; // offline_qq, float64 + const profileExportBytes = scanRows * scanCols * nkExport + nkExport * 8; // uint8 profiles, float32 lo/hi + const curvesHtmlSize = formatEstimatedHtmlSize(baseExportBytes); + const profilesHtmlSize = formatEstimatedHtmlSize(baseExportBytes + profileExportBytes); + + // With profiles (encoding "uint8") the file also embeds every scan position's 8-bit profile, so + // mask, probe and line stay interactive; without (encoding "full") it holds the curves only. + const handleExportHtml = (profiles: boolean) => { + const base = exportTitleSlug(title, "showpdf"); + const filename = `${base}_${scanRows}x${scanCols}_${profiles ? "quantized" : "exact"}.html`; + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + pendingExportRef.current = { id, filename, profiles }; + setLocalExportStatus(`Preparing ${filename}...`); + setExportBusy(true); + const encoding = profiles ? "uint8" : "full"; + setExportRequest(JSON.stringify({ mode: "single", download: true, id, filename, encoding })); + }; + + const handleExportChoice = (profiles: boolean) => { + setExportAnchor(null); + handleExportHtml(profiles); + }; + + // Download the file Python built for this view's request. + React.useEffect(() => { + const pending = pendingExportRef.current; + if (!pending || exportPayloadId !== pending.id) return; + const bytes = extractBytes(exportPayload); + if (bytes.length === 0) return; + // Copy out of the shared buffer if this is a subarray view. + const wholeBuffer = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength; + const payload = wholeBuffer ? bytes : bytes.slice(); + const blob = new Blob([payload as BlobPart], { type: "text/html;charset=utf-8" }); + const filename = exportPayloadFilename || pending.filename; + downloadBlob(blob, filename); + pendingExportRef.current = null; + setExportBusy(false); + const contents = pending.profiles + ? "exact float32 curves + quantized uint8 profiles" + : "exact float32 curves"; + setLocalExportStatus(`Saved ${filename} (${formatSavedBytes(payload.byteLength)}, ${contents})`); + // Release the embedded bytes from both sides so they are not re-synced. + setExportRequest(JSON.stringify({ mode: "clear", id: `${pending.id}-clear` })); + }, [exportPayload, exportPayloadId, exportPayloadFilename, setExportRequest]); + + // A failed export delivers no payload: release the button for a retry (the status shows the + // error). An idle view drops its stale "Saved …" for a newer Python failure or export_html() + // result (from another view, or code). + React.useEffect(() => { + const failed = exportStatus?.startsWith("Export failed"); + if (pendingExportRef.current && failed) { + pendingExportRef.current = null; + setExportBusy(false); + setLocalExportStatus(""); + } else if (!pendingExportRef.current && (failed || exportStatus?.startsWith("Exported "))) { + setLocalExportStatus(""); + } + }, [exportStatus]); + + // ========================================================================= + // Offline recompute (standalone export, no kernel) + // ========================================================================= + // The controls rerun the k-space pipeline in JS and write the results into the curve traits, + // so the plot redraws as for Python's results. An export that carries the quantized profile of + // every scan position (offlineReady) also recomputes I(k) for a new mask, probe or line. + const offlineReady = !!(offline && offlineRecompute && offlineProfiles && offlineProfilesLo + && offlineProfilesHi && offlineNk > 0 && offlineRows > 0 && offlineCols > 0); + const offProf = React.useMemo(() => { + return offlineProfiles ? extractBytes(offlineProfiles) : null; + }, [offlineProfiles]); + const offLo = React.useMemo(() => { + return offlineProfilesLo ? extractFloat32(offlineProfilesLo) : null; + }, [offlineProfilesLo]); + const offHi = React.useMemo(() => { + return offlineProfilesHi ? extractFloat32(offlineProfilesHi) : null; + }, [offlineProfilesHi]); + // Every export bakes the exact float64 k axis: the background fit must select the same k bins as + // the library, which the float32 curve axis cannot guarantee at the range ends. + const offQq = React.useMemo(() => { + return offlineQq ? extractFloat64(offlineQq) : null; + }, [offlineQq]); + const offlineK = (): Float64Array | null => { + if (offQq) return offQq; + const k32 = ikXBytes ? extractFloat32(ikXBytes) : null; + return k32 ? Float64Array.from(k32) : null; + }; + + // Mean profile I(k) over the scan positions `include` selects, and their number; null when it + // selects none. + const offlineMeanIk = (include: ScanPredicate) => { + const q = offProf, lo = offLo, hi = offHi; + if (!offlineReady || !q || !lo || !hi) return null; + const sr = scanRows / offlineRows, sc = scanCols / offlineCols; // 1 unless scan-binned + const acc = new Float64Array(offlineNk); + let n = 0; + for (let r = 0; r < scanRows; r++) { + const pr = profileIndex(r, sr, offlineRows); + for (let c = 0; c < scanCols; c++) { + if (!include(r, c)) continue; + const base = (pr * offlineCols + profileIndex(c, sc, offlineCols)) * offlineNk; + addProfile(acc, q, base, lo, hi); + n++; + } + } + if (n === 0) return null; + for (let k = 0; k < offlineNk; k++) acc[k] /= n; + return { ik: acc, n }; + }; + + // The scan positions of the current single selection: the mask, the probe or the averaged line. + const offlineSelectionInclude = (lineWidthPx: number): ScanPredicate | null => { + if (analysisMode === "probe") { + const half = Math.max(0, localProbeSize - 1); + const r0 = localProbe.row - half, r1 = localProbe.row + half; + const c0 = localProbe.col - half, c1 = localProbe.col + half; + return (r, c) => r >= r0 && r <= r1 && c >= c0 && c <= c1; + } + if (analysisMode === "line") { + // From the line's geometry, not the lineActive state, which lags behind a fresh draw. + return lineBandIncludes(effectiveLine(localLineRef.current), lineWidthPx); + } + // An all-ones mask (no mask) selects every position. + const m = maskRef.current; + if (!m || m.every((v) => v !== 0)) return () => true; + return (r, c) => m[r * scanCols + c] === 1; + }; + + // Parameters of an offline recompute, as ShowPDF._curve_params; `over` carries the value a + // control just committed. Damping and g(r) need the density and are skipped while it is unset. + const offlineParams = (over: OfflineOver) => { + const dVal = over.densityValue ?? densityValue; + return { + gr: { + kMinFit: over.kMinFit ?? kMinFit, kMaxFit: over.kMaxFit ?? kMaxFit, + kMinWindow: over.kMinWindow ?? kMinWindow, kMaxWindow: over.kMaxWindow ?? kMaxWindow, + kLowpass: over.kLowpass ?? kLowpass, kHighpass: over.kHighpass ?? kHighpass, + rMax: over.rMax ?? rMax, rStep, + }, + damp: over.damp ?? dampOrigin, + density: dVal > 0 ? dVal : null, + rCut: over.rCut ?? rCut, + maxIter: over.maxIter ?? maxIter, + }; + }; + + // An export's status line (no kernel sets status_message): a refused recompute, or the same + // window and density hints as ShowPDF. + const [offlineError, setOfflineError] = React.useState(""); + // Until the browser first recomputes (or refuses to), an export shows the error Python baked + // into it, if any; after that, its own status. + const [offlineRecomputed, setOfflineRecomputed] = React.useState(false); + const bakedError = ERROR_STATUS.test(statusMessage || "") ? statusMessage : ""; + // Whether the current k window has no positive weight, judged as the library does: with the + // bounds computeGr resolves (0 window ends follow the fit range, 0 fit ends are the k axis ends) + // and its float32 window. + const offlineWindowEmpty = React.useMemo(() => { + const qq = offline ? offlineK() : null; + if (!qq || qq.length < 2) return false; + const b = resolveWindowBounds(qq, { kMinFit, kMaxFit, kMinWindow, kMaxWindow }); + return !kWindow(qq, b.kMinWindow, b.kMaxWindow).some((w) => w > 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [offline, offQq, ikXBytes, kMinFit, kMaxFit, kMinWindow, kMaxWindow]); + const offlineStatus = offlineError + || (offlineRecomputed ? "" : bakedError) + || (offlineWindowEmpty ? WINDOW_HINT : "") + || densityHint(densityValue, dampOrigin, plotMode); + const shownStatus = offline ? offlineStatus : statusMessage; + + const setOfflineSelectionStats = (n: number) => { + const total = scanRows * scanCols; + setMaskPixelCount(n); + setMaskFraction(total > 0 ? n / total : 0); + }; + + // The curves of the single selection. I(k) comes from the mask, probe or line when the export + // carries the profiles, otherwise from the baked curve; an empty selection averages the full + // scan, as calculate_Gr does without a mask. + const recomputeOffline = (over: OfflineOver) => { + const qf = offlineK(); + if (!qf) return; + let ik: Float32Array | Float64Array | null = null; + let fromSel = false; + if (offlineReady) { + const inc = offlineSelectionInclude(over.lineWidth ?? localLineWidth); + const s = (inc ? offlineMeanIk(inc) : null) ?? offlineMeanIk(() => true); + if (s) { + ik = s.ik; + fromSel = true; + setOfflineSelectionStats(s.n); + } + } + if (!ik) ik = ikYBytes ? extractFloat32(ikYBytes) : null; + if (!ik) return; + const p = offlineParams(over); + let res, damped; + try { + res = computeGr(qf, Float64Array.from(ik), p.gr); + damped = p.damp && p.density !== null + ? dampOriginOscillations(qf, res, { density: p.density, rCut: p.rCut, maxIter: p.maxIter }) + : null; + } catch (e) { + // As a failed Python recompute: keep the previous curves, report why. + setOfflineError(`Error: ${(e as Error).message}`); + setOfflineRecomputed(true); + return; + } + setOfflineError(""); + setOfflineRecomputed(true); + // The I(k) plot shows the selection's profile. + if (fromSel) { + setIkYBytes(packF32(ik)); + setIkXBytes(packF32(qf)); + } + setIkBgYBytes(packF32(res.bg)); + // F(k) and G(r) as the transform produced them: the damped ones when damping ran. + setFkXBytes(packF32(qf)); + setFkYBytes(packF32(damped ? damped.fkWin : res.fkWin)); + const grShown = damped ? damped.gr : res.gr; + setGrXBytes(packF32(res.r)); + setGrYBytes(packF32(grShown)); + // g(r) = 1 + G(r)/(4*pi*r*rho0) needs the density; empty while it is unset. + if (p.density !== null) { + setPdfXBytes(packF32(res.r)); + setPdfYBytes(packF32(computeGofR(res.r, grShown, p.density))); + } else { + setPdfXBytes(packF32(new Float64Array(0))); + setPdfYBytes(packF32(new Float64Array(0))); + } + }; + + // The line-scan stack, as ShowPDF._compute_linescan. + const recomputeOfflineLinescan = (over: OfflineOver) => { + const qf = offlineK(); + if (!offlineReady || !qf || !offProf || !offLo || !offHi) return; + const clear = () => { + setNLinescan(0); + setLinescanNPoints(0); + setLinescanBytes(packF32(new Float64Array(0))); + setLinescanAxisBytes(packF32(new Float64Array(0))); + }; + // From the line's geometry, not the lineActive state, which lags behind model.set during a + // fresh draw: a band means a real line. + const line = effectiveLine(localLineRef.current); + const dr = line.r1 - line.r0, dc = line.c1 - line.c0, segSq = dr * dr + dc * dc; + const inBand = lineBandIncludes(line, over.lineWidth ?? localLineWidth); + if (!inBand) { + clear(); + setOfflineSelectionStats(0); + setOfflineError(""); + setOfflineRecomputed(true); + return; + } + // Samples evenly spaced from A to the tip B, one per scan step along the longer axis; each + // position in the band is averaged into its nearest sample (ShowPDF._linescan_samples). + const nBins = linescanSampleCount(dr, dc, linescanMaxBins ?? 256); + const Nk = offlineNk; + const sr = scanRows / offlineRows, sc = scanCols / offlineCols; // 1 unless scan-binned + const sums: Float64Array[] = []; + const counts: number[] = new Array(nBins).fill(0); + for (let b = 0; b < nBins; b++) sums.push(new Float64Array(Nk)); + for (let r = 0; r < scanRows; r++) { + const pr = profileIndex(r, sr, offlineRows); + for (let c = 0; c < scanCols; c++) { + if (!inBand(r, c)) continue; + const bin = linescanSampleIndex(((r - line.r0) * dr + (c - line.c0) * dc) / segSq, nBins); + const base = (pr * offlineCols + profileIndex(c, sc, offlineCols)) * Nk; + addProfile(sums[bin], offProf, base, offLo, offHi); + counts[bin]++; + } + } + setOfflineSelectionStats(counts.reduce((a, b) => a + b, 0)); + const p = offlineParams(over); + let axis: Float64Array | null = null; + let M = 0; + let stack: Float32Array | null = null; + try { + for (let b = 0; b < nBins; b++) { + if (counts[b] === 0) continue; + const binIk = sums[b]; + for (let k = 0; k < Nk; k++) binIk[k] /= counts[b]; + const res = computeGr(qf, binIk, p.gr); + const damped = p.damp && p.density !== null + ? dampOriginOscillations(qf, res, { density: p.density, rCut: p.rCut, maxIter: p.maxIter }) + : null; + // The plot mode's curve; g(r) needs the density. + let x: ArrayLike<number>, y: ArrayLike<number>; + if (plotMode === "Ik") { + x = qf; + y = binIk; + } else if (plotMode === "Fk") { + x = qf; + y = damped ? damped.fkWin : res.fkWin; + } else if (plotMode === "gr") { + if (p.density === null) continue; + x = res.r; + y = computeGofR(res.r, damped ? damped.gr : res.gr, p.density); + } else { + x = res.r; + y = damped ? damped.gr : res.gr; + } + if (!stack) { + axis = Float64Array.from(x); + M = y.length; + stack = new Float32Array(nBins * M).fill(NaN); + } + const nn = Math.min(M, y.length); + for (let j = 0; j < nn; j++) stack[b * M + j] = y[j]; + } + } catch (e) { + // As a failed Python line-scan recompute: clear the stack, report why. + clear(); + setOfflineError(`Line-scan error: ${(e as Error).message}`); + setOfflineRecomputed(true); + return; + } + setOfflineError(""); + setOfflineRecomputed(true); + if (!stack || !axis) { + clear(); + return; + } + setNLinescan(nBins); + setLinescanNPoints(M); + setLinescanBytes(new DataView(stack.buffer)); + setLinescanAxisBytes(packF32(axis)); + }; + + // The line-scan rebuilds its stack; every other selection its curves. + const recomputeOfflineAll = (over: OfflineOver) => { + if (offlineReady && isLinescan) recomputeOfflineLinescan(over); + else recomputeOffline(over); + }; + + // Recompute when the selection mode, probe, plot mode or line settings change; mask paints and + // line edits recompute through syncMaskToPython and commitLine, at mouse-up rather than mid-drag. + // Not on load: the exported curves are exact, while a recompute starts from the 8-bit profiles. + const offlineLoadedRef = React.useRef(false); + React.useEffect(() => { + if (!offlineReady) return; + if (!offlineLoadedRef.current) { + offlineLoadedRef.current = true; + return; + } + recomputeOfflineAll({}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + offlineReady, analysisMode, lineMode, plotMode, localProbe.row, localProbe.col, probeSize, linePerp, + lineActive, + ]); + + // ========================================================================= + // Control commits + // ========================================================================= + // A slider commit that leaves its trait unchanged must not recompute: an export would replace + // its exact curves with recomputed ones. + const commitLineWidth = (v: number) => { + if (v === lineWidth) return; + setLineWidth(v); + if (offline) recomputeOfflineAll({ lineWidth: v }); + }; + + const commitKFit = (v: [number, number]) => { + // An end left where it was keeps its value (MUI reports it clamped to the slider). + const lo = committedFitEnd(v[0], kMinFit, kMinAvail, kMaxAvail); + const hi = committedFitEnd(v[1], kMaxFit, kMinAvail, kMaxAvail); + // The readout shows the kept ends, not the slider's bound, also when they are unchanged. + setLocalKFit([lo, hi]); + if (lo === kMinFit && hi === kMaxFit) return; + setKMinFit(lo); + setKMaxFit(hi); + if (offline) recomputeOfflineAll({ kMinFit: lo, kMaxFit: hi }); + }; + + const commitKWindow = (v: [number, number]) => { + // An end left where it was keeps its setting (<= 0: follow the fit range). + const lo = committedWindowEnd(v[0], kMinWindow, kMinFit, kMinAvail, kMaxAvail); + const hi = committedWindowEnd(v[1], kMaxWindow, kMaxFit, kMinAvail, kMaxAvail); + // The readout shows the window in effect, also when the setting is unchanged. + setLocalKWin(windowInEffect(lo, hi, kMinFit, kMaxFit)); + if (lo === kMinWindow && hi === kMaxWindow) return; + setKMinWindow(lo); + setKMaxWindow(hi); + if (offline) recomputeOfflineAll({ kMinWindow: lo, kMaxWindow: hi }); + }; + + const commitRMax = (v: number) => { + if (v === rMax) return; + setRMax(v); + if (offline) recomputeOfflineAll({ rMax: v }); + }; + + const commitKLowpass = (v: number) => { + if (v === kLowpass) return; + setKLowpass(v); + if (offline) recomputeOfflineAll({ kLowpass: v }); + }; + + const commitKHighpass = (v: number) => { + if (v === kHighpass) return; + setKHighpass(v); + if (offline) recomputeOfflineAll({ kHighpass: v }); + }; + + const commitMaxIter = (v: number) => { + if (v === maxIter) return; + setMaxIter(v); + if (offline) recomputeOfflineAll({ maxIter: v }); + }; + + // r cut moves on the r grid of G(r) and its readouts show r_step's decimals. Values are rounded + // to the grid, since MUI's keyboard path does not round. + const rCutRange = rCutSlider(rStep, rMax); + const roundRCut = (v: number) => Number(v.toFixed(rCutRange.decimals)); + const commitRCut = (v: number) => { + const rc = roundRCut(v); + if (rc === rCut) return; + setRCut(rc); + if (offline) recomputeOfflineAll({ rCut: rc }); + }; + + const toggleDamp = (on: boolean) => { + setDampOrigin(on); + if (offline) recomputeOfflineAll({ damp: on }); + }; + + // The Log switch refits at once with its new value. + const handleLogScaleChange = (log: boolean) => { + setIkLogScale(log); + refitPlot(log); + }; + + // A count >= 1 other than the current one is set; otherwise the field shows the current count. + const commitWaterfallCount = () => { + const v = parseInt(localWfCount, 10); + if (!isNaN(v) && v >= 1 && v !== waterfallCount) setWaterfallCount(v); + else setLocalWfCount(String(waterfallCount)); + }; + + // Density field: densityEditedRef records typing since the field got focus. Leaving the field + // commits an edit: a number >= 0 sets the density, an empty field unsets it (0). + const handleDensityFocus = () => { + densityEditedRef.current = false; + }; + + const handleDensityChange = (e: React.ChangeEvent<HTMLInputElement>) => { + densityEditedRef.current = true; + setLocalDensity(e.target.value); + }; + + const handleDensityBlur = (e: React.FocusEvent<HTMLInputElement>) => { + const edited = densityEditedRef.current; + densityEditedRef.current = false; + // A malformed number reads as "" (validity.badInput): keep the density and clear the text + // (restoring "" while unset would not re-render). + if (e.currentTarget.validity.badInput) { + e.currentTarget.value = ""; + setLocalDensity(fmtDensity(densityValue)); + return; + } + // Leaving the field unedited keeps the density (the field shows it rounded). + if (!edited) return; + const text = localDensity.trim(); + const v = text === "" ? 0 : parseFloat(text); + if (!isNaN(v) && v >= 0 && v !== densityValue) { + setDensityValue(v); + if (offline) recomputeOfflineAll({ densityValue: v }); + } else { + setLocalDensity(fmtDensity(densityValue)); + } + }; + + // ========================================================================= + // View + // ========================================================================= + // Active processing is never invisible: without the controls (show_controls=False hides the + // damping and filter sliders and the ⟂ button), a badge in the title row lists it. + const hiddenProcessing: { short: string; full: string }[] = []; + if (!showControls) { + if (dampOrigin && densityValue > 0) { + const full = `Low-r damping on (r cut ${rCut.toFixed(rCutRange.decimals)} Å, ${maxIter} iterations)`; + hiddenProcessing.push({ short: "Damped", full }); + } + if (kLowpass > 0) { + const k = kLowpass.toFixed(3); + hiddenProcessing.push({ short: `Lowpass ${k}`, full: `k lowpass ${k}` }); + } + if (kHighpass > 0) { + const k = kHighpass.toFixed(3); + hiddenProcessing.push({ short: `Highpass ${k}`, full: `k highpass ${k}` }); + } + if (analysisMode === "line" && linePerp) { + hiddenProcessing.push({ short: "⟂ line", full: "Sampling perpendicular to the drawn line" }); + } + } + + const isStatusError = ERROR_STATUS.test(shownStatus || ""); + let statusColor = colors.textMuted; + if (isStatusError) statusColor = "#d32f2f"; + else if (computing) statusColor = colors.accent; + + const maskActionMode = maskAction === "add" + ? "Add mode: painting excludes positions (red)" + : "Subtract mode: painting re-includes positions"; + const maskActionTitle = `${maskActionMode}. Click to switch; switching resets the whole mask (X).`; + const navDraws = analysisMode === "probe" || analysisMode === "line" || maskTool === "freeform"; + const navCursor = navDraws ? "crosshair" : "default"; + const lineText = lineActive + ? `len ${lineLenPx.toFixed(1)} px · θ ${lineOrientDeg.toFixed(1)}°` + : "draw a line…"; + const plotCursor = isLinescan ? "col-resize" : "default"; + + // The hovered value, shown under the plot. With nothing hovered there is no readout line, so the + // controls sit right under the plot. + const xSymbol = isRAxis(plotMode) ? "r" : "k"; + const ySymbol = curveLabels(plotMode).symbol; + let plotReadout = ""; + if (isLinescan) { + if (hmHover) { + const r = formatNumber(hmHover.r, 4); + const pos = formatNumber(hmHover.pos, 2); + const val = formatNumber(hmHover.val, 4); + plotReadout = `${xSymbol} = ${r}, position = ${pos}, ${ySymbol} = ${val}`; + } + } else if (cursorData) { + const v = curveValueAt(cursorData.x); + if (v !== null) { + plotReadout = `${xSymbol} = ${formatNumber(cursorData.x, 4)}, ${ySymbol} = ${formatNumber(v, 4)}`; + } + } + + // This view's own export status while it has one, otherwise Python's. + const exportShownStatus = localExportStatus || exportStatus; + const exportButtonTitle = exportShownStatus + || "Export a standalone HTML viewer (saved to your Downloads folder)"; + const exportSpinner = <CircularProgress size={12} sx={{ color: colors.accent }} />; + const curvesExportTitle = isLinescan + ? "Curves stored as float32. The line-scan stack is fixed: selection, k-space, damping, density and plot-mode controls are locked (use live selection to keep them)." + : "Curves stored as float32. Mask, probe and line are fixed; the k-space, damping and density controls still recompute."; + + // Styles that follow the theme. + const typo = { + title: { fontSize: 15, fontWeight: 600, color: colors.accent, fontFamily: FONT }, + label: { fontSize: 13, color: colors.text, fontFamily: FONT }, + labelSmall: { fontSize: 12, fontWeight: 600, color: colors.textMuted, fontFamily: FONT }, + value: { fontSize: 12, fontFamily: MONO, color: colors.text }, + }; + const parameterLabelSx = { ...typo.labelSmall, minWidth: 55, flexShrink: 0 }; + const readoutSx = { ...typo.value, ...numericSx, color: colors.textMuted }; + // content-box: the 1px border sits outside the canvas size, also where box-sizing is + // border-box (the standalone export). + const imageBox = { + position: "relative" as const, + boxSizing: "content-box" as const, + border: `1px solid ${colors.border}`, + overflow: "hidden", + bgcolor: isDark ? "#111" : "#eee", + }; + const overlayCanvasStyle = { + position: "absolute", top: 0, left: 0, width: NAV_SIZE, height: navH, pointerEvents: "none", + } as const; + const themedSelect = { + fontSize: 12, + height: 28, + bgcolor: colors.controlBg, + color: colors.text, + "& .MuiSvgIcon-root": { color: colors.text }, + "& .MuiOutlinedInput-notchedOutline": { borderColor: colors.border }, + "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: colors.accent }, + }; + const themedMenuProps = { + PaperProps: { + sx: { + bgcolor: colors.controlBg, + color: colors.text, + border: `1px solid ${colors.border}`, + "& .MuiMenuItem-root:hover, & .MuiMenuItem-root.Mui-selected, & .MuiMenuItem-root.Mui-focusVisible": { + bgcolor: colors.bg, + }, + }, + }, + sx: { zIndex: 9999 }, + }; + + return ( + <Box + className="showpdf-root" + tabIndex={0} + onKeyDown={handleKeyDown} + sx={{ + fontFamily: FONT, outline: "none", border: "none", p: 1, overflowX: "auto", bgcolor: colors.bg, + color: colors.text, "&:focus": { outline: "none" }, "&:focus-visible": { outline: "none" }, + }} + > + {/* Title row: title, hidden-processing badge, status */} + <Stack + direction="row" + alignItems="center" + justifyContent="space-between" + sx={{ mb: `${SPACING.SM}px`, gap: 1, minWidth: 0, flexWrap: "wrap", rowGap: "4px" }} + > + <Stack direction="row" alignItems="center" sx={{ minWidth: 0 }}> + <Typography + sx={{ + ...typo.title, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", + }} + title={title || "PDF"} + > + {title || "PDF"} + </Typography> + {hiddenProcessing.length > 0 && ( + <Box + component="span" + title={hiddenProcessing.map((p) => p.full).join("; ")} + sx={{ + ml: 0.5, px: 0.5, py: 0, fontSize: 11, fontWeight: 600, borderRadius: "3px", + backgroundColor: alpha(colors.accent, 0.13), color: colors.accent, + border: `1px solid ${alpha(colors.accent, 0.27)}`, whiteSpace: "nowrap", flexShrink: 0, + }} + > + {hiddenProcessing.map((p) => p.short).join(" · ")} + </Box> + )} + </Stack> + <Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}> + {computing && <CircularProgress size={14} sx={{ color: colors.accent }} />} + {shownStatus && ( + <Typography + title={shownStatus} + aria-label={shownStatus} + sx={{ + ...typo.labelSmall, maxWidth: 360, overflow: "hidden", textOverflow: "ellipsis", + whiteSpace: "nowrap", color: statusColor, + }} + > + {shownStatus} + </Typography> + )} + </Stack> + </Stack> + + <Stack direction="row" useFlexGap sx={{ flexWrap: "nowrap", gap: `${SPACING.LG}px` }}> + {/* LEFT: Nav + Mask */} + <Box sx={{ width: NAV_SIZE, flexShrink: 0 }}> + <Stack + direction="row" + justifyContent="space-between" + alignItems="center" + sx={{ mb: `${SPACING.XS}px`, minHeight: 32, flexWrap: "wrap", rowGap: `${SPACING.XS}px` }} + > + <span style={{ fontSize: 12, fontFamily: FONT, color: colors.textMuted }}> + Scan ({scanRows}×{scanCols}) + </span> + <Stack direction="row" alignItems="center" spacing={`2px`}> + {ANALYSIS_MODES.map((m) => ( + <Button + key={m} + size="small" + variant={analysisMode === m ? "contained" : "outlined"} + disabled={lockMask} + sx={{ ...compactBtn, minWidth: 38 }} + onClick={() => setAnalysisMode(m)} + aria-pressed={analysisMode === m} + > + {m} + </Button> + ))} + </Stack> + </Stack> + {analysisMode === "mask" && ( + <Stack + direction="row" + spacing={`2px`} + alignItems="center" + sx={{ mb: `${SPACING.XS}px`, flexWrap: "wrap", rowGap: `${SPACING.XS}px` }} + > + {MASK_TOOLS.map(([tool, glyph, name]) => ( + <Button + key={tool} + size="small" + variant={maskTool === tool ? "contained" : "outlined"} + disabled={lockMask} + sx={{ ...compactBtn, minWidth: 24 }} + onClick={() => setMaskTool(tool)} + title={name} + aria-label={name} + > + {glyph} + </Button> + ))} + <Button + size="small" + variant={maskAction === "add" ? "contained" : "outlined"} + disabled={lockMask} + sx={{ ...compactBtn, minWidth: 24 }} + color={maskAction === "add" ? "primary" : "error"} + title={maskActionTitle} + aria-label={maskActionTitle} + onClick={switchMaskAction} + > + {maskAction === "add" ? "+" : "−"} + </Button> + <Button + size="small" + sx={compactBtn} + disabled={lockMask} + title="Clear the mask (C)" + onClick={clearMask} + > + Clear + </Button> + <Button + size="small" + sx={compactBtn} + disabled={lockMask} + title="Invert the mask and swap add/subtract mode (I inverts only)" + onClick={handleInvertClick} + > + Invert + </Button> + </Stack> + )} + <Box + sx={imageBox} + style={{ width: NAV_SIZE, height: navH }} + data-quantem-scientific-output="showpdf-navigation" + > + <canvas ref={navCanvasRef} style={{ width: NAV_SIZE, height: navH, display: "block" }} /> + <canvas ref={navOverlayRef} style={overlayCanvasStyle} /> + <canvas ref={navUiRef} style={overlayCanvasStyle} /> + <div + style={{ position: "absolute", inset: 0, cursor: navCursor }} + onMouseDown={handleNavMouseDown} + onMouseMove={handleNavMouseMove} + onMouseUp={handleNavMouseUp} + onMouseLeave={handleNavMouseLeave} + onWheel={handleNavWheel} + /> + </Box> + {showStats && ( + <div + style={{ + fontSize: 12, fontFamily: MONO, color: colors.textMuted, marginTop: SPACING.XS, + fontVariantNumeric: "tabular-nums", + }} + > + {maskPixelCount} / {scanRows * scanCols} included ({(maskFraction * 100).toFixed(1)}%) + </div> + )} + {showControls && ( + <Box sx={{ mt: `${SPACING.SM}px` }}> + {analysisMode === "mask" && maskTool === "freeform" && ( + <Stack direction="row" alignItems="center" gap={1} sx={{ mb: `${SPACING.XS}px` }}> + <Typography sx={typo.labelSmall}>Brush</Typography> + <Slider + value={maskBrushSize} + onChange={(_, v) => setMaskBrushSize(v as number)} + disabled={lockMask} + min={1} max={20} size="small" + sx={{ ...sliderSmall, width: 80 }} + aria-label="Brush size" + /> + <Typography sx={{ ...typo.value, ...numericSx }}>{maskBrushSize}</Typography> + </Stack> + )} + {analysisMode === "probe" && ( + <Stack direction="row" alignItems="center" gap={1} sx={{ mb: `${SPACING.XS}px` }}> + <Typography + sx={typo.labelSmall} + title="Probe size: the square spans 2·size−1 scan positions per side" + > + Probe + </Typography> + <CommitSlider + value={localProbeSize} + onChange={(_, v) => setLocalProbeSize(v as number)} + onChangeCommitted={(_, v) => setProbeSize(v as number)} + disabled={lockMask} + min={1} max={100} size="small" + sx={{ ...sliderSmall, width: 80 }} + aria-label="Probe size" + /> + <Typography sx={{ ...typo.value, ...numericSx }}>{localProbeSize}</Typography> + <Typography + sx={{ + ...typo.value, ...numericSx, color: colors.textMuted, ml: `${SPACING.SM}px`, + minWidth: `${String(scanRows).length + String(scanCols).length + 5}ch`, + }} + > + @({localProbe.row}, {localProbe.col}) + </Typography> + </Stack> + )} + {analysisMode === "line" && ( + <Stack + direction="row" + alignItems="center" + gap={1} + sx={{ mb: `${SPACING.XS}px`, flexWrap: "wrap", rowGap: `${SPACING.XS}px` }} + > + <Box sx={controlPairSx}> + <Typography sx={typo.labelSmall}>Width</Typography> + <CommitSlider + value={localLineWidth} + onChange={(_, v) => setLocalLineWidth(v as number)} + onChangeCommitted={(_, v) => commitLineWidth(v as number)} + disabled={lockMask} + min={1} max={Math.max(99, maxLineWidthSeen)} step={2} size="small" + sx={{ ...sliderSmall, width: 80 }} + aria-label="Line width" + /> + <Typography sx={{ ...typo.value, ...numericSx }}>{localLineWidth}</Typography> + </Box> + <Button + size="small" + variant={linePerp ? "contained" : "outlined"} + disabled={lockMask} + sx={{ ...compactBtn, minWidth: 24 }} + onClick={() => setLinePerp(!linePerp)} + aria-pressed={linePerp} + title="Sample perpendicular to the drawn line" + aria-label="Sample perpendicular to the drawn line" + > + ⟂ + </Button> + <Box sx={controlPairSx}> + {LINE_MODES.map(([lm, label]) => ( + <Button + key={lm} + size="small" + variant={lineMode === lm ? "contained" : "outlined"} + disabled={lockMask} + sx={{ ...compactBtn, minWidth: 30 }} + onClick={() => setLineMode(lm)} + aria-pressed={lineMode === lm} + > + {label} + </Button> + ))} + </Box> + <Typography sx={{ ...typo.value, ...numericSx, color: colors.textMuted }} title={lineText}> + {lineText} + </Typography> + <Button size="small" sx={compactBtn} disabled={lockMask || !lineActive} onClick={clearLine}> + Clear + </Button> + </Stack> + )} + <Stack direction="row" alignItems="center" gap={1}> + <Typography sx={typo.labelSmall}>Cmap</Typography> + <Select + size="small" + value={cmap} + onChange={(e) => setCmap(e.target.value)} + sx={{ ...themedSelect, minWidth: 80 }} + MenuProps={themedMenuProps} + inputProps={{ "aria-label": "Color map of the navigation image" }} + > + {CMAP_OPTIONS.map((name) => ( + <MenuItem key={name} value={name} sx={{ fontSize: 12 }}>{name}</MenuItem> + ))} + </Select> + </Stack> + </Box> + )} + {exportEnabled && ( + <Box + sx={{ mt: `${SPACING.SM}px`, pt: `${SPACING.SM}px`, borderTop: `1px solid ${colors.border}` }} + > + <Button + size="small" + variant="outlined" + fullWidth + disabled={exportBusy} + startIcon={exportBusy ? exportSpinner : undefined} + sx={{ ...compactBtn, "&.Mui-disabled": { color: colors.text, borderColor: colors.border } }} + onClick={(e) => setExportAnchor(e.currentTarget)} + aria-haspopup="menu" + aria-controls={exportAnchor ? "showpdf-export-menu" : undefined} + aria-expanded={exportAnchor ? "true" : undefined} + title={exportButtonTitle} + > + {exportBusy ? "Exporting…" : "Export HTML"} + </Button> + <Menu + id="showpdf-export-menu" + anchorEl={exportAnchor} + open={Boolean(exportAnchor)} + onClose={() => setExportAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + transformOrigin={{ vertical: "top", horizontal: "left" }} + MenuListProps={{ "aria-label": "Export options" }} + {...themedMenuProps} + > + <MenuItem + sx={{ fontSize: 12 }} + onClick={() => handleExportChoice(false)} + title={curvesExportTitle} + > + HTML exact float32 curves, fixed selection ({curvesHtmlSize}) + </MenuItem> + <MenuItem + sx={{ fontSize: 12 }} + onClick={() => handleExportChoice(true)} + title="Also embeds every scan position's radial profile at 8 bits per k bin, so mask, probe and line stay interactive." + > + HTML + quantized uint8 profiles, live selection ({profilesHtmlSize}) + </MenuItem> + </Menu> + {exportShownStatus && ( + <Typography + sx={{ + ...typo.value, fontFamily: FONT, mt: `${SPACING.XS}px`, overflow: "hidden", + textOverflow: "ellipsis", whiteSpace: "nowrap", + color: exportShownStatus.startsWith("Export failed") ? "#d32f2f" : colors.textMuted, + }} + title={exportShownStatus} + aria-label={exportShownStatus} + > + {exportShownStatus} + </Typography> + )} + </Box> + )} + </Box> + + {/* RIGHT: Curves */} + <Box sx={{ width: PLOT_W, flexShrink: 0 }}> + {/* Spacer as tall as the mask tools row on the left, so both canvases line up in mask mode. */} + {analysisMode === "mask" && ( + <Stack + direction="row" + spacing={`2px`} + alignItems="center" + aria-hidden + sx={{ + mb: `${SPACING.XS}px`, flexWrap: "wrap", rowGap: `${SPACING.XS}px`, visibility: "hidden", + }} + > + <Button size="small" sx={{ ...compactBtn, minWidth: 24 }}>▭</Button> + </Stack> + )} + <Stack + direction="row" + alignItems="center" + sx={{ mb: `${SPACING.XS}px`, minHeight: 32, flexWrap: "wrap", rowGap: `${SPACING.XS}px` }} + > + <Stack direction="row" spacing={`${SPACING.XS}px`}> + {PLOT_MODES.map(([mode, label]) => ( + <Button + key={mode} + size="small" + variant={plotMode === mode ? "contained" : "outlined"} + sx={compactBtn} + aria-pressed={plotMode === mode} + disabled={lockParameters} + onClick={() => setPlotMode(mode)} + > + {label} + </Button> + ))} + </Stack> + <Box sx={{ flex: 1 }} /> + <Stack direction="row" alignItems="center" gap={1}> + {plotMode === "Ik" && ( + <> + <Typography sx={typo.labelSmall}>Log</Typography> + <Switch + checked={ikLogScale} + onChange={(e) => handleLogScaleChange(e.target.checked)} + size="small" + sx={switchSmall} + slotProps={{ input: { role: "switch", "aria-label": "Log scale for the I(k) axis" } }} + /> + <Typography sx={typo.labelSmall} title="Show the fitted background B(k)"> + Background + </Typography> + <Switch + checked={showBackground} + onChange={(e) => setShowBackground(e.target.checked)} + size="small" + sx={switchSmall} + slotProps={{ input: { role: "switch", "aria-label": "Show the fitted background B(k)" } }} + /> + </> + )} + {isLinescan && LINESCAN_VIEWS.map(([v, label]) => ( + <Button + key={v} + size="small" + variant={lineScanView === v ? "contained" : "outlined"} + sx={compactBtn} + aria-pressed={lineScanView === v} + onClick={() => setLineScanView(v)} + > + {label} + </Button> + ))} + {isLinescan && lineScanView === "waterfall" && ( + <> + <Typography + sx={typo.labelSmall} + title="Number of curves to stack (evenly sampled along the line)" + > + N + </Typography> + <input + type="number" min={1} step={1} value={localWfCount} + title="Number of curves to stack (evenly sampled along the line)" + aria-label="N, number of stacked curves" + onChange={(e) => setLocalWfCount(e.target.value)} + onBlur={commitWaterfallCount} + onKeyDown={blurOnEnter} + style={{ + width: 48, boxSizing: "content-box", fontSize: 12, padding: "2px 4px", fontFamily: MONO, + background: colors.controlBg, color: colors.text, border: `1px solid ${colors.border}`, + }} + /> + </> + )} + <Button + size="small" + sx={compactBtn} + onClick={() => refitPlot()} + aria-label="Reset plot zoom" + title="Refit the plot axes (R also resets the scan zoom)" + > + Reset + </Button> + </Stack> + </Stack> + <Box + sx={imageBox} + style={{ width: PLOT_W, height: PLOT_H }} + data-quantem-scientific-output="showpdf-curves" + > + <canvas + ref={plotCanvasRef} + style={{ width: PLOT_W, height: PLOT_H, display: "block", cursor: plotCursor }} + onMouseDown={handlePlotMouseDown} + onMouseMove={handlePlotMouseMove} + onMouseUp={handlePlotMouseUp} + onMouseLeave={handlePlotMouseLeave} + onDoubleClick={handlePlotDoubleClick} + onWheel={handlePlotWheel} + /> + </Box> + {showStats && plotReadout !== "" && ( + <Typography + sx={{ + ...typo.value, ...numericSx, mt: `${SPACING.XS}px`, + overflow: "hidden", textOverflow: "ellipsis", + }} + > + {plotReadout} + </Typography> + )} + {showControls && ( + <Box + sx={{ mt: `${SPACING.SM}px`, display: "flex", flexDirection: "column", gap: `${SPACING.XS}px` }} + > + <Stack direction="row" alignItems="center" gap={1} sx={{ flexWrap: "nowrap" }}> + <Typography sx={parameterLabelSx}>k fit</Typography> + <CommitSlider + value={localKFit} + onChange={(_, v) => setLocalKFit(v as [number, number])} + onChangeCommitted={(_, v) => commitKFit(v as [number, number])} + disabled={lockParameters} + min={kMinAvail} max={kMaxAvail} step={0.01} size="small" + sx={parameterSliderSx} + aria-label="k fit range (background fit)" + /> + <Typography sx={{ ...readoutSx, minWidth: 90 }}> + [{localKFit[0].toFixed(2)}, {localKFit[1].toFixed(2)}] + </Typography> + </Stack> + <Stack direction="row" alignItems="center" gap={1} sx={{ flexWrap: "nowrap" }}> + <Typography sx={parameterLabelSx}>k window</Typography> + <CommitSlider + value={localKWin} + onChange={(_, v) => setLocalKWin(v as [number, number])} + onChangeCommitted={(_, v) => commitKWindow(v as [number, number])} + disabled={lockParameters} + min={kMinAvail} max={kMaxAvail} step={0.01} size="small" + sx={parameterSliderSx} + aria-label="k window range" + /> + <Typography sx={{ ...readoutSx, minWidth: 90 }}> + [{localKWin[0].toFixed(2)}, {localKWin[1].toFixed(2)}] + </Typography> + </Stack> + <Stack direction="row" alignItems="center" gap={1} sx={{ flexWrap: "nowrap" }}> + <Typography sx={parameterLabelSx}>r max</Typography> + <CommitSlider + value={localRMax} + onChange={(_, v) => setLocalRMax(v as number)} + onChangeCommitted={(_, v) => commitRMax(v as number)} + disabled={lockParameters} + min={1} max={50} step={0.5} size="small" + sx={parameterSliderSx} + aria-label="r max" + /> + <Typography sx={{ ...readoutSx, minWidth: 50 }}>{localRMax.toFixed(1)} Å</Typography> + </Stack> + <Stack direction="row" alignItems="center" gap={1} sx={{ flexWrap: "nowrap" }}> + <Typography sx={parameterLabelSx}>k lowpass</Typography> + <CommitSlider + value={localKLowpass} + onChange={(_, v) => setLocalKLowpass(v as number)} + onChangeCommitted={(_, v) => commitKLowpass(v as number)} + disabled={lockParameters} + min={0} max={0.1} step={0.001} size="small" + sx={parameterSliderSx} + aria-label="k lowpass filter" + /> + <Typography sx={{ ...readoutSx, minWidth: 50 }}> + {localKLowpass > 0 ? localKLowpass.toFixed(3) : "off"} + </Typography> + </Stack> + <Stack direction="row" alignItems="center" gap={1} sx={{ flexWrap: "nowrap" }}> + <Typography sx={parameterLabelSx}>k highpass</Typography> + <CommitSlider + value={localKHighpass} + onChange={(_, v) => setLocalKHighpass(v as number)} + onChangeCommitted={(_, v) => commitKHighpass(v as number)} + disabled={lockParameters} + min={0} max={0.1} step={0.001} size="small" + sx={parameterSliderSx} + aria-label="k highpass filter" + /> + <Typography sx={{ ...readoutSx, minWidth: 50 }}> + {localKHighpass > 0 ? localKHighpass.toFixed(3) : "off"} + </Typography> + </Stack> + <Stack direction="row" alignItems="center" gap={1} sx={{ flexWrap: "nowrap" }}> + <Typography + sx={{ ...typo.labelSmall, flexShrink: 0 }} + title="Damp low-r oscillations (needs the density)" + > + Damp + </Typography> + <Switch + checked={dampOrigin} + disabled={lockParameters} + onChange={(e) => toggleDamp(e.target.checked)} + size="small" + sx={switchSmall} + slotProps={{ input: { role: "switch", "aria-label": "Damp low-r oscillations" } }} + /> + {dampOrigin && ( + <> + <Typography + sx={{ ...typo.labelSmall, ml: 1, flexShrink: 0 }} + title="Upper end r (Å) of the low-r correction: G(r) is forced onto -4π ρ₀ r on [0, r cut]. Set it below the nearest-neighbour distance" + > + r_cut + </Typography> + <CommitSlider + value={localRCut} + onChange={(_, v) => setLocalRCut(roundRCut(v as number))} + onChangeCommitted={(_, v) => commitRCut(v as number)} + disabled={lockParameters} + min={rCutRange.min} max={rCutRange.max} step={rCutRange.step} size="small" + sx={{ ...sliderSmall, flex: 1 }} + aria-label="r cut (upper end of the low-r correction)" + /> + <Typography sx={{ ...readoutSx, minWidth: `${rCutRange.decimals + 4}ch` }}> + {localRCut.toFixed(rCutRange.decimals)} Å + </Typography> + </> + )} + </Stack> + {dampOrigin && ( + <Stack direction="row" alignItems="center" gap={1} sx={{ flexWrap: "nowrap" }}> + <Typography + sx={parameterLabelSx} + title="Maximum number of iterations of the low-r correction" + > + iterations + </Typography> + <CommitSlider + value={localMaxIter} + onChange={(_, v) => setLocalMaxIter(v as number)} + onChangeCommitted={(_, v) => commitMaxIter(v as number)} + disabled={lockParameters} + min={1} max={30} step={1} size="small" + sx={parameterSliderSx} + aria-label="iterations (maximum of the low-r correction)" + /> + <Typography sx={{ ...readoutSx, minWidth: 50 }}>{localMaxIter}</Typography> + </Stack> + )} + {(plotMode === "gr" || dampOrigin) && ( + <Stack direction="row" alignItems="center" gap={1}> + <Typography sx={parameterLabelSx}>Density</Typography> + <input + type="number" + step="0.001" + min={0} + value={localDensity} + placeholder="required" + title="Number density (atoms/ų) for g(r) and the damping. It is never estimated; clear the field to unset it." + aria-label="Number density in atoms per cubic ångström" + disabled={lockParameters} + onFocus={handleDensityFocus} + onChange={handleDensityChange} + onBlur={handleDensityBlur} + onKeyDown={blurOnEnter} + style={{ + width: 90, boxSizing: "content-box", fontSize: 12, padding: "3px 6px", fontFamily: MONO, + border: `1px solid ${colors.border}`, + background: isDark ? "#1a1a1a" : "#fff", + color: colors.text, + outline: "none", + }} + /> + <Typography sx={typo.labelSmall}>Å⁻³</Typography> + </Stack> + )} + </Box> + )} + </Box> + </Stack> + </Box> + ); +} + +// A notebook saved with save_state=False keeps a static picture instead of the scan image, mask and +// line-scan stack. Reopened without a kernel, the widget shows that picture; live, it hides the +// picture's separate output. +function ShowPDFRoot() { + const model = useModel(); + const rootRef = React.useRef<HTMLDivElement>(null); + const [scanRows] = useModelState<number>("scan_rows"); + const [navImageBytes] = useModelState<DataView>("nav_image_bytes"); + const [preview] = useModelState<string>("_static_fallback_jpeg"); + const [previewMime] = useModelState<string>("_static_fallback_mime"); + const hasPixels = byteLength(navImageBytes) > 0; + useHideStaticFallback(model, rootRef, hasPixels); + if (!hasPixels && scanRows > 0 && preview) { + return ( + <div ref={rootRef} style={{ fontFamily: FONT, padding: 8 }}> + <img + src={`data:${previewMime || "image/png"};base64,${preview}`} + alt="Saved ShowPDF view" + style={{ maxWidth: "100%", display: "block" }} + /> + <div style={{ fontSize: 12, opacity: 0.7 }}>Saved preview · rerun the cell for interaction</div> + </div> + ); + } + return ( + <div ref={rootRef}> + <ShowPDFWidget /> + </div> + ); +} + +export default { render: createRender(ShowPDFRoot) }; diff --git a/js/showpdf/kwindow.test.ts b/js/showpdf/kwindow.test.ts new file mode 100644 index 000000000..25a9d67c7 --- /dev/null +++ b/js/showpdf/kwindow.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { committedFitEnd, committedWindowEnd } from "./kwindow"; + +describe("ShowPDF k-window slider commit", () => { + const kMinAvail = Math.fround(0.05), kMaxAvail = Math.fround(1.55); // float32 data range + + it("keeps the follow-fit sentinel for an untouched end clamped by the slider", () => { + // k_max_fit = 1.55 lies just above the float32 k_max_available: MUI reports + // the untouched upper handle as kMaxAvail, which is still "left where it was". + expect(committedWindowEnd(kMaxAvail, 0, 1.55, kMinAvail, kMaxAvail)).toBe(0); + expect(committedWindowEnd(kMinAvail, 0, 0.05, kMinAvail, kMaxAvail)).toBe(0); + expect(committedWindowEnd(1.2, 0, 1.2, kMinAvail, kMaxAvail)).toBe(0); + expect(committedWindowEnd(0.4, 0.4, 0.3, kMinAvail, kMaxAvail)).toBe(0.4); + }); + + it("keeps a negative setting, which follows the fit range as 0 does", () => { + // Python accepts any finite end and treats one <= 0 as unset: the slider shows the fit end. + expect(committedWindowEnd(0.3, -0.5, 0.3, kMinAvail, kMaxAvail)).toBe(-0.5); + expect(committedWindowEnd(1.2, -2, 1.2, kMinAvail, kMaxAvail)).toBe(-2); + // a moved end is explicit, also at the slider's min (not the clamp of the setting) + expect(committedWindowEnd(kMinAvail, -0.5, 0.3, kMinAvail, kMaxAvail)).toBe(kMinAvail); + expect(committedWindowEnd(0.31, -0.5, 0.3, kMinAvail, kMaxAvail)).toBe(0.31); + }); + + it("keeps the setting of an untouched end off the slider's step grid", () => { + // fit max after keyboard steps (1.2 - 20 * 0.01), or set from Python + expect(committedWindowEnd(0.9999999999999998, 0, 0.9999999999999998, kMinAvail, kMaxAvail)).toBe(0); + expect(committedWindowEnd(1.234, 0, 1.234, kMinAvail, kMaxAvail)).toBe(0); + // an explicit off-grid end keeps its exact value + expect(committedWindowEnd(1.234, 1.234, 1.2, kMinAvail, kMaxAvail)).toBe(1.234); + }); + + it("takes a moved end as an explicit bound", () => { + expect(committedWindowEnd(0.06, 0, 0.3, kMinAvail, kMaxAvail)).toBe(0.06); + expect(committedWindowEnd(1.3, 0, 1.55, kMinAvail, kMaxAvail)).toBe(1.3); + // one step from an off-grid end: a keyboard step, and a drag to the next grid value + expect(committedWindowEnd(1.224, 0, 1.234, kMinAvail, kMaxAvail)).toBe(1.224); + expect(committedWindowEnd(1.23, 0, 1.234, kMinAvail, kMaxAvail)).toBe(1.23); + expect(committedWindowEnd(1.24, 0, 1.234, kMinAvail, kMaxAvail)).toBe(1.24); + expect(committedWindowEnd(1.0, 0, 1.01, kMinAvail, kMaxAvail)).toBe(1.0); + // to the slider's max (End, or a drag past it) from an end less than a step below it + expect(committedWindowEnd(kMaxAvail, 1.546, 1.2, kMinAvail, kMaxAvail)).toBe(kMaxAvail); + expect(committedWindowEnd(kMaxAvail, 0, 1.546, kMinAvail, kMaxAvail)).toBe(kMaxAvail); + }); +}); + +describe("ShowPDF k-fit slider commit", () => { + const kMinAvail = Math.fround(0.05), kMaxAvail = Math.fround(1.55); // float32 data range + + it("keeps the value of an untouched end clamped by the slider", () => { + // k_max_fit = 1.55 lies just above the float32 k_max_available, k_min_fit = 0.05 just below + expect(committedFitEnd(kMaxAvail, 1.55, kMinAvail, kMaxAvail)).toBe(1.55); + expect(committedFitEnd(kMinAvail, 0.05, kMinAvail, kMaxAvail)).toBe(0.05); + expect(committedFitEnd(1.234, 1.234, kMinAvail, kMaxAvail)).toBe(1.234); + }); + + it("takes a moved end", () => { + expect(committedFitEnd(1.54, 1.55, kMinAvail, kMaxAvail)).toBe(1.54); + expect(committedFitEnd(0.31, 0.3, kMinAvail, kMaxAvail)).toBe(0.31); + expect(committedFitEnd(kMaxAvail, 1.2, kMinAvail, kMaxAvail)).toBe(kMaxAvail); + }); +}); diff --git a/js/showpdf/kwindow.ts b/js/showpdf/kwindow.ts new file mode 100644 index 000000000..839ba24eb --- /dev/null +++ b/js/showpdf/kwindow.ts @@ -0,0 +1,19 @@ +// k-window slider commit: a handle left where it was keeps its setting (<= 0: follow the fit +// range), a moved one takes the committed value. +export function committedWindowEnd( + committed: number, setting: number, fit: number, min: number, max: number, +): number { + const shown = shownOnSlider(setting > 0 ? setting : fit, min, max); + return committed === shown ? setting : committed; +} + +// k-fit slider commit, by the same rule (Python accepts a fit end up to half a k step outside the +// float32 k range, which MUI reports clamped). +export function committedFitEnd(committed: number, value: number, min: number, max: number): number { + return committed === shownOnSlider(value, min, max) ? value : committed; +} + +// Where an untouched handle reports value v: MUI clamps it to the slider's [min, max]. +function shownOnSlider(v: number, min: number, max: number) { + return Math.max(min, Math.min(v, max)); +} diff --git a/js/showpdf/linescan.numpy.test.ts b/js/showpdf/linescan.numpy.test.ts new file mode 100644 index 000000000..43a4711bf --- /dev/null +++ b/js/showpdf/linescan.numpy.test.ts @@ -0,0 +1,210 @@ +// @ts-nocheck +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { + effectiveLineEnds, lineBandColumns, lineBandIncludes, linescanSampleCount, linescanSampleIndex, + sampleFraction, +} from "./linescan"; + +// Python reference: ShowPDF._effective_line and ShowPDF._linescan_samples +// themselves (bound to a light stub so no dataset is needed), for random lines +// with whole, half-pixel and fractional endpoints, perpendicular sampling, +// several widths and linescan_max_bins caps, for lines every 4 degrees, and +// for decimal perpendicular lines that snap differently with a hypot length. +// Skips when the interpreter in $PYTHON cannot import ShowPDF (it needs +// quantem PR 248). +function pythonSamples() { + const python = process.env.PYTHON || "python"; + const code = String.raw` +import json +import math +import random +import sys + +try: + from quantem.widget.showpdf import ShowPDF +except Exception: + sys.exit(1) + + +class Stub: + _line_band_mask = ShowPDF._line_band_mask + _effective_line = ShowPDF._effective_line + _linescan_samples = ShowPDF._linescan_samples + + +rng = random.Random(7) +stubs = [] +for i in range(150): + st = Stub() + st.scan_rows, st.scan_cols = rng.randint(6, 40), rng.randint(6, 40) + st.line_active = True + st.line_perpendicular = rng.random() < 0.4 + kind = rng.random() + if kind < 0.4: + pick = lambda hi: float(rng.randint(0, hi - 1)) + elif kind < 0.7: + pick = lambda hi: rng.randint(0, 2 * hi - 3) / 2.0 + elif kind < 0.85: + pick = lambda hi: rng.uniform(0, hi - 1) + else: # ends outside the scan (clipped) + pick = lambda hi: rng.uniform(-8, hi + 7) + st.line_row0, st.line_col0 = pick(st.scan_rows), pick(st.scan_cols) + st.line_row1, st.line_col1 = pick(st.scan_rows), pick(st.scan_cols) + st.line_width = rng.choice([1, 3, 5, 7, 9]) + st.linescan_max_bins = rng.choice([256, 256, 256, 7, 3, 1]) + stubs.append(st) +for deg in range(0, 360, 4): # every angle class, from the centre of a 41 x 41 scan + st = Stub() + st.scan_rows = st.scan_cols = 41 + st.line_active, st.line_perpendicular, st.linescan_max_bins = True, False, 256 + th = math.radians(deg) + st.line_row0, st.line_col0 = 20.0, 20.0 + st.line_row1, st.line_col1 = 20.0 - 17 * math.sin(th), 20.0 + 17 * math.cos(th) + st.line_width = (1, 3, 5, 9)[deg // 4 % 4] + stubs.append(st) +# Decimal ends whose bisector snaps differently when its length comes from a +# hypot (np.hypot, math.hypot, Math.hypot) instead of sqrt(dr * dr + dc * dc). +for ends in ([0, 26.2, 34.4, 4.8], [37.3, 28.7, 30.8, 17.8], [19.2, 3.5, 7.2, 10.9], [55.9, 57.1, 2.6, 9.2], + [16.4, 34.0, 53.4, 7.2]): + st = Stub() + st.scan_rows = st.scan_cols = 64 + st.line_active, st.line_perpendicular, st.linescan_max_bins = True, True, 256 + st.line_row0, st.line_col0, st.line_row1, st.line_col1 = ends + st.line_width = 3 + stubs.append(st) +cases = [] +for st in stubs: + eff = st._effective_line() + res = st._linescan_samples() + case = dict(drawn=[st.line_row0, st.line_col0, st.line_row1, st.line_col1], perp=st.line_perpendicular, + eff=[float(v) for v in eff], rows=st.scan_rows, cols=st.scan_cols, + width=st.line_width, maxBins=st.linescan_max_bins, n=None) + if res is not None: + band, idx, n, length, positions = res + case.update(n=int(n), length=length, positions=[float(p) for p in positions], + band=band.astype(int).ravel().tolist(), idx=idx.astype(int).ravel().tolist()) + cases.append(case) +print(json.dumps(cases)) +`; + try { + const out = execFileSync(python, ["-c", code], { + encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"], + }); + return JSON.parse(out); + } catch { + return null; + } +} + +const cases = pythonSamples(); + +// Python reference for every integer line on a 16 x 16 scan, sampled +// perpendicular. Many of their bisectors have an end exactly half way between +// scan positions, so the line's length must round exactly as in Python for +// that end to snap the same way (e.g. (0, 0) -> (12, 7) samples (9, 0) -> (2, 10)). +function pythonPerpendicularSweep() { + const python = process.env.PYTHON || "python"; + const code = String.raw` +import json +import sys + +try: + from quantem.widget.showpdf import ShowPDF +except Exception: + sys.exit(1) + + +class Stub: + _effective_line = ShowPDF._effective_line + + +st = Stub() +st.scan_rows = st.scan_cols = 16 +st.line_active = st.line_perpendicular = True +lines = [] +for r0 in range(16): + for c0 in range(16): + for r1 in range(16): + for c1 in range(16): + st.line_row0, st.line_col0, st.line_row1, st.line_col1 = float(r0), float(c0), float(r1), float(c1) + lines.append([r0, c0, r1, c1, *(float(v) for v in st._effective_line())]) +print(json.dumps(lines)) +`; + try { + const out = execFileSync(python, ["-c", code], { + encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"], + }); + return JSON.parse(out); + } catch { + return null; + } +} + +const sweep = pythonPerpendicularSweep(); + +(cases ? describe : describe.skip)("ShowPDF line-scan sampling matches Python", () => { + it("has enough reference lines", () => { + expect(cases.filter((c) => c.n !== null).length).toBeGreaterThan(80); + }); + + it("snaps the sampled line exactly as Python", () => { + const hypotCase = { drawn: [0, 26.2, 34.4, 4.8], perp: true, eff: [8, 0, 28, 33] }; + expect(cases).toContainEqual(expect.objectContaining(hypotCase)); + for (const c of cases) { + const [r0, c0, r1, c1] = c.drawn; + const e = effectiveLineEnds({ r0, c0, r1, c1 }, c.perp, c.rows, c.cols); + expect([e.r0, e.c0, e.r1, e.c1]).toEqual(c.eff); + } + }); + + it("gives the same samples, positions and assignment for every scan position", () => { + for (const c of cases) { + const [r0, c0, r1, c1] = c.eff; + // no band (a zero-length line): nothing to draw or select + if (c.n === null) { + expect(lineBandIncludes({ r0, c0, r1, c1 }, c.width)).toBeNull(); + continue; + } + const dr = r1 - r0; + const dc = c1 - c0; + const segSq = dr * dr + dc * dc; + const len = Math.sqrt(segSq); + const n = linescanSampleCount(dr, dc, c.maxBins); + expect(n).toBe(c.n); + for (let i = 0; i < n; i++) expect(sampleFraction(i, n) * c.length).toBeCloseTo(c.positions[i], 9); + if (n > 1) expect(c.positions[n - 1]).toBeCloseTo(len, 9); // the last position is the tip + // the band the overlay draws and the offline recompute selects + const inBand = lineBandIncludes({ r0, c0, r1, c1 }, c.width); + expect(inBand).not.toBeNull(); + for (let r = 0; r < c.rows; r++) { + for (let col = 0; col < c.cols; col++) { + const t = ((r - r0) * dr + (col - c0) * dc) / segSq; + expect(inBand(r, col) ? 1 : 0).toBe(c.band[r * c.cols + col]); + if (inBand(r, col)) expect(linescanSampleIndex(t, n)).toBe(c.idx[r * c.cols + col]); + } + } + // the overlay tests only lineBandColumns' columns and paints exactly the band + const painted = new Array(c.rows * c.cols).fill(0); + for (let r = 0; r < c.rows; r++) { + const [lo, hi] = lineBandColumns({ r0, c0, r1, c1 }, c.width, r, c.cols); + for (let col = lo; col <= hi; col++) if (inBand(r, col)) painted[r * c.cols + col] = 1; + } + expect(painted).toEqual(c.band); + } + // Thousands of checks: about 1.5 s alone, past the default 5 s when every test file runs at once. + }, 20_000); +}); + +(sweep ? describe : describe.skip)("ShowPDF perpendicular line ends match Python", () => { + it("snaps every integer line's bisector on a 16 x 16 scan as Python", () => { + expect(sweep.length).toBe(16 ** 4); + expect(sweep).toContainEqual([0, 0, 12, 7, 9, 0, 2, 10]); + const differ = []; + for (const [r0, c0, r1, c1, ...eff] of sweep) { + const e = effectiveLineEnds({ r0, c0, r1, c1 }, true, 16, 16); + if ([e.r0, e.c0, e.r1, e.c1].some((v, i) => v !== eff[i])) differ.push([r0, c0, r1, c1]); + } + expect(differ).toEqual([]); + }); +}); diff --git a/js/showpdf/linescan.test.ts b/js/showpdf/linescan.test.ts new file mode 100644 index 000000000..7fd820d4b --- /dev/null +++ b/js/showpdf/linescan.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + effectiveLineEnds, lineBandColumns, lineBandIncludes, linescanSampleCount, linescanSampleIndex, + sampleFraction, sampledBinIndices, type LineEnds, +} from "./linescan"; + +describe("ShowPDF line-scan sampling", () => { + it("snaps the sampled line's ends to scan positions", () => { + expect(effectiveLineEnds({ r0: 1.3, c0: 0.4, r1: 15.8, c1: 12.5 }, false, 40, 40)) + .toEqual({ r0: 1, c0: 0, r1: 16, c1: 13 }); + // perpendicular bisector of (14, 12) -> (14, 23) has half-pixel ends (19.5, 17.5) + // -> (8.5, 17.5); they snap halves up + expect(effectiveLineEnds({ r0: 14, c0: 12, r1: 14, c1: 23 }, true, 40, 40)) + .toEqual({ r0: 20, c0: 18, r1: 9, c1: 18 }); + expect(effectiveLineEnds({ r0: 3, c0: 3, r1: 3, c1: 3 }, true, 40, 40)) + .toEqual({ r0: 3, c0: 3, r1: 3, c1: 3 }); + }); + + it("clips the sampled line to the scan, so its tip has data", () => { + // perpendicular bisector of (1, 4) -> (1, 20) runs from (9, 12) to (-7, 12) + expect(effectiveLineEnds({ r0: 1, c0: 4, r1: 1, c1: 20 }, true, 24, 24)) + .toEqual({ r0: 9, c0: 12, r1: 0, c1: 12 }); + // oblique line leaving the right edge + expect(effectiveLineEnds({ r0: 2, c0: 10, r1: 12, c1: 40 }, false, 24, 24)) + .toEqual({ r0: 2, c0: 10, r1: 6, c1: 23 }); + // wholly outside: collapses to a point + const out = effectiveLineEnds({ r0: -5, c0: 2, r1: -2, c1: 9 }, false, 24, 24); + expect(out.r0 === out.r1 && out.c0 === out.c1).toBe(true); + }); + + it("selects the band of _line_band_mask: flat ends at A and B, half width at least 0.5", () => { + // The band as one string of 0/1 per scan row. + const band = (ends: LineEnds, width: number, rows: number, cols: number) => { + const inBand = lineBandIncludes(ends, width)!; + const row = (r: number) => Array.from({ length: cols }, (_, c) => (inBand(r, c) ? 1 : 0)).join(""); + return Array.from({ length: rows }, (_, r) => row(r)); + }; + // horizontal, width 3: rows 1-3, columns from A to B only + expect(band({ r0: 2, c0: 1, r1: 2, c1: 4 }, 3, 5, 6)) + .toEqual(["000000", "011110", "011110", "011110", "000000"]); + // width 1: half width 0.5; the neighbours of a 45-degree line are sqrt(0.5) off it + expect(band({ r0: 0, c0: 0, r1: 3, c1: 3 }, 1, 4, 4)).toEqual(["1000", "0100", "0010", "0001"]); + expect(band({ r0: 0, c0: 0, r1: 2, c1: 4 }, 1, 3, 5)).toEqual(["11000", "01110", "00011"]); + expect(lineBandIncludes({ r0: 3, c0: 3, r1: 3, c1: 3 }, 5)).toBeNull(); + }); + + it("limits each row of the band to its columns, at every angle", () => { + // Lines from near the centre of a 121 x 121 scan every 3 degrees, short to + // clipped, with snapped (sampled) and with fractional ends. + const rows = 121, cols = 121; + let lines = 0, missed = 0, extra = 0; + for (let deg = 0; deg < 360; deg += 3) { + const th = (deg * Math.PI) / 180; + for (const len of [1, 7, 50, 80]) { + const drawn = { r0: 60.3, c0: 59.8, r1: 60.3 - len * Math.sin(th), c1: 59.8 + len * Math.cos(th) }; + for (const ends of [effectiveLineEnds(drawn, false, rows, cols), drawn]) { + for (const width of [1, 3, 5, 9, 15]) { + const inBand = lineBandIncludes(ends, width); + if (!inBand) continue; + lines++; + for (let r = 0; r < rows; r++) { + const [lo, hi] = lineBandColumns(ends, width, r, cols); + let n = 0; + for (let c = 0; c < cols; c++) { + if (!inBand(r, c)) continue; + n++; + if (c < lo || c > hi) missed++; // every band position is a candidate + } + if (hi - lo + 1 > n + 4) extra++; // and at most 4 other columns are + } + } + } + } + } + expect(lines).toBeGreaterThan(4000); + expect(missed).toBe(0); + expect(extra).toBe(0); + }); + + it("takes one sample per scan step along the longer axis, capped", () => { + expect(linescanSampleCount(0, 10, 256)).toBe(11); // horizontal, 10 px + expect(linescanSampleCount(10, 10, 256)).toBe(11); // 45 degrees: 14.1 px, 11 samples + expect(linescanSampleCount(-7, 3, 256)).toBe(8); + expect(linescanSampleCount(7, 3, 4)).toBe(4); // linescan_max_bins cap + expect(linescanSampleCount(0, 0, 256)).toBe(1); + }); + + it("puts the first sample at A and the last at the tip B", () => { + for (const N of [2, 3, 8, 11, 57]) { + expect(sampleFraction(0, N)).toBe(0); + expect(sampleFraction(N - 1, N)).toBe(1); + } + expect(sampleFraction(0, 1)).toBe(0.5); + }); + + it("assigns positions to the nearest sample", () => { + expect(linescanSampleIndex(0, 11)).toBe(0); + expect(linescanSampleIndex(1, 11)).toBe(10); + expect(linescanSampleIndex(0.049, 11)).toBe(0); + expect(linescanSampleIndex(0.05, 11)).toBe(1); // tie goes up, as floor(x + 0.5) + expect(linescanSampleIndex(0.75 - 1e-15, 3)).toBe(2); // a tie off by rounding error still goes up + expect(linescanSampleIndex(0.5, 1)).toBe(0); + }); + + it("picks exactly evenly spaced waterfall curves that end at the tip", () => { + for (let N = 1; N <= 80; N++) { + for (let count = 1; count <= 30; count++) { + const s = sampledBinIndices(N, count); + expect(s.length).toBeGreaterThan(0); + expect(s[0]).toBeGreaterThanOrEqual(0); + expect(s[s.length - 1]).toBe(N - 1); // the tip is always drawn + const steps = new Set(s.slice(1).map((v, i) => v - s[i])); + expect(steps.size).toBeLessThanOrEqual(1); // one step size only + if (steps.size === 1) expect(s[0]).toBeLessThan([...steps][0]); // gap at A below one step + if (count >= N) expect(s.length).toBe(N); + if (count <= 1) expect(s).toEqual([N - 1]); + // the count is the closest achievable with exact spacing ending at the tip + if (count > 1 && count < N) { + let best = Infinity; + for (let k = 1; k <= N - 1; k++) { + best = Math.min(best, Math.abs(Math.floor((N - 1) / k) + 1 - count)); + } + expect(Math.abs(s.length - count)).toBe(best); + } + } + } + expect(sampledBinIndices(10, 3)).toEqual([1, 5, 9]); + expect(sampledBinIndices(15, 10)).toEqual([0, 2, 4, 6, 8, 10, 12, 14]); + expect(sampledBinIndices(22, 10)).toEqual([1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21]); + }); +}); diff --git a/js/showpdf/linescan.ts b/js/showpdf/linescan.ts new file mode 100644 index 000000000..2a88acd65 --- /dev/null +++ b/js/showpdf/linescan.ts @@ -0,0 +1,165 @@ +// Line geometry and line-scan sampling shared by the display and the offline recompute. Mirrors +// ShowPDF._effective_line, _line_band_mask and _linescan_samples in showpdf.py. +// +// Scan positions are whole pixels. The sampled line has its ends on scan positions and is clipped +// to the scan, so its band, its samples and its tip all sit on scan positions with data. The +// samples are evenly spaced from the start A to the tip B, one per scan step along the line's +// longer axis (capped by linescan_max_bins), and each scan position in the band is averaged into +// its nearest sample. + +export interface LineEnds { r0: number; c0: number; r1: number; c1: number } + +// Nearest scan position; halves round up (Python: math.floor(v + 0.5)). +export function snapToScan(v: number): number { + return Math.floor(v + 0.5); +} + +// The line actually sampled: the drawn line or its perpendicular bisector, with both ends snapped +// to scan positions and clipped to the scan. A line wholly outside the scan collapses to a point, +// which samples nothing. +export function effectiveLineEnds(l: LineEnds, perpendicular: boolean, rows: number, cols: number): LineEnds { + const line = perpendicular ? perpendicularBisector(l) : l; + const snapped = { + r0: snapToScan(line.r0), + c0: snapToScan(line.c0), + r1: snapToScan(line.r1), + c1: snapToScan(line.c1), + }; + return clipToScan(snapped, rows, cols); +} + +// Same midpoint and length, rotated 90 degrees. The length is sqrt(dr * dr + dc * dc) as in Python, +// not Math.hypot (which can be an ulp off), so an end half way between scan positions snaps the same. +function perpendicularBisector(l: LineEnds) { + const dr = l.r1 - l.r0; + const dc = l.c1 - l.c0; + const len = Math.sqrt(dr * dr + dc * dc); + if (len > 0) { + const mr = (l.r0 + l.r1) / 2; + const mc = (l.c0 + l.c1) / 2; + const pr = -dc / len; // unit vector perpendicular to the line + const pc = dr / len; + const half = len / 2; + return { r0: mr - pr * half, c0: mc - pc * half, r1: mr + pr * half, c1: mc + pc * half }; + } + return l; +} + +// Liang-Barsky clip to the scan [0, rows - 1] x [0, cols - 1], with the clipped ends snapped again. +function clipToScan(l: LineEnds, rows: number, cols: number) { + const { r0, c0 } = l; + const dr = l.r1 - r0; + const dc = l.c1 - c0; + const point = { r0, c0, r1: r0, c1: c0 }; + let t0 = 0; + let t1 = 1; + const edges = [[-dr, r0], [dr, rows - 1 - r0], [-dc, c0], [dc, cols - 1 - c0]]; + for (const [p, q] of edges) { + if (p === 0) { + if (q < 0) return point; // parallel to this edge and outside it + } else if (p < 0) { + t0 = Math.max(t0, q / p); + } else { + t1 = Math.min(t1, q / p); + } + } + if (t0 > t1) return point; + return { + r0: snapToScan(r0 + t0 * dr), + c0: snapToScan(c0 + t0 * dc), + r1: snapToScan(r0 + t1 * dr), + c1: snapToScan(c0 + t1 * dc), + }; +} + +// Membership test for the band of the sampled line (see effectiveLineEnds), in the operation order +// of ShowPDF._line_band_mask: t (0 = A, 1 = B) in [0, 1], so the ends are flat, and the distance to +// the line at most max(0.5, width / 2), width in scan px. null for a zero-length line (no band). +export function lineBandIncludes(ends: LineEnds, width: number): ((r: number, c: number) => boolean) | null { + const { r0, c0, r1, c1 } = ends; + const dr = r1 - r0; + const dc = c1 - c0; + const segSq = dr * dr + dc * dc; + if (segSq <= 0) return null; + const len = Math.sqrt(segSq); + const halfW = Math.max(0.5, width / 2); + return (r, c) => { + const pr = r - r0; + const pc = c - c0; + const t = (pr * dr + pc * dc) / segSq; + return t >= 0 && t <= 1 && Math.abs(pr * dc - pc * dr) / len <= halfW; + }; +} + +// Candidate columns [lo, hi] of scan row r for the band of a line of nonzero length, within +// [0, cols - 1] (lo > hi if none): the two conditions of lineBandIncludes solved for the column, +// one column wider on each side against rounding. Testing only these columns with lineBandIncludes +// gives exactly the band, in time proportional to its area instead of its bounding box. +export function lineBandColumns(ends: LineEnds, width: number, r: number, cols: number): [number, number] { + const { r0, c0, r1, c1 } = ends; + const dr = r1 - r0; + const dc = c1 - c0; + const segSq = dr * dr + dc * dc; + const len = Math.sqrt(segSq); + const halfW = Math.max(0.5, width / 2); + const pr = r - r0; + let lo = 0; + let hi = cols - 1; + // Keeps the columns c0 + pc with pc between a and b. + const keepBetween = (a: number, b: number) => { + lo = Math.max(lo, Math.floor(c0 + Math.min(a, b)) - 1); + hi = Math.min(hi, Math.ceil(c0 + Math.max(a, b)) + 1); + }; + // Distance: |pr * dc - pc * dr| <= halfW * len, with pc = c - c0. On a horizontal line it does + // not depend on the column and is tested as lineBandIncludes does. + if (dr === 0) { + if (Math.abs(pr * dc) / len > halfW) return [0, -1]; + } else { + keepBetween((pr * dc - halfW * len) / dr, (pr * dc + halfW * len) / dr); + } + // Along the line: 0 <= pr * dr + pc * dc <= segSq; on a vertical line, tested as above. + if (dc === 0) { + const t = (pr * dr) / segSq; + if (!(t >= 0 && t <= 1)) return [0, -1]; + } else { + keepBetween((-pr * dr) / dc, (segSq - pr * dr) / dc); + } + return [lo, hi]; +} + +// Number of samples for a line with row/col extent (dr, dc) between snapped ends: one per scan +// step along the longer axis, capped at maxBins. +export function linescanSampleCount(dr: number, dc: number, maxBins: number): number { + const steps = Math.round(Math.max(Math.abs(dr), Math.abs(dc))); + return Math.max(1, Math.min(maxBins, steps + 1)); +} + +// Nearest sample of a position at fraction t (0 = A, 1 = B) along the line, as in Python: the +// offset is snapped to 1e-9 so exact ties are not split by rounding error, then floor(x + 0.5). +export function linescanSampleIndex(t: number, n: number): number { + if (n <= 1) return 0; + const offset = Math.min(Math.max(t, 0), 1) * (n - 1); + const snapped = Math.floor(offset * 1e9 + 0.5) / 1e9; + return Math.min(Math.floor(snapped + 0.5), n - 1); +} + +// Fraction along the line (A = 0, B = 1) of sample i of N; the last is B. +export function sampleFraction(i: number, N: number): number { + return N > 1 ? i / (N - 1) : 0.5; +} + +// Evenly spaced subset of the N samples for the waterfall view, counted back from the tip B, which +// is always included. The step is the one whose curve count is closest to `count` (on a tie, more +// curves); any remainder is a gap of less than one step at the start A. +export function sampledBinIndices(N: number, count: number): number[] { + if (N <= 0) return []; + if (count >= N) return Array.from({ length: N }, (_, i) => i); + if (count <= 1) return [N - 1]; + const curves = (step: number) => Math.floor((N - 1) / step) + 1; // samples N - 1, N - 1 - step, ... + let best = 1; + for (let step = 2; step <= N - 1; step++) { + if (Math.abs(curves(step) - count) < Math.abs(curves(best) - count)) best = step; + } + const n = curves(best); + return Array.from({ length: n }, (_, j) => N - 1 - (n - 1 - j) * best); +} diff --git a/js/showpdf/pdf_compute.numpy.test.ts b/js/showpdf/pdf_compute.numpy.test.ts new file mode 100644 index 000000000..97b808fef --- /dev/null +++ b/js/showpdf/pdf_compute.numpy.test.ts @@ -0,0 +1,290 @@ +// @ts-nocheck +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { computeGofR, computeGr, dampOriginOscillations, kWindow } from "./pdf_compute"; + +// Python reference: quantem.diffraction.PairDistributionFunction on a +// k-calibrated synthetic 4D-STEM dataset. The JS must reproduce the +// library's background, F(k), S(k), G(r), damped F(k)/G(r) and g(r). +// Skips when the interpreter in $PYTHON (default "python") lacks the library +// the widget is written against (calculate_Gr with density and max_iter, and +// the Lorch k window). +function pdfReferenceFixture() { + const python = process.env.PYTHON || "python"; + const code = String.raw` +import inspect +import json +import sys + +import numpy as np +import torch + +try: + from quantem.core.datastructures.dataset4dstem import Dataset4dstem + from quantem.diffraction import PairDistributionFunction +except ImportError: + sys.exit(1) +if not {"density", "max_iter"} <= set(inspect.signature(PairDistributionFunction.calculate_Gr).parameters): + sys.exit(1) + +det = 64 +rr, cc = np.mgrid[0:det, 0:det].astype(np.float64) +rad = np.hypot(rr - 31.5, cc - 31.5) +dp = ( + 400.0 * np.exp(-(rad**2) / (2 * 2.0**2)) + + 30.0 * np.exp(-rad / 9.0) + + 6.0 * np.exp(-((rad - 9.0) ** 2) / 5.0) + + 2.5 * np.exp(-((rad - 17.0) ** 2) / 9.0) + + 1.2 * np.exp(-((rad - 25.0) ** 2) / 12.0) + + 0.2 +).astype(np.float32) +data = np.broadcast_to(dp, (2, 2, det, det)).copy() +ds = Dataset4dstem.from_array(data, sampling=(1.0, 1.0, 0.05, 0.05), units=("A", "A", "A^-1", "A^-1")) +pdf = PairDistributionFunction.from_data( + ds, find_origin=False, origin_row=31.5, origin_col=31.5, num_annular_bins=90, device="cpu" +) +# An older window (the sine taper is 1 mid-band) is not the one the JS follows. +w_mid = float(pdf._k_window(torch.tensor([0.75]), 0.5, 1.0)[0]) +if abs(w_mid - np.sin(0.75 * np.pi) / (0.75 * np.pi)) > 1e-6: + sys.exit(1) +qq = np.asarray(pdf.qq, dtype=float) +cases = [ + dict(name="defaults", k_min_fit=None, k_max_fit=None, k_min_window=None, k_max_window=None, + k_lowpass=None, k_highpass=None, r_max=10.0, r_step=0.02, + density=None, r_cut=0.8), + dict(name="window", k_min_fit=0.3, k_max_fit=1.5, k_min_window=0.35, k_max_window=1.4, + k_lowpass=None, k_highpass=None, r_max=12.0, r_step=0.05, + density=None, r_cut=0.8), + dict(name="lowpass", k_min_fit=0.3, k_max_fit=1.5, k_min_window=None, k_max_window=None, + k_lowpass=0.05, k_highpass=None, r_max=10.0, r_step=0.02, + density=None, r_cut=0.8), + dict(name="bandpass", k_min_fit=0.3, k_max_fit=1.5, k_min_window=None, k_max_window=None, + k_lowpass=0.08, k_highpass=0.03, r_max=10.0, r_step=0.02, + density=None, r_cut=0.8), + dict(name="damped", k_min_fit=0.3, k_max_fit=1.5, k_min_window=None, k_max_window=None, + k_lowpass=None, k_highpass=None, r_max=10.0, r_step=0.02, + density=0.05, r_cut=0.8), + # r_cut on the r = 0.78 point, which torch's float32 arange (Apple silicon) + # puts one ulp above float32(0.78): the damping leaves that point out. + dict(name="damped_rcut_tie", k_min_fit=0.3, k_max_fit=1.5, k_min_window=None, + k_max_window=None, k_lowpass=None, k_highpass=None, r_max=10.0, r_step=0.02, + density=0.05, r_cut=0.78), + # 3 * k_lowpass / dk sits just above an integer in float64 but below it with + # the library's float32 dk: the kernel radius must follow the float32 value. + dict(name="lowpass_kernel_edge", k_min_fit=0.3, k_max_fit=1.5, k_min_window=None, k_max_window=None, + k_lowpass=0.05 * 4 / 3 * (1 + 3e-9), k_highpass=None, r_max=10.0, + r_step=0.02, density=None, r_cut=0.8), + # k_min_fit is the next float64 above the k = 0.15 bin, so that bin is + # outside the fit range in float64 but inside it in float32; the damping + # corrects it (library float32 comparison) and the window reaches it. + dict(name="damping_kfit_edge", k_min_fit=float(np.nextafter(qq[3], np.inf)), k_max_fit=1.5, + k_min_window=0.1, k_max_window=None, k_lowpass=None, k_highpass=None, + r_max=10.0, r_step=0.02, density=0.05, r_cut=0.8), +] +assert qq[3] < cases[-1]["k_min_fit"] and np.float32(qq[3]) == np.float32(cases[-1]["k_min_fit"]) +out = [] +for case in cases: + params = {k: v for k, v in case.items() if k not in ("name", "density")} + density = case["density"] + pdf.calculate_Gr(**params, damp_origin_oscillations=density is not None, density=density) + ref = dict( + name=case["name"], + params=case, + bg=np.asarray(pdf.bg, dtype=float).tolist(), + fk=np.asarray(pdf.Fk, dtype=float).tolist(), + fkWin=np.asarray(pdf.Fk_masked, dtype=float).tolist(), + sk=np.asarray(pdf.Sk, dtype=float).tolist(), + r=np.asarray(pdf.r, dtype=float).tolist(), + gr=np.asarray(pdf.reduced_pdf, dtype=float).tolist(), + ) + if density is not None: + ref["fkDamped"] = np.asarray(pdf.Fk_damped, dtype=float).tolist() + ref["grDamped"] = np.asarray(pdf.reduced_pdf_damped, dtype=float).tolist() + pdf.calculate_gr(density=density, r_cut=case["r_cut"], set_pdf_positive=True) + ref["gofr"] = np.asarray(pdf.pdf, dtype=float).tolist() + out.append(ref) + +# Degenerate settings the UI can reach: record whether the library raises, +# returns all-NaN curves, or finite ones, and the finite G(r) (the JS must do +# the same). The one-bin window has its edges one float64 ulp outside the +# k = 0.6 / 0.65 bins but on them in float32, so the library's float32 window +# has no positive weight: 0 at k = 0.6 and tiny and negative at k = 0.65. +one_lo, one_hi = float(np.nextafter(qq[12], -np.inf)), float(np.nextafter(qq[13], np.inf)) +assert np.float32(one_lo) == np.float32(qq[12]) and np.float32(one_hi) == np.float32(qq[13]) +edge_cases = [ + dict(name="window_below_fit_damp", k_min_fit=0.6, k_max_fit=1.2, k_min_window=0.2, k_max_window=0.4, + density=0.05), + dict(name="window_empty_damp", k_min_fit=0.3, k_max_fit=0.6, k_min_window=0.8, k_max_window=None, + density=0.05), + dict(name="window_one_bin_f32", k_min_fit=0.3, k_max_fit=1.5, k_min_window=one_lo, k_max_window=one_hi, + density=None), + dict(name="window_one_bin_f32_damp", k_min_fit=0.3, k_max_fit=1.5, k_min_window=one_lo, + k_max_window=one_hi, density=0.05), +] +edges = [] +for case in edge_cases: + case = dict(case, k_lowpass=None, k_highpass=None, r_max=10.0, r_step=0.02, + r_cut=0.8) + params = {k: v for k, v in case.items() if k not in ("name", "density")} + density = case["density"] + try: + pdf.calculate_Gr(**params, damp_origin_oscillations=density is not None, density=density) + except Exception: + edges.append(dict(name=case["name"], params=case, outcome="raises")) + continue + g = np.asarray(pdf.reduced_pdf_damped if density is not None else pdf.reduced_pdf, dtype=float) + outcome = "nan" if np.isnan(g[1:]).all() else "finite" if np.isfinite(g).all() else "mixed" + edge = dict(name=case["name"], params=case, outcome=outcome) + if outcome == "finite": + edge["gr"] = g.tolist() + edges.append(edge) + +# The window calculate_Gr builds (its resolved bounds, the library's _k_window +# on its float32 k), whether it has any positive weight, and the curves. +dk = float(qq[1] - qq[0]) +window_cases = [ + dict(name="normal", k_min_fit=0.3, k_max_fit=1.5, k_min_window=0.35, k_max_window=1.4), + dict(name="follows_fit", k_min_fit=0.3, k_max_fit=1.5, k_min_window=None, k_max_window=None), + dict(name="follows_axis", k_min_fit=None, k_max_fit=None, k_min_window=None, k_max_window=None), + dict(name="inverted", k_min_fit=0.3, k_max_fit=1.5, k_min_window=1.0, k_max_window=0.5), + dict(name="equal", k_min_fit=0.3, k_max_fit=1.5, k_min_window=0.8, k_max_window=0.8), + dict(name="inverted_by_fit", k_min_fit=0.6, k_max_fit=1.5, k_min_window=None, k_max_window=0.4), + dict(name="narrower_than_step", k_min_fit=0.3, k_max_fit=1.5, k_min_window=float(qq[12]) + dk / 4, + k_max_window=float(qq[12]) + dk / 2), + dict(name="narrower_than_step_on_bin", k_min_fit=0.3, k_max_fit=1.5, k_min_window=float(qq[12]) - dk / 4, + k_max_window=float(qq[12]) + dk / 4), + dict(name="one_bin_f32", k_min_fit=0.3, k_max_fit=1.5, k_min_window=one_lo, k_max_window=one_hi), +] +windows = [] +for case in window_cases: + case = dict(case, k_lowpass=None, k_highpass=None, r_max=10.0, r_step=0.02, + density=None) + params = {k: v for k, v in case.items() if k not in ("name", "density")} + pdf.calculate_Gr(**params) + k32 = torch.from_numpy(qq.astype(np.float32)) + wk = pdf._k_window(k32, pdf.kmin_window, pdf.kmax_window) + windows.append(dict(name=case["name"], params=case, empty=not bool((wk > 0).any()), + wk=wk.numpy().astype(float).tolist(), + kmin_window=float(pdf.kmin_window), kmax_window=float(pdf.kmax_window), + fkWin=np.asarray(pdf.Fk_masked, dtype=float).tolist(), + gr=np.asarray(pdf.reduced_pdf, dtype=float).tolist())) +print(json.dumps({"qq": qq.tolist(), "ik": np.asarray(pdf.Ik, dtype=float).tolist(), "cases": out, + "edges": edges, "windows": windows})) +`; + try { + const out = execFileSync(python, ["-c", code], { + encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"], + }); + return JSON.parse(out); + } catch { + return null; + } +} + +// max |a - b| relative to max |b| +function relErr(a: ArrayLike<number>, b: ArrayLike<number>): number { + expect(a.length).toBe(b.length); + let scale = 0; + for (let i = 0; i < b.length; i++) scale = Math.max(scale, Math.abs(b[i])); + scale = scale || 1; + let err = 0; + for (let i = 0; i < b.length; i++) err = Math.max(err, Math.abs(a[i] - b[i]) / scale); + return err; +} + +function grParams(p) { + return { + kMinFit: p.k_min_fit ?? 0, kMaxFit: p.k_max_fit ?? 0, + kMinWindow: p.k_min_window ?? 0, kMaxWindow: p.k_max_window ?? 0, + kLowpass: p.k_lowpass ?? 0, kHighpass: p.k_highpass ?? 0, + rMax: p.r_max, rStep: p.r_step, + }; +} + +const fixture = pdfReferenceFixture(); + +(fixture ? describe : describe.skip)("ShowPDF offline PDF compute matches PairDistributionFunction", () => { + const qq = fixture ? Float64Array.from(fixture.qq) : null; + const ik = fixture ? Float64Array.from(fixture.ik) : null; + + for (const ref of fixture ? fixture.cases : []) { + it(`reproduces calculate_Gr (${ref.name})`, () => { + const res = computeGr(qq, ik, grParams(ref.params)); + // the r grid is float32(i*r_step) in both, so r_cut ties fall the same way + expect(Array.from(res.r)).toEqual(ref.r); + // JS works in float64 and the library in float32 (torch), so the curves + // agree to ~1e-6 of their scale; the bounds leave about a 10x margin. + expect(relErr(res.bg, ref.bg)).toBeLessThan(1e-6); + expect(relErr(res.fk, ref.fk)).toBeLessThan(1e-5); + expect(relErr(res.fkWin, ref.fkWin)).toBeLessThan(1e-5); + expect(relErr(res.sk, ref.sk)).toBeLessThan(1e-5); + expect(relErr(res.r, ref.r)).toBeLessThan(1e-6); + expect(relErr(res.gr, ref.gr)).toBeLessThan(2e-5); + expect(res.gr[0]).toBe(0); + if (ref.params.density !== null) { + const damping = { density: ref.params.density, rCut: ref.params.r_cut }; + const damped = dampOriginOscillations(qq, res, damping); + expect(relErr(damped.fkWin, ref.fkDamped)).toBeLessThan(2e-5); + expect(relErr(damped.gr, ref.grDamped)).toBeLessThan(2e-5); + const gofr = computeGofR(res.r, damped.gr, ref.params.density); + expect(relErr(gofr, ref.gofr)).toBeLessThan(2e-5); + } + }); + } + + for (const ref of fixture ? fixture.edges : []) { + it(`raises, returns NaN or computes as the library does (${ref.name}: ${ref.outcome})`, () => { + let outcome, gr; + try { + const res = computeGr(qq, ik, grParams(ref.params)); + gr = ref.params.density !== null + ? dampOriginOscillations(qq, res, { density: ref.params.density, rCut: ref.params.r_cut }).gr + : res.gr; + const tail = Array.from(gr).slice(1); + outcome = tail.every(Number.isNaN) ? "nan" : gr.every(Number.isFinite) ? "finite" : "mixed"; + } catch { + outcome = "raises"; + } + expect(outcome).toBe(ref.outcome); + // A degenerate window is not exactly zero, so compare finite curves too. + if (ref.gr) expect(relErr(gr, ref.gr)).toBeLessThan(2e-5); + }); + } + + it("has the empty-window reference cases", () => { + expect(fixture.windows.map((w) => `${w.name}:${w.empty}`)).toEqual([ + "normal:false", "follows_fit:false", "follows_axis:false", "inverted:true", "equal:true", + "inverted_by_fit:true", "narrower_than_step:true", "narrower_than_step_on_bin:false", + "one_bin_f32:true", + ]); + }); + + for (const ref of fixture ? fixture.windows : []) { + it(`decides an empty k window as the library's window does (${ref.name}: ${ref.empty})`, () => { + // kWindow on the bounds computeGr resolves, which are the library's (the + // exported file's status line resolves them the same way). + const res = computeGr(qq, ik, grParams(ref.params)); + expect([res.kMinWindow, res.kMaxWindow]).toEqual([ref.kmin_window, ref.kmax_window]); + const wk = kWindow(qq, res.kMinWindow, res.kMaxWindow); + // The same zero, negative (a bin at k max) and positive bins as the + // library's float32 window, and its values to float32 rounding. + const sign = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0); + expect(Array.from(wk, sign)).toEqual(ref.wk.map(sign)); + for (let i = 0; i < wk.length; i++) { + expect(Math.abs(wk[i] - ref.wk[i])).toBeLessThanOrEqual(1e-6 * Math.abs(ref.wk[i])); + } + const empty = !wk.some((w) => w > 0); + expect(empty).toBe(ref.empty); + // Without positive weight the window can keep a tiny negative one, so the + // curves are not always zero: compare them with the library's. + expect(relErr(res.fkWin, ref.fkWin)).toBeLessThan(1e-5); + expect(relErr(res.gr, ref.gr)).toBeLessThan(2e-5); + expect(Array.from(res.gr).every((g) => g === 0)).toBe(ref.gr.every((g) => g === 0)); + }); + } + + it("refuses a highpass above the lowpass, as the library does", () => { + const bandPass = { ...grParams(fixture.cases[0].params), kLowpass: 0.02, kHighpass: 0.05 }; + expect(() => computeGr(qq, ik, bandPass)).toThrow(/highpass/); + }); +}); diff --git a/js/showpdf/pdf_compute.test.ts b/js/showpdf/pdf_compute.test.ts new file mode 100644 index 000000000..cf90351ee --- /dev/null +++ b/js/showpdf/pdf_compute.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + computeGofR, computeGr, dampOriginOscillations, kWindow, resolveWindowBounds, type GrParams, +} from "./pdf_compute"; + +// Synthetic calibrated I(k): a decaying background plus damped oscillations. +const N = 120; +const qq = Float64Array.from({ length: N }, (_, i) => i * 0.02); +const ik = Float64Array.from(qq, (k) => 50 * Math.exp(-(k * k) / 0.08) + 5 * Math.exp(-k / 0.6) + 0.3 + + 0.4 * Math.exp(-k / 1.5) * Math.sin(2 * Math.PI * 2.6 * k)); +const base: GrParams = { + kMinFit: 0.2, kMaxFit: 2.2, kMinWindow: 0, kMaxWindow: 0, + kLowpass: 0, kHighpass: 0, rMax: 10, rStep: 0.05, +}; + +describe("ShowPDF offline PDF compute", () => { + it("refuses inputs the library refuses", () => { + expect(() => computeGr(qq, ik, { ...base, kLowpass: 0.02, kHighpass: 0.05 })).toThrow(/highpass/); + expect(() => computeGr(qq, ik, { ...base, kMinFit: 5, kMaxFit: 6 })).toThrow(/fit range/); + expect(() => computeGr(qq, ik, { ...base, rMax: 0 })).toThrow(/r grid/); + }); + + it("builds the ramped Lorch window of _k_window", () => { + const kmin = 0.5, kmax = 1.5, e = 0.1 * (kmax - kmin); // sin^2 ramp over 10% of the band + const w = kWindow(qq, kmin, kmax); + for (let i = 0; i < N; i++) { + const k = qq[i]; + if (k < kmin || k > kmax) { expect(w[i]).toBe(0); continue; } + const ramp = k < kmin + e ? Math.sin(0.5 * Math.PI * (k - kmin) / e) ** 2 : 1; + const lorch = Math.sin(Math.PI * k / kmax) / (Math.PI * k / kmax); + // evaluated in float32 like the library, so ~1e-7 + expect(w[i]).toBeCloseTo(ramp * lorch, 6); + if (k > kmin && k < kmax) expect(w[i]).toBeGreaterThan(0); + } + // k = 0.5 = kmin: the ramp starts at 0. k = 1.5 = kmax: sin(float32(pi)) < 0 + // leaves a tiny negative weight, as in the library. + expect(w[25]).toBe(0); + expect(w[75]).toBeLessThan(0); + expect(w[75]).toBeGreaterThan(-1e-7); + // Equal ends on a bin: only that bin is in band, with no positive weight. + const one = kWindow(qq, qq[40], qq[40]); + expect(Array.from(one).filter((v) => v !== 0)).toEqual([one[40]]); + expect(one[40]).toBeLessThan(0); + }); + + it("follows calculate_Gr: window defaults, r grid, G(0) = 0", () => { + const res = computeGr(qq, ik, base); + expect(res.kMinWindow).toBe(0.2); + expect(res.kMaxWindow).toBe(2.2); + const wk = kWindow(qq, 0.2, 2.2); + for (let i = 0; i < N; i++) expect(res.fkWin[i]).toBeCloseTo(res.fk[i] * wk[i], 12); + expect(res.r.length).toBe(Math.ceil(base.rMax / base.rStep)); + expect(res.r[0]).toBe(0); + expect(res.gr[0]).toBe(0); + expect(res.gr.every(Number.isFinite)).toBe(true); + // Unset fit range: the data ends, rounded to float32 as the library does + const def = computeGr(qq, ik, { ...base, kMinFit: 0, kMaxFit: 0 }); + expect(def.kMaxFit).toBe(Math.fround(qq[N - 1])); + expect(def.kMaxWindow).toBe(def.kMaxFit); + }); + + it("resolves the window bounds as computeGr uses them", () => { + // 0 fit ends: the float32 k axis ends; 0 window ends: the fit ends + const axisEnds = { kMinFit: Math.fround(qq[0]), kMaxFit: Math.fround(qq[N - 1]) }; + expect(resolveWindowBounds(qq, { kMinFit: 0, kMaxFit: 0, kMinWindow: 0, kMaxWindow: 0 })) + .toEqual({ ...axisEnds, kMinWindow: axisEnds.kMinFit, kMaxWindow: axisEnds.kMaxFit }); + expect(resolveWindowBounds(qq, { kMinFit: 0.6, kMaxFit: 0, kMinWindow: 0, kMaxWindow: 0.4 })) + .toEqual({ kMinFit: 0.6, kMaxFit: axisEnds.kMaxFit, kMinWindow: 0.6, kMaxWindow: 0.4 }); + const settings = [ + base, { ...base, kMinFit: 0, kMaxFit: 0 }, { ...base, kMinWindow: 0.3, kMaxWindow: 1.9 }, + ]; + for (const p of settings) { + const { kMinFit, kMaxFit, kMinWindow, kMaxWindow } = computeGr(qq, ik, p); + expect(resolveWindowBounds(qq, p)).toEqual({ kMinFit, kMaxFit, kMinWindow, kMaxWindow }); + } + }); + + it("damping without iterations reproduces the undamped transform", () => { + const res = computeGr(qq, ik, base); + const none = dampOriginOscillations(qq, res, { density: 0.05, rCut: 0.8, maxIter: 0 }); + for (let i = 0; i < res.gr.length; i++) expect(none.gr[i]).toBeCloseTo(res.gr[i], 9); + const damped = dampOriginOscillations(qq, res, { density: 0.05, rCut: 0.8 }); + expect(damped.gr.length).toBe(res.gr.length); + expect(damped.fkWin.length).toBe(N); + expect(damped.gr.every(Number.isFinite)).toBe(true); + expect(damped.gr[0]).toBe(0); + }); + + it("computes g(r) = max(0, 1 + G/(4 pi r rho0)) with g(0) = 0", () => { + const r = Float64Array.from([0, 1, 2]); + const gr = Float64Array.from([5, -4 * Math.PI * 0.05 * 3, 4 * Math.PI * 0.05 * 2]); + expect(Array.from(computeGofR(r, gr, 0.05))).toEqual([0, 0, 2]); + }); +}); diff --git a/js/showpdf/pdf_compute.ts b/js/showpdf/pdf_compute.ts new file mode 100644 index 000000000..3c6980d6a --- /dev/null +++ b/js/showpdf/pdf_compute.ts @@ -0,0 +1,480 @@ +// G(r) and g(r) in the browser, for the standalone HTML export (no Python kernel). From a 1D I(k) +// and its k axis, it follows quantem.diffraction.PairDistributionFunction: +// calculate_Gr: background fit, F(k), frequency filter, S(k), k window, sine transform -> G(r) +// damp_origin_oscillations: low-r correction of G(r) for a supplied density +// calculate_gr: g(r) +// As in the library, the number density is an input, never estimated. Math.fround reproduces the +// library's float32 arithmetic where it changes a result. pdf_compute.numpy.test.ts checks parity. + +export interface GrParams { + kMinFit: number; // 0 = data minimum (k_min_fit=None) + kMaxFit: number; // 0 = data maximum (k_max_fit=None) + kMinWindow: number; // <= 0 = kMinFit (k_min_window=None) + kMaxWindow: number; // <= 0 = kMaxFit (k_max_window=None) + kLowpass: number; // 0 = off + kHighpass: number; // 0 = off + rMax: number; + rStep: number; +} + +export interface GrResult { + bg: Float64Array; // background B(k) over the full k axis + fk: Float64Array; // F(k) times 2*pi, before the window + fkWin: Float64Array; // windowed F(k): the input of the sine transform + sk: Float64Array; // S(k) = 1 + F(k)/k (before 2*pi) + r: Float64Array; + gr: Float64Array; // reduced PDF G(r) + // Bounds actually used (0 sentinels resolved), needed by the damping. + kMinFit: number; + kMaxFit: number; + kMinWindow: number; + kMaxWindow: number; +} + +export interface DampingParams { + density: number; // number density rho0 (atoms/ų), > 0 + rCut: number; // upper end (Å) of the corrected interval [0, rCut] + maxIter?: number; // default 6, as in the library + tolerance?: number; // default 1e-3 (S(k) units), as in the library +} + +export interface DampingResult { + fkWin: Float64Array; // windowed corrected F(k) used for the transform + gr: Float64Array; // G(r) with the origin oscillations corrected +} + +// G(r) as calculate_Gr computes it, without the damping. `qq` must be the library's exact float64 +// k axis: the background fit selects k bins by comparing it with the fit range, as fit_bg_batched does. +export function computeGr(qq: Float64Array, ik: Float64Array, p: GrParams): GrResult { + const n = qq.length; + const dk = qq[1] - qq[0]; + const { kMinFit, kMaxFit, kMinWindow, kMaxWindow } = resolveWindowBounds(qq, p); + const { bg, f } = fitBackground(qq, ik, kMinFit, kMaxFit); + + // F(k) = (I - B) * k / f, with k and f floored as in calculate_Gr + const fFloor = 1e-10 * maxValue(f); + const fkUnfiltered = new Float64Array(n); + for (let i = 0; i < n; i++) { + const kSafe = Math.max(qq[i], 1e-10); + const fSafe = Math.max(f[i], fFloor); + fkUnfiltered[i] = (ik[i] - bg[i]) * kSafe / fSafe; + } + // The filter uses calculate_Gr's dk, from its float32 k: with the float64 dk the kernel radius + // ceil(3 * k_lowpass / dk) can differ by a sample. + const dk32 = Math.fround(Math.fround(qq[1]) - Math.fround(qq[0])); + const fk = frequencyFiltering(fkUnfiltered, p.kLowpass, p.kHighpass, dk32); + + // S(k) = 1 + F(k)/k is taken before F(k) gets its 2*pi factor, as in calculate_Gr. + const sk = new Float64Array(n); + for (let i = 0; i < n; i++) { + const kSafe = Math.max(qq[i], 1e-10); + sk[i] = qq[i] > 0 ? 1 + fk[i] / kSafe : 1; + } + for (let i = 0; i < n; i++) fk[i] *= 2 * Math.PI; + + const wk = kWindow(qq, kMinWindow, kMaxWindow); + const fkWin = fk.map((v, i) => v * wk[i]); + const r = rGrid(p.rMax, p.rStep); + const gr = sineTransform(qq, r, fkWin, dk); + return { bg, fk, fkWin, sk, r, gr, kMinFit, kMaxFit, kMinWindow, kMaxWindow }; +} + +type KBounds = Pick<GrParams, "kMinFit" | "kMaxFit" | "kMinWindow" | "kMaxWindow">; + +// The fit and window bounds calculate_Gr uses. An unset (0) fit end is the end of the library's +// float32 k axis, so the last float64 bin can fall just outside the fit, as in the library; an +// unset window end is the fit end. +export function resolveWindowBounds(qq: Float64Array, p: KBounds): KBounds { + const kMinFit = p.kMinFit > 0 ? p.kMinFit : Math.fround(qq[0]); + const kMaxFit = p.kMaxFit > 0 ? p.kMaxFit : Math.fround(qq[qq.length - 1]); + return { + kMinFit, + kMaxFit, + kMinWindow: p.kMinWindow > 0 ? p.kMinWindow : kMinFit, + kMaxWindow: p.kMaxWindow > 0 ? p.kMaxWindow : kMaxFit, + }; +} + +// The k window of _k_window: zero outside [kmin, kmax]; inside, a sin^2 ramp over the first 10% of +// the band times the Lorch factor sin(pi*k/kmax) / (pi*k/kmax). Evaluated in float32 like the +// library, which decides the bins in band and gives w = 0 at kmin and, as sin(float32(pi)) < 0, +// a tiny negative w at kmax. +export function kWindow(k: Float64Array, kmin: number, kmax: number): Float64Array { + const f32 = Math.fround; + // Only the ramp width e and the ramp end kmin + e are float64 sums in the library. + const e = 0.1 * (kmax - kmin); + const e32 = f32(e); + const rampEnd32 = f32(kmin + e); + const kmin32 = f32(kmin); + const kmax32 = f32(kmax); + const halfPi32 = f32(0.5 * Math.PI); + const pi32 = f32(Math.PI); + const wk = new Float64Array(k.length); + for (let i = 0; i < k.length; i++) { + const k32 = f32(k[i]); + if (k32 < kmin32 || k32 > kmax32) continue; // out of band: 0 + let ramp = 1; + if (k32 < rampEnd32) { + const t = f32(f32(k32 - kmin32) / e32); + const s = f32(Math.sin(f32(halfPi32 * t))); + ramp = f32(s * s); + } + const x = f32(k32 / kmax32); + const px = f32(pi32 * x); + const sinc = x === 0 ? 1 : f32(f32(Math.sin(px)) / px); + wk[i] = f32(ramp * sinc); + } + return wk; +} + +// The low-r correction of damp_origin_oscillations (Kaplow et al., in the Yoshimoto-Omote form) +// for a supplied density: the deviation of G(r) from -4*pi*rho0*r on [0, rCut] is moved back into +// S(k), until that correction stops changing. +export function dampOriginOscillations(qq: Float64Array, res: GrResult, p: DampingParams): DampingResult { + const n = qq.length; + const dk = qq[1] - qq[0]; + const r = res.r; + const rho0 = p.density; + const maxIter = p.maxIter ?? 6; + const tolerance = p.tolerance ?? 1e-3; + + // Corrected k columns: [kMinFit, kMaxWindow], compared in float32 as the library does. + const kLo32 = Math.fround(res.kMinFit); + const kHi32 = Math.fround(res.kMaxWindow); + const kIdx: number[] = []; + for (let i = 0; i < n; i++) { + const k32 = Math.fround(qq[i]); + if (k32 >= kLo32 && k32 <= kHi32) kIdx.push(i); + } + const qScaled = kIdx.map((i) => qq[i] * 2 * Math.PI); + + // r points of the correction integrals: [0, rCut], compared in float32 as the library does. + const rCut32 = Math.fround(p.rCut); + const rIdx: number[] = []; + for (let i = 0; i < r.length; i++) { + if (r[i] >= 0 && Math.fround(r[i]) <= rCut32) rIdx.push(i); + } + const rShort = rIdx.map((i) => r[i]); + + const wk = kWindow(qq, res.kMinWindow, res.kMaxWindow); + const skCor = Float64Array.from(res.sk); + // The windowed F(k) of the corrected S(k), and its G(r). + const transform = () => { + const fkWin = new Float64Array(n); + for (let i = 0; i < n; i++) fkWin[i] = qq[i] * (skCor[i] - 1.0) * wk[i] * 2 * Math.PI; + return { fkWin, gr: sineTransform(qq, r, fkWin, dk) }; + }; + + let corrected = transform(); + let betaPrev: Float64Array | null = null; + for (let it = 0; it < maxIter; it++) { + const gShort = rIdx.map((i) => corrected.gr[i]); + const { alpha, beta } = alphaBeta(qScaled, rShort, gShort); + if (betaPrev !== null) { + // The library's torch.max over an empty beta fails here. + if (beta.length === 0) { + throw new Error("the damping k range [k min fit, k max window] contains no data"); + } + let change = 0; + for (let c = 0; c < beta.length; c++) change = Math.max(change, Math.abs(beta[c] - betaPrev[c])); + if (change < tolerance) break; + } + betaPrev = beta; + for (let c = 0; c < kIdx.length; c++) { + const i = kIdx[c]; + skCor[i] = skCor[i] - beta[c] + rho0 * alpha[c]; + } + corrected = transform(); + } + return corrected; +} + +// g(r) = 1 + G(r) / (4*pi*r*rho0), 0 at r = 0 and clamped to >= 0, as +// calculate_gr(set_pdf_positive=True). +export function computeGofR(r: Float64Array, gr: Float64Array, rho0: number): Float64Array { + const out = new Float64Array(r.length); + for (let i = 0; i < r.length; i++) { + out[i] = r[i] > 0 ? Math.max(0, 1 + gr[i] / (4 * Math.PI * r[i] * rho0)) : 0; + } + return out; +} + +// --- Background fit: fit_bg_batched for a single curve -------------------- + +// Fits B(k) = c + i0 exp(-k^2 / (2 s0^2)) + i1 exp(-k^4 / (2 s1^4)) to I(k) on [kmin, kmax] and +// returns B(k) and f(k) = B(k) - c over the full k axis. +function fitBackground(qq: Float64Array, ik: Float64Array, kmin: number, kmax: number) { + const n = qq.length; + const k2 = qq.map((k) => k * k); + const dk = qq[1] - qq[0]; + + const fitIdx: number[] = []; + for (let i = 0; i < n; i++) { + if (qq[i] >= kmin && qq[i] <= kmax) fitIdx.push(i); + } + const nf = fitIdx.length; + if (nf === 0) throw new Error("the k fit range contains no data"); + const kFit = fitIdx.map((i) => qq[i]); + const k2Fit = fitIdx.map((i) => k2[i]); + const ikFit = fitIdx.map((i) => Math.max(ik[i], 1e-10)); + + // Residual weights: a sin^2 taper over the first 0.25 1/Å above kmin (0 where it is <= 1e-4) + // suppresses the central-beam tail; dividing by a line falling with k favours high k. + const lastK = kFit[nf - 1]; + const weights = kFit.map((k) => { + const s = Math.sin(0.5 * Math.PI * Math.min(Math.max((k - kmin) / 0.25, 0), 1)); + const taper = s * s; + return (taper > 1e-4 ? taper : 0) / (lastK - 0.9 * k + dk); + }); + + // Initial guess: c = min I, amplitudes = median I - min I, widths = mean k. + const sorted = [...ikFit].sort((a, b) => a - b); + const c0 = Math.max(sorted[0], 1e-10); + const median = sorted[Math.floor((nf - 1) / 2)]; // the lower middle value, as torch.median + const amp = Math.max(median - c0, 1e-10); + let kSum = 0; + for (const k of qq) kSum += k; + const sig = Math.max(kSum / n, 1e-3); + // The fit works on the parameters' logs, which keeps them positive. + const theta0 = [c0, amp, sig, amp, sig].map((v) => Math.log(v)); + + // Weighted residuals (B - I) * w and their derivatives in log c, log i0, log s0, log i1, log s1. + const residuals = (theta: number[]) => { + const [c, i0, s0, i1, s1] = theta.map((v) => Math.exp(v)); + const r = new Float64Array(nf); + const jac: number[][] = []; + for (let j = 0; j < nf; j++) { + const e0 = clampedExp(-k2Fit[j] / (2 * s0 * s0)); + const e1 = clampedExp(-(k2Fit[j] * k2Fit[j]) / (2 * s1 * s1 * s1 * s1)); + const B = c + i0 * e0 + i1 * e1; + const w = weights[j]; + r[j] = (B - ikFit[j]) * w; + const t0 = i0 * e0; + const t1 = i1 * e1; + jac.push([ + c * w, + t0 * w, + t0 * (k2Fit[j] / (s0 * s0)) * w, + t1 * w, + t1 * (2.0 * k2Fit[j] * k2Fit[j] / (s1 * s1 * s1 * s1)) * w, + ]); + } + return { r, jac }; + }; + + return backgroundCurves(levenbergMarquardt(theta0, residuals), k2); +} + +// B(k) and f(k) = B(k) - c (floored at 1e-10 max B) for the fitted log parameters, rounded to +// float32 as fit_bg_batched returns them: F(k) divides by f(k), which amplifies the rounding of +// B(k) where f(k) is small. +function backgroundCurves(theta: number[], k2: Float64Array) { + const [c, i0, s0, i1, s1] = theta.map((v) => Math.exp(v)); + const bg = k2.map((kSq) => { + const e0 = clampedExp(-kSq / (2 * s0 * s0)); + const e1 = clampedExp(-(kSq * kSq) / (2 * s1 * s1 * s1 * s1)); + return c + i0 * e0 + i1 * e1; + }); + const fFloor = 1e-10 * maxValue(bg); + const f = bg.map((b) => Math.max(b - c, fFloor)); + return { bg: bg.map((v) => Math.fround(v)), f: f.map((v) => Math.fround(v)) }; +} + +// Levenberg-Marquardt over the log parameters, as fit_bg_batched runs it for one curve: up to 200 +// Jacobian updates with up to 30 damping trials each, no log parameter moving more than 1 per step. +function levenbergMarquardt( + theta0: number[], + residuals: (theta: number[]) => { r: Float64Array; jac: number[][] }, +) { + const maxLogStep = 1.0; + const loss = (theta: number[]) => { + const { r } = residuals(theta); + let sum = 0; + for (let j = 0; j < r.length; j++) sum += r[j] * r[j]; + return sum; + }; + + let theta = theta0; + let currentLoss = loss(theta); + let lam = 1.0; + for (let outer = 0; outer < 200; outer++) { + const { r, jac } = residuals(theta); + // Normal equations: J^T J and J^T r. + const JtJ = Array.from({ length: 5 }, () => [0, 0, 0, 0, 0]); + const Jtr = [0, 0, 0, 0, 0]; + for (let j = 0; j < r.length; j++) { + for (let a = 0; a < 5; a++) { + Jtr[a] += jac[j][a] * r[j]; + for (let b = 0; b < 5; b++) JtJ[a][b] += jac[j][a] * jac[j][b]; + } + } + const diag = JtJ.map((row, i) => Math.max(row[i], 1e-12)); + const prevLoss = currentLoss; + let accepted = false; + for (let inner = 0; inner < 30; inner++) { + // Step: (J^T J + lam * diag + 1e-12) delta = J^T r, shortened to maxLogStep. + const A = JtJ.map((row, i) => row.map((v, j) => v + (i === j ? lam * diag[i] + 1e-12 : 0))); + const delta = solve5(A, Jtr); + let largest = 1e-30; + for (const d of delta) largest = Math.max(largest, Math.abs(d)); + const stepScale = Math.min(maxLogStep / largest, 1.0); + const trial = theta.map((v, i) => v - delta[i] * stepScale); + const trialLoss = loss(trial); + if (isFinite(trialLoss) && trialLoss < currentLoss) { + theta = trial; + currentLoss = trialLoss; + accepted = true; + lam = Math.max(lam * 0.3, 1e-12); // accepted: toward Gauss-Newton + break; + } + lam = Math.min(lam * 3.0, 1e12); // rejected: toward gradient descent + } + if (!accepted) break; + if ((prevLoss - currentLoss) / (prevLoss + 1e-30) < 1e-9) break; // converged + } + return theta; +} + +// Solves the 5 x 5 system A x = b by Gauss-Jordan elimination with partial pivoting. +function solve5(A: number[][], b: number[]) { + const n = 5; + const M = A.map((row, i) => [...row, b[i]]); // augmented matrix [A | b] + for (let c = 0; c < n; c++) { + let pivot = c; + for (let r = c + 1; r < n; r++) { + if (Math.abs(M[r][c]) > Math.abs(M[pivot][c])) pivot = r; + } + [M[c], M[pivot]] = [M[pivot], M[c]]; + const d = M[c][c] || 1e-300; // a zero (or NaN) pivot is replaced by 1e-300 + for (let r = 0; r < n; r++) { + if (r === c) continue; + const factor = M[r][c] / d; + for (let k = c; k <= n; k++) M[r][k] -= factor * M[c][k]; + } + } + return M.map((row, i) => row[n] / (row[i] || 1e-300)); +} + +// exp(x) with x clamped to [-100, 0], as in the background model of fit_bg_batched. +function clampedExp(x: number) { + return Math.exp(Math.min(Math.max(x, -100), 0)); +} + +// The largest value, skipping NaN (-Infinity if there is none). +function maxValue(a: Float64Array) { + let max = -Infinity; + for (const v of a) { + if (v > max) max = v; + } + return max; +} + +// --- Frequency filter: _frequency_filtering and core.utils.filter --------- + +// Gaussian low-pass, high-pass (F(k) minus its smoothed copy) or band-pass (difference of the two +// smoothings) of F(k), with sigmas in 1/Å (0 = off). +function frequencyFiltering(fk: Float64Array, kLowpass: number, kHighpass: number, dk: number) { + const smooth = (sigma: number) => gaussianFilter1d(fk, gaussianKernel1d(sigma / dk)); + if (kLowpass > 0 && kHighpass > 0) { + // _frequency_filtering raises a ValueError here too. + if (kHighpass > kLowpass) { + throw new Error("k highpass must not exceed k lowpass for band-pass filtering"); + } + const low = smooth(kLowpass); + const high = smooth(kHighpass); + return high.map((v, i) => v - low[i]); + } + if (kLowpass > 0) return smooth(kLowpass); + if (kHighpass > 0) { + const high = smooth(kHighpass); + return fk.map((v, i) => v - high[i]); + } + return fk; +} + +// Normalised Gaussian of radius ceil(3 sigma) samples, as gaussian_kernel_1d. +function gaussianKernel1d(sigma: number) { + const radius = Math.ceil(3.0 * sigma); + const n = 2 * radius + 1; + const kernel = new Float64Array(n); + let sum = 0; + for (let i = 0; i < n; i++) { + const x = i - radius; + const v = Math.exp(-0.5 * (x * x) / (sigma * sigma)) / (sigma * Math.sqrt(2 * Math.PI)); + kernel[i] = v; + sum += v; + } + for (let i = 0; i < n; i++) kernel[i] /= sum; + return kernel; +} + +// Convolution with replicate ("edge") padding, as gaussian_filter_1d. +function gaussianFilter1d(arr: Float64Array, kernel: Float64Array) { + const n = arr.length; + const pad = Math.floor(kernel.length / 2); + const out = new Float64Array(n); + for (let i = 0; i < n; i++) { + let acc = 0; + for (let j = 0; j < kernel.length; j++) { + let idx = i + j - pad; + if (idx < 0) idx = 0; + else if (idx >= n) idx = n - 1; + acc += arr[idx] * kernel[j]; + } + out[i] = acc; + } + return out; +} + +// --- Transforms ----------------------------------------------------------- + +// The r grid of calculate_Gr: torch.arange(0, r_max, r_step) built in float64 and cast to float32. +function rGrid(rMax: number, rStep: number) { + const length = Math.max(0, Math.ceil(rMax / rStep)); + if (length === 0) throw new Error("the r grid is empty: r max must be positive"); + const r = new Float64Array(length); + for (let i = 0; i < length; i++) r[i] = Math.fround(i * rStep); + return r; +} + +// Discrete sine transform G(r) = (2/pi)*dk*2*pi * sum_k sin(2*pi*k*r) * fkWin(k), with G[0] = 0 +// (calculate_Gr and the damping zero the first r point). +function sineTransform(qq: Float64Array, r: Float64Array, fkWin: Float64Array, dk: number) { + const out = new Float64Array(r.length); + const scale = (2 / Math.PI) * dk * 2 * Math.PI; + for (let ri = 0; ri < r.length; ri++) { + let acc = 0; + const twoPiR = 2 * Math.PI * r[ri]; + for (let ki = 0; ki < qq.length; ki++) acc += Math.sin(twoPiR * qq[ki]) * fkWin[ki]; + out[ri] = scale * acc; + } + if (r.length > 0) out[0] = 0; + return out; +} + +// The integrals of _compute_alpha_beta over r in [0, rCut], one per corrected k column (Q = 2*pi*k): +// alpha of the ideal -4*pi*r (per unit density) and beta of the current G(r). +function alphaBeta(qScaled: number[], rShort: number[], gShort: number[]) { + const alpha = new Float64Array(qScaled.length); + const beta = new Float64Array(qScaled.length); + for (let c = 0; c < qScaled.length; c++) { + const Q = qScaled[c] === 0 ? 1e-12 : qScaled[c]; + const alphaIntegrand = new Float64Array(rShort.length); + const betaIntegrand = new Float64Array(rShort.length); + for (let i = 0; i < rShort.length; i++) { + const s = Math.sin(Q * rShort[i]) / Q; + alphaIntegrand[i] = -4 * Math.PI * rShort[i] * s; + betaIntegrand[i] = gShort[i] * s; + } + alpha[c] = trapz(alphaIntegrand, rShort); + beta[c] = trapz(betaIntegrand, rShort); + } + return { alpha, beta }; +} + +// Trapezoid-rule integral of y over x. +function trapz(y: ArrayLike<number>, x: ArrayLike<number>) { + let s = 0; + for (let i = 1; i < y.length; i++) s += 0.5 * (x[i] - x[i - 1]) * (y[i] + y[i - 1]); + return s; +} diff --git a/js/showpdf/rcut.test.ts b/js/showpdf/rcut.test.ts new file mode 100644 index 000000000..10a55ae92 --- /dev/null +++ b/js/showpdf/rcut.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { rCutSlider } from "./rcut"; + +describe("ShowPDF r cut slider", () => { + it("steps on the default r grid (r_step 0.02) from 0.02 to 5", () => { + expect(rCutSlider(0.02, 20)).toEqual({ min: 0.02, max: 5, step: 0.02, decimals: 2 }); + // r max below 5 Å ends the range at the grid point at or below it + expect(rCutSlider(0.02, 3.01)).toEqual({ min: 0.02, max: 3, step: 0.02, decimals: 2 }); + }); + + it("keeps grid points and excludes r = 0", () => { + expect(rCutSlider(0.25, 20)).toMatchObject({ min: 0.25, max: 5, decimals: 2 }); + // 5 Å is not on the grid: the range ends at the grid point below it + expect(rCutSlider(0.3, 20)).toMatchObject({ min: 0.3, max: 4.8, decimals: 1 }); + expect(rCutSlider(0.1, 20)).toEqual({ min: 0.1, max: 5, step: 0.1, decimals: 1 }); + expect(rCutSlider(0.0125, 4)).toMatchObject({ min: 0.0125, max: 4, decimals: 4 }); + }); + + it("gives every r bin up to 5 Å as a slider value", () => { + const { min, max, step } = rCutSlider(0.02, 20); + expect(Math.round((max - min) / step) + 1).toBe(250); + }); +}); diff --git a/js/showpdf/rcut.ts b/js/showpdf/rcut.ts new file mode 100644 index 000000000..2c6ac6bec --- /dev/null +++ b/js/showpdf/rcut.ts @@ -0,0 +1,21 @@ +// r cut slider on the r grid of the computed G(r), n·r_step from r = 0: from the first grid point +// above 0 to the last one at or below min(5 Å, r max). `decimals` is the number r_step needs +// (0.02 → 2), for the readout ("0.80 Å"). +export function rCutSlider( + rStep: number, rMax: number, +): { min: number; max: number; step: number; decimals: number } { + const step = rStep > 0 ? rStep : 0.1; + const decimals = decimalsOf(step); + const gridPoint = (n: number) => Number((n * step).toFixed(decimals)); + const min = gridPoint(1); + const max = gridPoint(Math.floor(Math.min(5, rMax) / step + 1e-9)); + return { min, max: Math.max(min, max), step, decimals }; +} + +// Decimals that write step exactly, from 1 to at most 6. +function decimalsOf(step: number) { + const offInteger = (x: number) => Math.abs(x - Math.round(x)); + let decimals = 1; + while (decimals < 6 && offInteger(step * 10 ** decimals) > 1e-6) decimals++; + return decimals; +} diff --git a/js/showpdf/scalebar.ts b/js/showpdf/scalebar.ts new file mode 100644 index 000000000..840455652 --- /dev/null +++ b/js/showpdf/scalebar.ts @@ -0,0 +1,66 @@ +import { formatScaleLabel, formatZoomLabel, roundToNiceValue } from "../figure"; + +const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + +// Scale bar and zoom of the navigation image, drawn on its HiDPI overlay canvas: white bar and text +// with a solid black outline, so they read on any image (a bright virtual-BF one too), unlike the +// faint drop shadow of the shared drawScaleBarHiDPI. +export function drawNavScaleBar( + canvas: HTMLCanvasElement, + dpr: number, + zoom: number, + pixelSize: number, + unit: string, + imageWidth: number, +) { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.scale(dpr, dpr); + + const cssWidth = canvas.width / dpr; + const cssHeight = canvas.height / dpr; + const scaleX = cssWidth / imageWidth; + const effectiveZoom = zoom * scaleX; + + const targetBarPx = 60; + const barThickness = 5; + const border = 2; + const fontSize = 16; + const margin = 12; + + // A bar of a round physical length, about targetBarPx long on screen. + const targetPhysical = (targetBarPx / effectiveZoom) * pixelSize; + const nicePhysical = roundToNiceValue(targetPhysical); + const barPx = (nicePhysical / pixelSize) * effectiveZoom; + const barX = cssWidth - barPx - margin; + const barY = cssHeight - margin; + + // Bar: black border rectangle, then white fill on top. + ctx.fillStyle = "black"; + ctx.fillRect(barX - border, barY - border, barPx + 2 * border, barThickness + 2 * border); + ctx.fillStyle = "white"; + ctx.fillRect(barX, barY, barPx, barThickness); + + // Labels: white text over a black outline; the length centred above the bar, the zoom at the + // bottom left. + ctx.font = `${fontSize}px ${FONT}`; + ctx.lineJoin = "round"; + ctx.lineWidth = 3.5; + ctx.strokeStyle = "black"; + ctx.fillStyle = "white"; + ctx.textBaseline = "bottom"; + ctx.textAlign = "center"; + drawOutlinedText(ctx, formatScaleLabel(nicePhysical, unit), barX + barPx / 2, barY - 4); + ctx.textAlign = "left"; + drawOutlinedText(ctx, formatZoomLabel(zoom), margin, cssHeight - margin + barThickness); + + ctx.restore(); +} + +function drawOutlinedText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number) { + ctx.strokeText(text, x, y); + ctx.fillText(text, x, y); +} diff --git a/scripts/build.mjs b/scripts/build.mjs index 330fdef88..2f7b78ed7 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -23,6 +23,7 @@ const widgets = [ "show3dslices", "show4dstem", "showdiffraction", + "showpdf", "showptycho", "chooselattice", "showcif", diff --git a/scripts/widget_release_check.sh b/scripts/widget_release_check.sh index 16b660cd0..78ef96753 100755 --- a/scripts/widget_release_check.sh +++ b/scripts/widget_release_check.sh @@ -65,6 +65,7 @@ required = { "quantem/widget/static/show4dstem.js", "quantem/widget/static/showcif.js", "quantem/widget/static/showdiffraction.js", + "quantem/widget/static/showpdf.js", "quantem/widget/static/showptycho.js", } with zipfile.ZipFile(wheel) as zf: diff --git a/src/quantem/widget/__init__.py b/src/quantem/widget/__init__.py index 993bd0169..7caf53bea 100644 --- a/src/quantem/widget/__init__.py +++ b/src/quantem/widget/__init__.py @@ -28,6 +28,7 @@ "ShowDiffraction": ("quantem.widget.showdiffraction", "ShowDiffraction"), "Phase": ("quantem.widget.showdiffraction", "Phase"), "library_phase": ("quantem.widget.showdiffraction", "library_phase"), + "ShowPDF": ("quantem.widget.showpdf", "ShowPDF"), "ShowPtycho": ("quantem.widget.showptycho", "ShowPtycho"), "read_4dstem": ("quantem.widget.show4dstem.reader", "read_4dstem"), "read_image": ("quantem.widget.io.image", "read_image"), @@ -143,6 +144,7 @@ def used_gb() -> float: "Phase", "ShowDiffraction", "library_phase", + "ShowPDF", "ShowPtycho", "read_4dstem", "read_image", diff --git a/src/quantem/widget/datasets.py b/src/quantem/widget/datasets.py index 92a7c549e..889c4d3e1 100644 --- a/src/quantem/widget/datasets.py +++ b/src/quantem/widget/datasets.py @@ -48,6 +48,10 @@ _GOLD_4DSTEM_SOURCE_SIZE = "full" _FE3O4_SAED_VIEWER = "showdiffraction" _FE3O4_SAED_NAME = "fe3o4-saed" +# The ShowPDF data follow the dataset's current layout (<modality>/<name> at one pinned revision, +# see the quantem.data README); the loaders above still read the older widget-tutorials/ tree. +_TA_SIM_PATH = "4dstem/ta_amorphous_sim" +_TA_SIM_REVISION = "fe907499a3a2c1f827c8827c0b3472c5b08f47bf" # the dataset commit that adds it def _snapshot_download_dataset(**kwargs) -> Path: @@ -86,7 +90,16 @@ def _download_widget_tutorial_folder( never pulls the full-size payloads stored next to it. """ size = _normalise_tutorial_size(size) - path_in_repo = f"{TUTORIAL_DATA_ROOT}/{viewer}/{name}/{size}" + return _download_dataset_folder( + f"{TUTORIAL_DATA_ROOT}/{viewer}/{name}/{size}", + revision=revision, + cache_dir=cache_dir, + force_download=force_download, + ) + + +def _download_dataset_folder(path_in_repo, *, revision=None, cache_dir=None, force_download=False): + """Download one folder of the dataset repository and return its local path.""" kwargs = { "repo_id": TUTORIAL_DATA_REPO_ID, "repo_type": "dataset", @@ -340,6 +353,66 @@ def showdiffraction_fe3o4( return make_dataset(pattern, name="Fe3O4 SAED") +def showpdf_ta( + *, + cache_dir: str | Path | None = None, + revision: str | None = _TA_SIM_REVISION, + force_download: bool = False, + verbose: bool = True, +): + """Load the simulated amorphous Ta 4D-STEM scan used by the ShowPDF tutorial. + + The scan is downloaded from ``4dstem/ta_amorphous_sim``: a 20 x 20 scan of + 307 x 307 float32 patterns simulated from a 1,024,000-atom amorphous Ta + model, with the detector calibrated in 1/Å (k = 1/d). The model's number + density is 1,024,000 / 268.6053³ atoms/ų. + + Parameters + ---------- + cache_dir + Optional Hugging Face cache directory. + revision + Hugging Face dataset revision; defaults to the pinned one. + force_download + If ``True``, ask Hugging Face Hub to refresh the cached files. + verbose + If ``True``, print a short dataset summary. + + Returns + ------- + Dataset4dstem + The calibrated scan, a quantem core ``Dataset4dstem`` (the widget's + stand-in where quantem core cannot install). + """ + folder = _download_dataset_folder( + _TA_SIM_PATH, revision=revision, cache_dir=cache_dir, force_download=force_download + ) + metadata = json.loads((folder / "meta.json").read_text()) + data = np.load(folder / "data.npy") + dataset = make_dataset( + data, sampling=metadata["sampling"], units=metadata["units"], name="Simulated amorphous Ta" + ) + if verbose: + print(f"Source: {metadata['name']} from Hugging Face") + print(f"Scan: {data.shape} {data.dtype}") + print(f"Sampling: {[float(value) for value in dataset.sampling]} {list(dataset.units)}") + return dataset + + +def showpdf_ta_ground_truth( + *, + cache_dir: str | Path | None = None, + revision: str | None = _TA_SIM_REVISION, + force_download: bool = False, +): + """g(r) of the atomic model behind ``showpdf_ta``: ``(r, g)`` arrays, r in Å.""" + folder = _download_dataset_folder( + _TA_SIM_PATH, revision=revision, cache_dir=cache_dir, force_download=force_download + ) + with np.load(folder / "ground_truth_gr.npz") as truth: + return truth["r"], truth["gr"] + + def _gold_haadf_2d_from_folder(folder: Path, *, stride: int, verbose: bool = True): """Build a calibrated 2D HAADF preview from the shared tutorial source.""" @@ -448,4 +521,6 @@ def _gold_4dstem_from_folder( "show3d_gold", "show4dstem_gold", "showdiffraction_fe3o4", + "showpdf_ta", + "showpdf_ta_ground_truth", ] diff --git a/src/quantem/widget/showpdf.py b/src/quantem/widget/showpdf.py new file mode 100644 index 000000000..c58e8a3df --- /dev/null +++ b/src/quantem/widget/showpdf.py @@ -0,0 +1,2604 @@ +"""ShowPDF: interactive pair distribution function analysis of 4D-STEM data. + +The widget selects scan positions (a painted mask, a square probe or a line band) and shows +the curves that quantem's PairDistributionFunction computes for them. +""" + +import base64 +import csv +import datetime +import inspect +import io +import json +import math +import pathlib +import time +import warnings +import zipfile +from typing import Self + +import anywidget +import numpy as np +import traitlets + +from quantem.widget.export import HtmlExportMixin, ensure_mobile_viewport, export_slug +from quantem.widget.fallback import StaticFallbackMixin +from quantem.widget.state import SavedStateMixin +from quantem.widget.utils.array import to_numpy +from quantem.widget.utils.state_io import resolve_widget_version, unwrap_state_payload + +_PDF_REQUIREMENT = ( + "ShowPDF needs quantem's PairDistributionFunction with calculate_Gr(density=..., " + "max_iter=...), from electronmicroscopy/quantem pull request 248." +) + +# Removed constructor keywords and what to use instead; passing one raises TypeError with +# the hint instead of being silently ignored by traitlets. +_REMOVED_KWARGS = { + "density_mode": "the density is never estimated; pass density_value (atoms/ų) instead", + "line_max_bins": "renamed to linescan_max_bins", + "_offline": "renamed to offline, which only exported HTML files set", + "theme": "removed; the widget follows the notebook theme", + "disabled_tools": "removed", + "hidden_tools": "removed", + "k_width": "removed; the k window's taper width is fixed", + "r_min": "removed; the r grid always starts at 0", +} + +# Status hints for an output that needs the number density, which is never estimated. +_DENSITY_HINT_DAMPING = "Set the density to apply damping and compute g(r)." +_DENSITY_HINT_GR = "Set the density to compute g(r)." +_DENSITY_HINTS = (_DENSITY_HINT_DAMPING, _DENSITY_HINT_GR) +# Status hint for a k window with no positive weight, where F(k) and G(r) are zero (g(r) = 1). +# It takes precedence over the density hints. +_WINDOW_HINT = ( + "The k window contains no k points; set its lower end below its upper end, " + "more than one k step apart." +) +_LINE_WIDTH_ERROR = "line_width must be an odd number of scan pixels (1, 3, 5, ...), got {!r}." +# Bound on the line ends in scan pixels. No scan comes close; ends beyond about 1e154 would +# overflow the length of the perpendicular bisector. +_LINE_END_LIMIT_TEXT = "1e9" # the spelling users see, as in the docs +_LINE_END_LIMIT = float(_LINE_END_LIMIT_TEXT) +_LINE_END_ERROR = f"{{}} must be a finite number within ±{_LINE_END_LIMIT_TEXT}, got {{!r}}." +# Each plot_mode's synced curve, as (x trait, y trait), and its axis labels. +_CURVE_TRAITS = { + "Ik": ("ik_x_bytes", "ik_y_bytes"), + "Fk": ("fk_x_bytes", "fk_y_bytes"), + "Gr": ("gr_x_bytes", "gr_y_bytes"), + "gr": ("pdf_x_bytes", "pdf_y_bytes"), +} +_CURVE_AXIS_LABELS = {"Ik": ("k (1/Å)", "I(k)"), "Fk": ("k (1/Å)", "F(k)"), "Gr": ("r (Å)", "G(r)"), "gr": ("r (Å)", "g(r)")} +# What the saved-notebook preview shows; a change to any of these redraws it at the next save. +_PREVIEW_TRAITS = ( + "title", "plot_mode", "cmap", "auto_contrast", "nav_image_bytes", "linescan_bytes", + *(y for _, y in _CURVE_TRAITS.values()), +) + + +# ============================================================================= +# Input and saved state +# ============================================================================= +def _pdf_class(): + """quantem's PairDistributionFunction, imported on first use. + + quantem core is optional for quantem.widget (it does not install on an Intel Mac or + Windows on ARM), so this module imports without it; ShowPDF raises ImportError naming + the requirement. + """ + try: + from quantem.diffraction import PairDistributionFunction + except ImportError as exc: + raise ImportError(_PDF_REQUIREMENT) from exc + gr_params = inspect.signature(PairDistributionFunction.calculate_Gr).parameters + if not {"density", "max_iter"} <= set(gr_params): + raise ImportError(_PDF_REQUIREMENT) + return PairDistributionFunction + + +def _as_dataset(data): + """Datasets pass through; 4D and 2D arrays are wrapped for PairDistributionFunction.from_data.""" + if hasattr(data, "array"): + return data + from quantem.core.datastructures import Dataset2d, Dataset4dstem + + arr = to_numpy(data) + if arr.ndim == 4: + return Dataset4dstem.from_array(arr) + if arr.ndim == 2: + return Dataset2d.from_array(arr) + raise ValueError(f"ShowPDF expects 4D (4D-STEM) or 2D input; got {arr.ndim}D array.") + + +def _load_pdf(data, **from_data_kwargs): + """``data`` as a PairDistributionFunction, and the title it carries ("" if none). + + Other inputs go through ``from_data`` with ``device`` "cpu" unless given: the library + computes in float64, which MPS does not support and other GPU drivers handle unevenly, + and the compute is light. + """ + pdf_class = _pdf_class() + if isinstance(data, pdf_class): + return data, "" + title = "" + if hasattr(data, "title") and hasattr(data, "data"): # an IOResult + title = data.title or "" + data = data.data + if hasattr(data, "array") and hasattr(data, "name"): # a Dataset + title = title or (data.name or "") + if from_data_kwargs.get("device") is None: + from_data_kwargs["device"] = "cpu" + return pdf_class.from_data(_as_dataset(data), **from_data_kwargs), title + + +def _read_state(state): + """The state dict of a ``state=`` argument: a dict, or a file written by ``save``.""" + if isinstance(state, (str, pathlib.Path)): + payload = json.loads(pathlib.Path(state).read_text()) + return unwrap_state_payload(payload, require_envelope=True, expected_widget="ShowPDF") + return unwrap_state_payload(state, expected_widget="ShowPDF") + + +def _migrate_state(state): + """A copy of a saved state with the values of earlier versions made loadable. + + Returns (state, notice): notice is the warning for a migrated even line_width, else None. + """ + state = dict(state) + if state.get("density_mode") == "estimated": + # This density_value was an automatic estimate, never supplied by the user, so it + # must not become the fixed density for g(r) and the damping. + state.pop("density_value", None) + density = state.get("density_value") + if isinstance(density, float) and not math.isfinite(density): + # NaN meant "not set" (save writes it as a NaN literal), so it loads as 0. The + # validator rejects a non-finite density everywhere else. + state["density_value"] = 0.0 + notice = None + width = state.get("line_width") + if isinstance(width, int): + # Any width was allowed: an even width w selected w + 1 rows of a horizontal line and + # a width below 1 acted as 1, so load the odd width that selects the same rows. + if width < 1: + state["line_width"] = 1 + elif width % 2 == 0: + state["line_width"] = width + 1 + notice = ( + f"The saved line_width {width} is even; it loads as line_width {width + 1}, " + "which selects the same positions of a horizontal or vertical line. " + "A diagonal band may include more positions than before." + ) + return state, notice + + +def _reject_unknown_kwargs(cls, kwargs): + """Raise TypeError for a keyword that is not a trait, which traitlets would silently ignore.""" + traits = set(cls.class_trait_names()) + unknown = sorted(k for k in kwargs if k not in traits) + if unknown: + key = unknown[0] + hint = _REMOVED_KWARGS.get(key, "check for typos or a renamed parameter") + raise TypeError(f"{cls.__name__}() got unexpected keyword argument {key!r}: {hint}.") + + +def _is_whole_number(value): + """True if int() converts ``value`` without truncating it (2.0 and np.int64(2), not 2.5). + + A value too large for a float is not a whole number here. + """ + try: + return float(value).is_integer() + except OverflowError: + return False + + +def _whole_number(name, value): + """``value`` as an int for the Int trait ``name``; int() alone would truncate 2.5 to 2.""" + if not _is_whole_number(value): + raise traitlets.TraitError(f"{name} must be a whole number, got {value!r}.") + return int(value) + + +def _line_width_int(width): + """``width`` as an int; a width that is not a whole number raises the validator's TraitError.""" + if not _is_whole_number(width): + raise traitlets.TraitError(_LINE_WIDTH_ERROR.format(width)) + return int(width) + + +def _line_end_float(name, value): + """``value`` as a float line end; NaN, infinity or a value beyond the limit raises ValueError.""" + try: + end = float(value) + except OverflowError: # an int too large for a float is beyond the limit too + raise ValueError(_LINE_END_ERROR.format(name, value)) from None + if not math.isfinite(end) or abs(end) > _LINE_END_LIMIT: + raise ValueError(_LINE_END_ERROR.format(name, end)) + return end + + +def _finite_pixel_size(pixel_size): + """``pixel_size`` as a float, or None when it is not given. + + NaN or infinity raises ValueError: it is not valid JSON in the HTML export. + """ + if pixel_size is None: + return None + value = float(pixel_size) + if not math.isfinite(value): + raise ValueError(f"pixel_size must be a finite number, got {value!r}.") + return value + + +def _finite_k_axis(pdf): + """The k axis of ``pdf`` as float32, the precision of the k traits. + + A NaN or infinite k (from a NaN or overflowing calibration) raises ValueError naming + that cause, instead of rejecting a default fit range the user never passed. + """ + qq = np.asarray(pdf.qq, dtype=np.float32) + if not np.isfinite(qq).all(): + raise ValueError( + "The dataset's k axis is not finite (or too large for float32); " + "check the diffraction calibration." + ) + return qq + + +# ============================================================================= +# Arrays and line geometry +# ============================================================================= +def _float32(values): + """A NumPy, Torch or CuPy array as a new float32 NumPy array.""" + return to_numpy(values).astype(np.float32) + + +def _finite_range(img): + """Min and max of the finite pixels of ``img``, or (0.0, 0.0) if it has none. + + NaN and infinity are not valid JSON in the HTML export; the frontend's findDataRange + skips the same pixels. + """ + finite = img[np.isfinite(img)] + if finite.size == 0: + return 0.0, 0.0 + return float(finite.min()), float(finite.max()) + + +def _bf_nav_image(arr): + """Bright-field virtual image of a 4D-STEM array for the navigation panel. + + The BF disk is found on the scan-summed pattern: threshold at mean + std, centroid for + the centre and radius from the disk area (A = pi r^2), falling back to the detector + centre with radius det_rows / 6 if nothing is above the threshold. Returns the mean + intensity inside the disk at each scan position. + """ + arr = np.asarray(arr, dtype=np.float32) + det_rows, det_cols = arr.shape[-2], arr.shape[-1] + summed = arr.reshape(-1, det_rows, det_cols).sum(axis=0) + disk = summed > summed.mean() + summed.std() + total = disk.sum() + row_coords = np.arange(det_rows, dtype=np.float64).reshape(-1, 1) + col_coords = np.arange(det_cols, dtype=np.float64).reshape(1, -1) + if total > 0: + center_row = float((row_coords * disk).sum() / total) + center_col = float((col_coords * disk).sum() / total) + bf_radius = float(np.sqrt(total / np.pi)) + else: + center_row, center_col, bf_radius = det_rows / 2.0, det_cols / 2.0, det_rows / 6.0 + rad = np.sqrt((row_coords - center_row) ** 2 + (col_coords - center_col) ** 2) + bf_mask = (rad < bf_radius).astype(np.float32) + return (arr * bf_mask).mean(axis=(-2, -1)).astype(np.float32) + + +def _round_half_up(value): + """Nearest whole scan coordinate, halves rounded up as Math.round does in the frontend.""" + return float(math.floor(value + 0.5)) + + +def _perpendicular_bisector(r0, c0, r1, c1): + """Ends of the segment's perpendicular bisector: same midpoint and length, turned 90°.""" + dr, dc = r1 - r0, c1 - c0 + # sqrt, not hypot, in the frontend's order of operations: both round the same, so an end + # half way between scan positions snaps the same way in Python and in the browser. + length = math.sqrt(dr * dr + dc * dc) + if length > 0: + mr, mc = (r0 + r1) / 2.0, (c0 + c1) / 2.0 + pr, pc = -dc / length, dr / length # unit vector perpendicular to the segment + half = length / 2.0 + return mr - pr * half, mc - pc * half, mr + pr * half, mc + pc * half + return r0, c0, r1, c1 # a point has no direction + + +def _clip_to_scan(r0, c0, r1, c1, rows, cols): + """Liang-Barsky clip of a segment to a rows x cols scan, in the frontend's arithmetic. + + The clipped ends are rounded to scan positions again; a segment wholly outside the + scan collapses to the point (r0, c0). + """ + dr, dc = r1 - r0, c1 - c0 + t0, t1 = 0.0, 1.0 + for p, q in ((-dr, r0), (dr, rows - 1 - r0), (-dc, c0), (dc, cols - 1 - c0)): + if p == 0: + if q < 0: + return r0, c0, r0, c0 + elif p < 0: + t0 = max(t0, q / p) + else: + t1 = min(t1, q / p) + if t0 > t1: + return r0, c0, r0, c0 + return ( + _round_half_up(r0 + t0 * dr), + _round_half_up(c0 + t0 * dc), + _round_half_up(r0 + t1 * dr), + _round_half_up(c0 + t1 * dc), + ) + + +def _nan_stack(n_rows, n_cols): + """A float32 (n_rows, n_cols) stack of NaN: rows that get no curve stay NaN.""" + return np.full((n_rows, n_cols), np.nan, dtype=np.float32) + + +def _fill_row(stack, row, values): + """Copy ``values`` into ``stack[row]``, cut to the stack's width.""" + n = min(len(values), stack.shape[1]) + stack[row, :n] = values[:n] + + +class ShowPDF(SavedStateMixin, HtmlExportMixin, StaticFallbackMixin, anywidget.AnyWidget): + """Interactive pair distribution function (PDF) analysis of 4D-STEM data. + + Wraps :class:`quantem.diffraction.PairDistributionFunction`. The left panel + shows a bright-field navigation image on which scan positions are selected + (painted mask, square probe, or line band). The right panel shows the curves + for the selection: I(k) with the fitted background B(k), the windowed reduced + structure factor F(k), the reduced PDF G(r), and the PDF g(r). + + The k axis is in 1/Å as 1/d (the 2π enters inside the sine transform). The + number density is never estimated: g(r) and the low-r damping need an + explicit ``density_value``. + + Parameters + ---------- + data : PairDistributionFunction, Dataset4dstem, Dataset2d, or array-like + A prepared ``PairDistributionFunction``, or data it is built from with + ``PairDistributionFunction.from_data``. Arrays must be 4D + ``(scan_row, scan_col, det_row, det_col)`` or a single 2D pattern. + nav_image : array-like, optional + Navigation image of shape ``(scan_row, scan_col)``. Defaults to a + bright-field virtual image of the input data. + title : str, optional + Widget title. Defaults to the dataset name, else ``"PDF"``. + k_min_fit, k_max_fit : float, default 0.0 + k range (1/Å) of the background fit. 0 means the full data range. + k_min_window, k_max_window : float, default 0.0 + k range (1/Å) of the window applied to F(k) before the transform. + 0 (or less) means "follow the fit range", as ``None`` in ``calculate_Gr``. + k_lowpass, k_highpass : float, default 0.0 + Gaussian low-pass and high-pass filter widths on F(k), in 1/Å. 0 disables. + r_max, r_step : float, default 20.0, 0.02 + Real-space grid (Å) of G(r) and g(r), which always starts at r = 0. + damp_origin_oscillations : bool, default False + Apply the low-r correction of ``PairDistributionFunction.damp_origin_oscillations``, + which forces G(r) onto -4π ρ₀ r on [0, r_cut]. Needs ``density_value``. + r_cut : float, default 0.8 + Upper end r (Å) of the low-r correction. Set it below the nearest-neighbour + distance. + max_iter : int, default 6 + Maximum number of iterations of the low-r correction. + density_value : float, default 0.0 + Atomic number density ρ₀ (atoms/ų) used for g(r) and the damping. + 0 means "not set": g(r) is left empty and damping is skipped. + analysis_mode : {"mask", "probe", "line"}, default "mask" + How scan positions are selected. + probe_row, probe_col : int, optional + Probe centre (row, col). Defaults to the scan centre. + probe_size : int, default 1 + The probe selects a square of side ``2 * probe_size - 1`` positions. + line_row0, line_col0, line_row1, line_col1 : float, default 0.0 + Line endpoints (row, col) in scan pixels; equal endpoints mean no line. + line_width : int, default 3 + Width of the line band, an odd number of scan pixels (1, 3, 5, ...). + A horizontal or vertical band of width w covers exactly w rows or + columns (clipped at the scan edge). + line_mode : {"averaged", "linescan"}, default "averaged" + One PDF from the whole band, or one PDF per position along the line. + line_perpendicular : bool, default False + Sample along the perpendicular bisector of the drawn line instead. + linescan_max_bins : int, default 256 + Maximum number of positions along the line in line-scan mode. The + positions are evenly spaced, one per whole scan-pixel step along the + line's longer axis, unless this cap is lower. + linescan_view : {"heatmap", "waterfall"}, default "heatmap" + Rendering of the line-scan stack. + waterfall_count : int, default 10 + Number of curves shown in the waterfall view, evenly spaced along the + line and ending at its tip; the shown count is the closest to this that + keeps the spacing exact. + plot_mode : {"Ik", "Fk", "Gr", "gr"}, default "Gr" + Curve shown in the right panel. + show_background, cmap, log_scale, auto_contrast, show_stats, show_controls + Display options. + find_origin, origin_row, origin_col, num_annular_bins, radial_min, radial_max, radial_step, two_fold_rotation_symmetry + Passed to ``PairDistributionFunction.from_data`` when ``data`` is not + already a ``PairDistributionFunction``. + device : str, optional + Torch device of the PDF computation. Defaults to ``"cpu"``. + pixel_size : float, optional + Scan pixel size in Å for the scale bar; overrides the dataset calibration. + save_state : bool, default False + Keep the scan image, mask and line-scan stack in a saved notebook, so that it + reopens interactive without a kernel. By default a saved notebook keeps a static + picture of the widget instead. + state : dict, str, or pathlib.Path, optional + State dictionary, or a file written by :meth:`save`, to restore. + **kwargs + Other widget traits. Unknown keywords raise ``TypeError``. + """ + + _esm = pathlib.Path(__file__).parent / "static" / "showpdf.js" + # A notebook saved with save_state=False keeps this static picture instead of these traits. + _static_fallback_jpeg = traitlets.Unicode("").tag(sync=True) + _static_fallback_mime = traitlets.Unicode("image/png").tag(sync=True) + _UNSAVED_HEAVY_KEYS = ("nav_image_bytes", "mask_b64", "mask_bytes", "linescan_bytes", "export_payload") + + # ========================================================================= + # Version + # ========================================================================= + widget_version = traitlets.Unicode("unknown").tag(sync=True) + + # ========================================================================= + # Shape and data (read-only from JS) + # ========================================================================= + title = traitlets.Unicode("PDF").tag(sync=True) + scan_rows = traitlets.Int(1).tag(sync=True) + scan_cols = traitlets.Int(1).tag(sync=True) + nav_image_bytes = traitlets.Bytes(b"").tag(sync=True) + nav_data_min = traitlets.Float(0.0).tag(sync=True) + nav_data_max = traitlets.Float(1.0).tag(sync=True) + # Scan calibration of the nav scale bar: the dataset's first axis in Å (nm converted), + # else 1 px. + nav_pixel_size = traitlets.Float(1.0).tag(sync=True) + nav_unit = traitlets.Unicode("px").tag(sync=True) + + # ========================================================================= + # Painted mask (JS paints, Python reads): 1 includes a scan position and + # 0 excludes it, as in calculate_radial_mean + # ========================================================================= + mask_bytes = traitlets.Bytes(b"").tag(sync=True) # raw uint8 copy of mask_b64 (Python only) + mask_b64 = traitlets.Unicode("").tag(sync=True) # the mask from JS, base64 uint8 + mask_version = traitlets.Int(0).tag(sync=True) # JS increments it to trigger a recompute + mask_tool = traitlets.Unicode("rectangle").tag(sync=True) + mask_brush_size = traitlets.Int(5).tag(sync=True) + mask_pixel_count = traitlets.Int(0).tag(sync=True) + mask_fraction = traitlets.Float(0.0).tag(sync=True) + + # ========================================================================= + # Analysis mode: "mask" (the painted mask), "probe" (a square of side + # 2 * probe_size - 1 around probe_row, probe_col) or "line" (a band) + # ========================================================================= + analysis_mode = traitlets.Unicode("mask").tag(sync=True) + probe_row = traitlets.Int(0).tag(sync=True) + probe_col = traitlets.Int(0).tag(sync=True) + probe_size = traitlets.Int(1).tag(sync=True) + + # ========================================================================= + # Line band: the scan positions within line_width / 2 of the segment + # (line_row0, line_col0)-(line_row1, line_col1). line_mode is "averaged" + # (one PDF from the band) or "linescan" (one PDF per position). + # ========================================================================= + line_row0 = traitlets.Float(0.0).tag(sync=True) + line_col0 = traitlets.Float(0.0).tag(sync=True) + line_row1 = traitlets.Float(0.0).tag(sync=True) + line_col1 = traitlets.Float(0.0).tag(sync=True) + line_width = traitlets.Int(3).tag(sync=True) + line_active = traitlets.Bool(False).tag(sync=True) # False until a line is drawn + line_mode = traitlets.Unicode("averaged").tag(sync=True) + # JS bumps it once per edit of the ends, so a drag triggers a single recompute. + line_version = traitlets.Int(0).tag(sync=True) + # Sample along the perpendicular bisector of the drawn line, so a line traced along a + # feature scans across it. + line_perpendicular = traitlets.Bool(False).tag(sync=True) + + # Line-scan stack (line_mode="linescan"): float32 (n_linescan, linescan_n_points), + # row-major by position, on the shared r or k axis of the plot_mode curve. Empty until + # a line is drawn. + linescan_bytes = traitlets.Bytes(b"").tag(sync=True) + n_linescan = traitlets.Int(0).tag(sync=True) + linescan_n_points = traitlets.Int(0).tag(sync=True) + linescan_axis_bytes = traitlets.Bytes(b"").tag(sync=True) + linescan_max_bins = traitlets.Int(256).tag(sync=True) + linescan_view = traitlets.Unicode("heatmap").tag(sync=True) # "heatmap" or "waterfall" + # Curves stacked in the waterfall view, evenly spaced along the line; the heatmap shows all. + waterfall_count = traitlets.Int(10).tag(sync=True) + + # ========================================================================= + # Curves (float32 bytes, all updated on every recompute) + # ========================================================================= + # I(k) radial mean and its fitted background B(k) + ik_x_bytes = traitlets.Bytes(b"").tag(sync=True) + ik_y_bytes = traitlets.Bytes(b"").tag(sync=True) + ik_bg_y_bytes = traitlets.Bytes(b"").tag(sync=True) + n_points_ik = traitlets.Int(0).tag(sync=True) + # F(k) after the k window, the curve the sine transform uses (damped when the low-r + # correction ran) + fk_x_bytes = traitlets.Bytes(b"").tag(sync=True) + fk_y_bytes = traitlets.Bytes(b"").tag(sync=True) + n_points_fk = traitlets.Int(0).tag(sync=True) + # G(r) reduced PDF + gr_x_bytes = traitlets.Bytes(b"").tag(sync=True) + gr_y_bytes = traitlets.Bytes(b"").tag(sync=True) + n_points_gr = traitlets.Int(0).tag(sync=True) + # g(r) pair distribution function + pdf_x_bytes = traitlets.Bytes(b"").tag(sync=True) + pdf_y_bytes = traitlets.Bytes(b"").tag(sync=True) + n_points_pdf = traitlets.Int(0).tag(sync=True) + + # ========================================================================= + # Standalone export data, set in the exported state only. offline_qq is the + # exact k axis; with encoding="uint8" the per-position radial profiles + # (mean over phi, uint8 per k bin) let the page recompute I(k) for any mask, + # probe or line selection without a kernel. + # ========================================================================= + offline_recompute = traitlets.Bool(False).tag(sync=True) + offline_nk = traitlets.Int(0).tag(sync=True) + offline_rows = traitlets.Int(0).tag(sync=True) + offline_cols = traitlets.Int(0).tag(sync=True) + offline_profiles = traitlets.Bytes(b"").tag(sync=True) # uint8 (rows*cols*nk) + offline_profiles_lo = traitlets.Bytes(b"").tag(sync=True) # float32 (nk,) + offline_profiles_hi = traitlets.Bytes(b"").tag(sync=True) # float32 (nk,) + offline_qq = traitlets.Bytes(b"").tag(sync=True) # float64 (nk,) k axis + + # ========================================================================= + # PDF parameters (user-tunable, trigger recompute) + # 0.0 sentinel means "auto" or "disabled" + # ========================================================================= + k_min_fit = traitlets.Float(0.0).tag(sync=True) + k_max_fit = traitlets.Float(0.0).tag(sync=True) + k_min_window = traitlets.Float(0.0).tag(sync=True) + k_max_window = traitlets.Float(0.0).tag(sync=True) + k_lowpass = traitlets.Float(0.0).tag(sync=True) + k_highpass = traitlets.Float(0.0).tag(sync=True) + r_max = traitlets.Float(20.0).tag(sync=True) + r_step = traitlets.Float(0.02).tag(sync=True) + damp_origin_oscillations = traitlets.Bool(False).tag(sync=True) + r_cut = traitlets.Float(0.8).tag(sync=True) + max_iter = traitlets.Int(6).tag(sync=True) + + # Number density (atoms/ų) for g(r) and the low-r damping; 0.0 means not set, since + # PairDistributionFunction does not estimate it. + density_value = traitlets.Float(0.0).tag(sync=True) + + # k range of the data (set from the data, used for the slider bounds) + k_min_available = traitlets.Float(0.0).tag(sync=True) + k_max_available = traitlets.Float(10.0).tag(sync=True) + + # ========================================================================= + # Display + # ========================================================================= + plot_mode = traitlets.Unicode("Gr").tag(sync=True) + show_background = traitlets.Bool(True).tag(sync=True) + cmap = traitlets.Unicode("gray").tag(sync=True) + log_scale = traitlets.Bool(False).tag(sync=True) + auto_contrast = traitlets.Bool(True).tag(sync=True) + show_stats = traitlets.Bool(True).tag(sync=True) + show_controls = traitlets.Bool(True).tag(sync=True) + + # Status + computing = traitlets.Bool(False).tag(sync=True) + status_message = traitlets.Unicode("").tag(sync=True) + + # True only in the standalone HTML export: there is no kernel, so the k-space sliders + # recompute the curves in JS from the baked I(k). The export traits come from + # HtmlExportMixin. + offline = traitlets.Bool(False).tag(sync=True) + + @traitlets.validate("linescan_max_bins") + def _validate_linescan_max_bins(self, proposal): + value = int(proposal["value"]) + if value < 1: + raise traitlets.TraitError(f"linescan_max_bins must be at least 1, got {value!r}.") + return value + + @traitlets.validate("linescan_view") + def _validate_linescan_view(self, proposal): + value = proposal["value"] + if value not in ("heatmap", "waterfall"): + raise traitlets.TraitError(f"linescan_view must be 'heatmap' or 'waterfall', got {value!r}.") + return value + + @traitlets.validate("waterfall_count") + def _validate_waterfall_count(self, proposal): + value = proposal["value"] + if value < 1: + raise traitlets.TraitError(f"waterfall_count must be at least 1, got {value!r}.") + return value + + @traitlets.validate("line_width") + def _validate_line_width(self, proposal): + value = int(proposal["value"]) + try: + float(value) # the band test divides line_width by 2.0 + except OverflowError: + raise traitlets.TraitError(_LINE_WIDTH_ERROR.format(value)) from None + if value < 1 or value % 2 == 0: + raise traitlets.TraitError(_LINE_WIDTH_ERROR.format(value)) + return value + + @traitlets.validate("line_row0", "line_col0", "line_row1", "line_col1") + def _validate_line_end(self, proposal): + # A NaN or infinite end has no band and is not valid JSON in an export; the limit + # keeps the perpendicular bisector finite. + value = float(proposal["value"]) + if not math.isfinite(value) or abs(value) > _LINE_END_LIMIT: + raise traitlets.TraitError(_LINE_END_ERROR.format(proposal["trait"].name, value)) + return value + + @traitlets.validate( + "k_min_fit", "k_max_fit", "k_min_window", "k_max_window", "k_lowpass", "k_highpass", + "r_max", "r_step", "r_cut", "density_value", + ) + def _validate_finite(self, proposal): + # NaN or infinity cannot be computed with and is not valid JSON in an export; + # 0 still means unset. + value = float(proposal["value"]) + if not math.isfinite(value): + raise traitlets.TraitError( + f"{proposal['trait'].name} must be a finite number, got {value!r}." + ) + return value + + # ========================================================================= + # Constructor + # ========================================================================= + def __init__( + self, + data, + *, + nav_image=None, + title="", + # PDF parameters + k_min_fit=0.0, + k_max_fit=0.0, + k_min_window=0.0, + k_max_window=0.0, + k_lowpass=0.0, + k_highpass=0.0, + r_max=20.0, + r_step=0.02, + damp_origin_oscillations=False, + r_cut=0.8, + max_iter=6, + # Number density (atoms/A^3) for g(r) and damping; 0.0 = not set + density_value=0.0, + # Analysis mode + probe geometry + analysis_mode="mask", + probe_row=None, + probe_col=None, + probe_size=1, + # Line geometry (analysis_mode="line") + line_row0=0.0, + line_col0=0.0, + line_row1=0.0, + line_col1=0.0, + line_width=3, + line_mode="averaged", + line_perpendicular=False, + linescan_max_bins=256, + linescan_view="heatmap", + waterfall_count=10, + # Display + plot_mode="Gr", + show_background=True, + cmap="gray", + log_scale=False, + auto_contrast=True, + show_stats=True, + show_controls=True, + # PDF construction params (only used when data is not already a PDF) + find_origin=True, + origin_row=None, + origin_col=None, + num_annular_bins=180, + radial_min=0.0, + radial_max=None, + radial_step=1.0, + two_fold_rotation_symmetry=False, + device=None, + # Real-space scale bar (overrides auto-detect from ds.sampling/units) + pixel_size=None, + # State + save_state=False, + state=None, + **kwargs, + ): + _reject_unknown_kwargs(type(self), kwargs) + # Set before the widget opens, since opening it takes a full state snapshot. + self._save_state = bool(save_state) + self._configure_static_fallback(notebook_preview_format="png") + super().__init__(**kwargs) + self._initializing = True + self.widget_version = resolve_widget_version() + t0 = time.perf_counter() + + self._pdf, input_title = _load_pdf( + data, + find_origin=find_origin, + origin_row=origin_row, + origin_col=origin_col, + num_annular_bins=num_annular_bins, + radial_min=radial_min, + radial_max=radial_max, + radial_step=radial_step, + two_fold_rotation_symmetry=two_fold_rotation_symmetry, + device=device, + ) + self._reset_pdf_cache() + # The dataset (counted by set_data) and the scan positions that the synced single + # curves were computed for; None while there are none. + self._data_version = 0 + self._curves_selection = None + self._pixel_size_override = None + + # --- Scan shape and k range of the data --- + polar_shape = self._pdf.polar.shape + self.scan_rows = int(polar_shape[0]) + self.scan_cols = int(polar_shape[1]) + qq = _finite_k_axis(self._pdf) + self.k_min_available = float(qq[0]) + self.k_max_available = float(qq[-1]) + # The fit range defaults to the full data range. The window keeps its 0.0 sentinel, + # so calculate_Gr follows the fit range. + if k_min_fit == 0.0: + k_min_fit = float(qq[0]) + if k_max_fit == 0.0: + k_max_fit = float(qq[-1]) + + # --- Parameter traits --- + self.title = title or input_title or "PDF" + self.k_min_fit = k_min_fit + self.k_max_fit = k_max_fit + self.k_min_window = k_min_window + self.k_max_window = k_max_window + self.k_lowpass = k_lowpass + self.k_highpass = k_highpass + self.r_max = r_max + self.r_step = r_step + self.damp_origin_oscillations = damp_origin_oscillations + self.r_cut = r_cut + self.max_iter = _whole_number("max_iter", max_iter) + self.density_value = float(density_value) + self.analysis_mode = analysis_mode + if probe_row is None: + probe_row = self.scan_rows // 2 + if probe_col is None: + probe_col = self.scan_cols // 2 + self.probe_row = _whole_number("probe_row", probe_row) + self.probe_col = _whole_number("probe_col", probe_col) + self.probe_size = _whole_number("probe_size", probe_size) + self.line_row0 = float(line_row0) + self.line_col0 = float(line_col0) + self.line_row1 = float(line_row1) + self.line_col1 = float(line_col1) + self.line_width = _line_width_int(line_width) + self.line_mode = line_mode + self.line_perpendicular = bool(line_perpendicular) + self.linescan_max_bins = _whole_number("linescan_max_bins", linescan_max_bins) + self.linescan_view = linescan_view + self.waterfall_count = _whole_number("waterfall_count", waterfall_count) + # A line is active once its ends differ. + self.line_active = (line_row0, line_col0) != (line_row1, line_col1) + + # --- Display traits --- + self.plot_mode = plot_mode + self.show_background = show_background + self.cmap = cmap + self.log_scale = log_scale + self.auto_contrast = auto_contrast + self.show_stats = show_stats + self.show_controls = show_controls + + self._set_nav_image(nav_image, pixel_size) + self._register_observers() + + # --- Initial computation --- + self._initializing = False + self._update_mask_stats() + self._recompute_full() + elapsed = time.perf_counter() - t0 + if not self.status_message: # keep an error or hint the compute set + self.status_message = f"Ready ({elapsed:.1f}s)" + + if state is not None: + self.load_state_dict(_read_state(state)) + + # ========================================================================= + # Dataset and navigation image + # ========================================================================= + def _reset_pdf_cache(self): + """Drop the results cached on the PairDistributionFunction. + + calculate_Gr reuses a cached radial mean when the cached mask matches, so a cache from + preprocessing or another dataset could bypass this widget's selection. + """ + for attr in ( + "Ik", "_Ik_mask", "bg", "f", "Sk", "Fk", "Fk_masked", "Fk_damped", + "rho0", "_reduced_pdf", "_r", "_pdf", "reduced_pdf_damped", + ): + if hasattr(self._pdf, attr): + setattr(self._pdf, attr, None) + + def _compute_nav_image(self): + """Bright-field image of the 4D input data, else the polar data summed over angle and radius.""" + input_data = getattr(self._pdf, "input_data", None) + if input_data is not None and getattr(input_data, "ndim", None) == 4: + return _bf_nav_image(input_data.numpy()) + return self._pdf.polar.numpy().sum(axis=(-2, -1)).astype(np.float32) + + def _resolve_nav_calibration(self): + """(pixel_size, unit) of the scan from the dataset: in Å for an Å or nm axis, else (1.0, "px").""" + input_data = getattr(self._pdf, "input_data", None) + sampling = getattr(input_data, "sampling", None) + units = getattr(input_data, "units", None) + if sampling is None or units is None or len(units) == 0: + return 1.0, "px" + unit0 = str(units[0]).lower() + size0 = float(sampling[0]) + if not math.isfinite(size0): + return 1.0, "px" # the frontend's scale bar falls back to pixels too + if unit0 in ("å", "a", "angstrom", "angstroms"): + return size0, "Å" + if unit0 == "nm" and math.isfinite(size0 * 10.0): # the conversion can overflow + return size0 * 10.0, "Å" + return 1.0, "px" + + def _set_nav_image(self, nav_image, pixel_size): + """Sync the navigation image (default: a bright-field image of the data) and its scale bar. + + A ``pixel_size`` (Å) overrides the dataset calibration from now on; None keeps the + current override. + """ + if nav_image is not None: + nav_img = to_numpy(nav_image).astype(np.float32) + else: + nav_img = self._compute_nav_image() + self._nav_image = nav_img + self.nav_data_min, self.nav_data_max = _finite_range(nav_img) + self.nav_image_bytes = nav_img.tobytes() + self.nav_pixel_size, self.nav_unit = self._resolve_nav_calibration() + if pixel_size is not None: + self._pixel_size_override = _finite_pixel_size(pixel_size) + if self._pixel_size_override is not None: + self.nav_pixel_size = self._pixel_size_override + self.nav_unit = "Å" + + def _carry_over_k_ranges(self, was_full_range): + """Keep the fit range and window where they lie inside the new k axis; reset them otherwise. + + A fit range that spanned the old data's full range spans the new data's full range too. + """ + # Compare with the float64 k axis, allowing half a step, so a value typed as e.g. 1.55 + # still counts as inside a float32 1.5499999. + qq64 = np.asarray(self._pdf.qq, dtype=np.float64) + tol = 0.5 * float(qq64[1] - qq64[0]) if qq64.size > 1 else 0.0 + + def inside(k): + return qq64[0] - tol <= k <= qq64[-1] + tol + + fit_inside = inside(self.k_min_fit) and inside(self.k_max_fit) and self.k_min_fit < self.k_max_fit + if was_full_range or not fit_inside: + self.k_min_fit = self.k_min_available + self.k_max_fit = self.k_max_available + if not inside(self.k_min_window): + self.k_min_window = 0.0 + if not inside(self.k_max_window): + self.k_max_window = 0.0 + + # ========================================================================= + # Observers + # ========================================================================= + def _register_observers(self): + """Recompute when a parameter or the selection changes, from Python or from JS.""" + self.observe(self._on_mask_change, names=["mask_version"]) + self.observe( + self._on_fit_params_change, + names=["k_min_fit", "k_max_fit", "k_min_window", "k_max_window", "k_lowpass", "k_highpass"], + ) + self.observe( + self._on_output_params_change, + names=["r_max", "r_step", "damp_origin_oscillations", "r_cut", "max_iter"], + ) + self.observe(self._on_density_change, names=["density_value"]) + self.observe(self._on_probe_change, names=["analysis_mode", "probe_row", "probe_col", "probe_size"]) + # The line ends sync without observers: JS bumps line_version once per edit, so a drag + # triggers a single recompute (not one per end). + self.observe( + self._on_line_change, + names=["line_version", "line_width", "line_mode", "line_perpendicular", "linescan_max_bins"], + ) + # In line-scan mode the shown curve decides which PDF the stack holds. + self.observe(self._on_plot_mode_change, names=["plot_mode"]) + self.observe(self._on_export_request_change, names=["export_request"]) + self.observe(self._invalidate_preview, names=list(_PREVIEW_TRAITS)) + + def _on_mask_change(self, change=None): + if self._initializing: + return + self._pdf.Ik = None + self._pdf.bg = None + self._update_mask_stats() + self._recompute_full() + + def _on_fit_params_change(self, change=None): + if self._initializing: + return + self._pdf.bg = None + self._recompute_full() + + def _on_output_params_change(self, change=None): + if self._initializing: + return + self._pdf.bg = None + self._recompute_full() + + def _on_density_change(self, change=None): + if self._initializing: + return + if self._is_linescan(): + # The stack depends on the density only through g(r) or the damping. + if self.plot_mode == "gr" or self.damp_origin_oscillations: + self._compute_linescan() + else: + # Rerun calculate_Gr rather than only calculate_gr: the damped G(r) and F(k) + # depend on the density, and a shared PairDistributionFunction may hold another + # selection's G(r). The radial mean is reused. + self._recompute_full() + + def _on_probe_change(self, change=None): + if self._initializing: + return + # The probe defines a synthetic mask, so this is a mask change. + self._pdf.Ik = None + self._pdf.bg = None + self._update_mask_stats() + self._recompute_full() + + def _on_line_change(self, change=None): + if self._initializing: + return + # The line defines a synthetic band mask, so this is a mask change. + self._pdf.Ik = None + self._pdf.bg = None + self._update_mask_stats() + self._recompute_full() + + def _on_plot_mode_change(self, change=None): + if self._initializing: + return + # Only the line scan computes per plot_mode; otherwise JS redraws the synced curves. + if self._is_linescan(): + self._compute_linescan() + elif ( + self.status_message in ("", _WINDOW_HINT) + _DENSITY_HINTS + or self.status_message.startswith("Ready") + ): + # The g(r) hint depends on which curve is shown; an error message stays. + self.status_message = self._status_hint() + + # ========================================================================= + # Selection + # ========================================================================= + def _is_linescan(self): + """True in line-scan mode, which computes one PDF per position along the line.""" + return self.analysis_mode == "line" and self.line_mode == "linescan" + + def _effective_line(self): + """Ends of the line actually sampled, on scan positions. + + The drawn line, or its perpendicular bisector when line_perpendicular is set (so + tracing along a feature samples across it), with both ends rounded to scan positions + and clipped to the scan, so the band, the line-scan samples and the line's tip all sit + on positions that have data. Mirrors effectiveLineEnds in js/showpdf/linescan.ts. + """ + ends = (self.line_row0, self.line_col0, self.line_row1, self.line_col1) + if self.line_perpendicular: + ends = _perpendicular_bisector(*ends) + r0, c0, r1, c1 = [_round_half_up(v) for v in ends] + return _clip_to_scan(r0, c0, r1, c1, self.scan_rows, self.scan_cols) + + def _line_band_mask(self): + """Boolean mask of the scan positions within line_width / 2 of the sampled segment. + + None when no line is active, the segment has zero length or the band holds no position. + """ + if not self.line_active: + return None + r0, c0, r1, c1 = self._effective_line() + dr, dc = r1 - r0, c1 - c0 + seg_len_sq = dr * dr + dc * dc + if seg_len_sq <= 0: + return None + length = np.sqrt(seg_len_sq) + rows = np.arange(self.scan_rows, dtype=np.float64)[:, None] + cols = np.arange(self.scan_cols, dtype=np.float64)[None, :] + pr = rows - r0 + pc = cols - c0 + # Position along the segment (0 at its start, 1 at its end) and distance from its + # infinite line: t in [0, 1] gives flat ends and the distance test a constant width, + # so the band is a rectangle. + t = (pr * dr + pc * dc) / seg_len_sq + perp = np.abs(pr * dc - pc * dr) / length + half_w = max(0.5, self.line_width / 2.0) + mask = (t >= 0.0) & (t <= 1.0) & (perp <= half_w) + if not mask.any(): + return None + return mask + + def _get_mask(self): + """Boolean mask of the selected scan positions; None leaves calculate_Gr on the full scan.""" + if self.analysis_mode == "line": + return self._line_band_mask() + if self.analysis_mode == "probe": + return self._probe_mask() + return self._painted_mask() + + def _probe_mask(self): + """Square of side 2 * probe_size - 1 around the probe (the brush's sizing), clipped to the scan.""" + half = max(0, self.probe_size - 1) + r0 = max(0, self.probe_row - half) + r1 = min(self.scan_rows, self.probe_row + half + 1) + c0 = max(0, self.probe_col - half) + c1 = min(self.scan_cols, self.probe_col + half + 1) + if r1 <= r0 or c1 <= c0: + return None + mask = np.zeros((self.scan_rows, self.scan_cols), dtype=bool) + mask[r0:r1, c0:c1] = True + return mask + + def _painted_mask(self): + """The painted mask; None when nothing is painted, or all or none of the scan is.""" + if not self.mask_b64: + return None + raw = base64.b64decode(self.mask_b64) + mask = np.frombuffer(raw, dtype=np.uint8).reshape(self.scan_rows, self.scan_cols) + if mask.all(): + return None + bool_mask = mask > 0 + if not bool_mask.any(): + return None + return bool_mask + + def _update_mask_stats(self): + """Sync the number and fraction of the scan positions the analysis averages.""" + mask = self._get_mask() + total = self.scan_rows * self.scan_cols + if mask is not None: + count = int(mask.sum()) + self.mask_pixel_count = count + self.mask_fraction = count / total + elif self._is_linescan(): + # No line drawn: the line scan has no positions and no curves. + self.mask_pixel_count = 0 + self.mask_fraction = 0.0 + elif self.analysis_mode in ("probe", "line") or not self.mask_b64: + # No mask (probe outside the scan, no line, nothing painted): calculate_Gr + # averages the full scan, so report that. + self.mask_pixel_count = total + self.mask_fraction = 1.0 + else: + # A painted mask that selects all or none of the scan, which is no mask. + count = int(np.frombuffer(base64.b64decode(self.mask_b64), dtype=np.uint8).sum()) + self.mask_pixel_count = count if count > 0 else total + self.mask_fraction = count / total if count > 0 else 1.0 + + # ========================================================================= + # Status hints + # ========================================================================= + def _density(self): + """The supplied number density (atoms/ų), or None while it is unset (0).""" + return self.density_value if self.density_value > 0 else None + + def _density_hint(self): + """Status hint when a requested output needs the unset density, else "".""" + if self._density() is not None: + return "" + if self.damp_origin_oscillations: + return _DENSITY_HINT_DAMPING + if self.plot_mode == "gr": + return _DENSITY_HINT_GR + return "" + + def _window_hint(self): + """_WINDOW_HINT when the k window has no positive weight (F(k) and G(r) are zero), else "". + + The window ends resolve as in calculate_Gr (<= 0 follows the fit range) and the + library's own window function weighs its float32 k axis, so this agrees with + calculate_Gr at the edges. + """ + import torch + + params = self._curve_params() + k = torch.from_numpy(np.asarray(self._pdf.qq).astype(np.float32)) + k_min_fit = params["k_min_fit"] if params["k_min_fit"] is not None else float(k.min()) + k_max_fit = params["k_max_fit"] if params["k_max_fit"] is not None else float(k.max()) + k_min = params["k_min_window"] if params["k_min_window"] is not None else k_min_fit + k_max = params["k_max_window"] if params["k_max_window"] is not None else k_max_fit + wk = self._pdf._k_window(k, k_min, k_max) + return "" if bool((wk > 0).any()) else _WINDOW_HINT + + def _status_hint(self): + """The empty-window hint, else the density hint, else ""; an error message beats both.""" + return self._window_hint() or self._density_hint() + + # ========================================================================= + # Curves + # ========================================================================= + def _curve_params(self): + """Keyword arguments for every calculate_Gr call, with the 0.0 sentinels as None. + + The low-r damping needs the density, so it is only requested once density_value is + set; _density_hint reports the skipped damping. + """ + density = self._density() + return dict( + k_min_fit=self.k_min_fit if self.k_min_fit > 0 else None, + k_max_fit=self.k_max_fit if self.k_max_fit > 0 else None, + k_min_window=self.k_min_window if self.k_min_window > 0 else None, + k_max_window=self.k_max_window if self.k_max_window > 0 else None, + k_lowpass=self.k_lowpass if self.k_lowpass > 0 else None, + k_highpass=self.k_highpass if self.k_highpass > 0 else None, + r_max=self.r_max, + r_step=self.r_step, + damp_origin_oscillations=self.damp_origin_oscillations and density is not None, + density=density, + r_cut=self.r_cut, + max_iter=int(self.max_iter), + ) + + def _refresh_pdf(self): + """Recompute this widget's curves on the PDF object before reading them. + + The PairDistributionFunction may be shared (the object passed in, or ``pdf``), so its + results can belong to another selection or parameter set. calculate_Gr reuses the + radial mean of the same mask, so this is cheap. It raises if the current settings + cannot be computed. + """ + params = self._curve_params() + self._pdf.calculate_Gr(mask_realspace=self._get_mask(), **params) + if params["density"] is not None: + self._pdf.calculate_gr( + density=params["density"], r_cut=self.r_cut, max_iter=self.max_iter, + set_pdf_positive=True, + ) + + def _displayed_fk(self): + """F(k) the transform used: the damped one when the low-r correction ran.""" + if self._pdf.Fk_damped is not None: + return self._pdf.Fk_damped + return self._pdf.Fk_masked + + def _displayed_gr(self): + """G(r) to show: the damped one when the low-r correction ran.""" + if self._pdf.reduced_pdf_damped is not None: + return self._pdf.reduced_pdf_damped + return self._pdf._reduced_pdf + + def _recompute_full(self): + """Recompute and sync the curves (or the line-scan stack) of the current selection.""" + if self._is_linescan(): + self._compute_linescan() + return + self.computing = True + self.status_message = "Computing..." + selection = None + try: + mask = self._get_mask() + selection = (self._data_version, None if mask is None else mask.tobytes()) + self._pdf.calculate_Gr(mask_realspace=mask, **self._curve_params()) + self._sync_curves_to_js() + self._curves_selection = selection + # A g(r) error takes precedence over the window and density hints. + self.status_message = self._compute_gr() or self._status_hint() + except Exception as e: + self.status_message = f"Error: {e}" + # Only a parameter change keeps the curves: those of another selection or dataset + # would describe other positions (and a full export would recompute G(r) from + # that I(k)). + if selection != self._curves_selection: + self._clear_curves() + finally: + self.computing = False + + def _clear_curves(self): + """Empty every synced single curve: I(k), B(k), F(k), G(r) and g(r).""" + self._curves_selection = None + self.n_points_ik = 0 + self.ik_x_bytes = b"" + self.ik_y_bytes = b"" + self.ik_bg_y_bytes = b"" + self.n_points_fk = 0 + self.fk_x_bytes = b"" + self.fk_y_bytes = b"" + self.n_points_gr = 0 + self.gr_x_bytes = b"" + self.gr_y_bytes = b"" + self._clear_gr() + + def _compute_gr(self): + """Compute g(r) with calculate_gr (negative values clamped to 0) and sync it. + + g(r) stays empty while density_value is unset, since the density is never estimated. + Returns an error message for the status line, or "" on success. + """ + density = self._density() + if self._pdf._reduced_pdf is None or self._pdf._r is None or density is None: + self._clear_gr() + return "" + try: + self._pdf.calculate_gr( + density=density, r_cut=self.r_cut, max_iter=self.max_iter, set_pdf_positive=True + ) + except Exception as e: + self._clear_gr() + return f"g(r) error: {e}" + r = _float32(self._pdf._r) + gr = _float32(self._pdf._pdf) + self.n_points_pdf = len(r) + self.pdf_x_bytes = r.tobytes() + self.pdf_y_bytes = gr.tobytes() + return "" + + def _clear_gr(self): + """Empty g(r) in JS and on the PDF object, so save_results keeps no g(r) of an earlier density.""" + self._pdf._pdf = None + self.n_points_pdf = 0 + self.pdf_x_bytes = b"" + self.pdf_y_bytes = b"" + + def _sync_curves_to_js(self): + """Send I(k), B(k), F(k) and G(r) of the PDF object to JS.""" + qq = np.asarray(self._pdf.qq, dtype=np.float32) + self.n_points_ik = len(qq) + self.ik_x_bytes = qq.tobytes() + self.ik_y_bytes = _float32(self._pdf.Ik).tobytes() + if self._pdf.bg is not None: + self.ik_bg_y_bytes = _float32(self._pdf.bg).tobytes() + else: + self.ik_bg_y_bytes = b"" + + fk_t = self._displayed_fk() + if fk_t is not None: + fk = _float32(fk_t) + self.n_points_fk = len(fk) + self.fk_x_bytes = qq[: len(fk)].tobytes() + self.fk_y_bytes = fk.tobytes() + else: + self.n_points_fk = 0 + self.fk_x_bytes = b"" + self.fk_y_bytes = b"" + + if self._pdf._r is not None and self._pdf._reduced_pdf is not None: + r = _float32(self._pdf._r) + Gr = _float32(self._displayed_gr()) + self.n_points_gr = len(r) + self.gr_x_bytes = r.tobytes() + self.gr_y_bytes = Gr.tobytes() + else: + self.n_points_gr = 0 + self.gr_x_bytes = b"" + self.gr_y_bytes = b"" + + # ========================================================================= + # Line scan + # ========================================================================= + def _extract_plot_curve(self): + """(y, x) float32 of the plot_mode curve from the computed PDF state. + + (None, None) if that curve has not been computed, or for g(r) without a density. + """ + mode = self.plot_mode + if mode == "Ik": + x = np.asarray(self._pdf.qq, dtype=np.float32) + y = _float32(self._pdf.Ik) + elif mode == "Fk": + fk = self._displayed_fk() + if fk is None: + return None, None + qq = np.asarray(self._pdf.qq, dtype=np.float32) + y = _float32(fk) + x = qq[: len(y)] + elif mode == "gr": + density = self._density() + if self._pdf._reduced_pdf is None or self._pdf._r is None or density is None: + return None, None + self._pdf.calculate_gr( + density=density, r_cut=self.r_cut, max_iter=self.max_iter, set_pdf_positive=True + ) + x = _float32(self._pdf._r) + y = _float32(self._pdf._pdf) + else: # "Gr" + if self._pdf._r is None or self._pdf._reduced_pdf is None: + return None, None + x = _float32(self._pdf._r) + y = _float32(self._displayed_gr()) + return y, x + + def _clear_linescan(self): + """Empty the synced line-scan stack.""" + self.n_linescan = 0 + self.linescan_n_points = 0 + self.linescan_bytes = b"" + self.linescan_axis_bytes = b"" + + def _linescan_samples(self): + """Evenly spaced samples along the line, and the sample each band position averages into. + + The samples run from the line start A (position 0) to its tip B (position L), one per + scan step along the line's longer axis (capped by linescan_max_bins). The sampled line + ends on scan positions (see _effective_line); without the cap no sample is empty. On a + diagonal line the samples fall between scan positions. Each band position goes to its + nearest sample; the two end samples cover half a step, because the band stops at A and + B. Returns None without a line, else ``(band, sample_idx, n_samples, length, positions)``. + """ + band = self._line_band_mask() + if band is None: + return None + r0, c0, r1, c1 = self._effective_line() + dr, dc = r1 - r0, c1 - c0 + seg_len_sq = dr * dr + dc * dc + length = float(np.sqrt(seg_len_sq)) + n_steps = round(max(abs(dr), abs(dc))) + n = max(1, min(self.linescan_max_bins, n_steps + 1)) + + rows = np.arange(self.scan_rows, dtype=np.float64)[:, None] + cols = np.arange(self.scan_cols, dtype=np.float64)[None, :] + t = np.clip(((rows - r0) * dr + (cols - c0) * dc) / seg_len_sq, 0.0, 1.0) + if n > 1: + # Nearest sample. The offset is snapped to 1e-9 so exact ties are not split by + # rounding error, then floor(x + 0.5) as in the frontend. + x = t * (n - 1) + x = np.floor(x * 1e9 + 0.5) / 1e9 + sample_idx = np.minimum(np.floor(x + 0.5), n - 1).astype(int) + positions = np.arange(n, dtype=np.float64) * (length / (n - 1)) + else: + sample_idx = np.zeros(t.shape, dtype=int) + positions = np.array([length / 2.0]) + return band, sample_idx, n, length, positions + + def _linescan_bin_radial_means(self, band, bin_idx, n_bins): + """Radial means I(k) of all line-scan samples in one chunked pass over the polar data. + + Equivalent to calculate_radial_mean per sample mask (same read path and output dtype), + but reads the 4D polar array once instead of n_bins times. Returns (Ik_all, valid_bins): + an (n_valid, Nk) float32 tensor on the PDF device and the matching non-empty samples. + """ + import torch + + pol = self._pdf.polar + if pol.array is None: + polar = pol.tensor + else: + polar = torch.from_numpy(np.ascontiguousarray(pol.array)) + n_rows, n_k = polar.shape[0], polar.shape[3] # polar is (scan row, scan col, phi, k) + device = self._pdf.device + labels = np.where(band, bin_idx, -1).astype(np.int64) # sample of each position, -1 outside + sums = torch.zeros(n_bins, n_k, dtype=torch.float64, device=device) + counts = torch.zeros(n_bins, dtype=torch.float64, device=device) + chunk = 16 + for row0 in range(0, n_rows, chunk): + row1 = min(row0 + chunk, n_rows) + arr = polar[row0:row1].to(device) + # Mean over phi, scan positions flattened, accumulated into their samples. + rad = arr.mean(dim=2).reshape(-1, n_k).double() + lbl = torch.as_tensor(labels[row0:row1].reshape(-1), device=device) + valid = lbl >= 0 + if bool(valid.any()): + v = lbl[valid] + sums.index_add_(0, v, rad[valid]) + ones = torch.ones(int(valid.sum()), dtype=torch.float64, device=device) + counts.index_add_(0, v, ones) + nonempty = counts > 0 + valid_bins = torch.nonzero(nonempty, as_tuple=False).flatten().tolist() + Ik_all = (sums[nonempty] / counts[nonempty][:, None]).float() + return Ik_all, valid_bins + + def _calculate_Gr_for_bin(self, Ik, bin_mask, params): + """calculate_Gr for one line-scan sample from its precomputed radial mean. + + The I(k) is cached under the sample's mask, which calculate_Gr's cache check accepts, + so the PDF object ends up as calculate_Gr(mask_realspace=bin_mask) would leave it. + """ + self._pdf.Ik = Ik + self._pdf._Ik_mask = bin_mask + self._pdf.calculate_Gr(mask_realspace=bin_mask, **params) + + def _compute_linescan(self): + """Compute and sync the plot_mode curve of every line-scan sample (see _linescan_samples). + + The radial means of all samples come from one pass over the polar data; each sample then + runs calculate_Gr exactly as a single masked computation would. The curves are stacked + into linescan_bytes, (n_linescan, linescan_n_points). + """ + self.computing = True + self.status_message = "Computing line-scan..." + try: + samples = self._linescan_samples() + if samples is None: + self._clear_linescan() + self.status_message = self._status_hint() + return + band, bin_idx, n_bins, _length, _positions = samples + + Ik_all, valid_bins = self._linescan_bin_radial_means(band, bin_idx, n_bins) + if not valid_bins: + self._clear_linescan() + self.status_message = "" + return + params = self._curve_params() + + stack = None + axis = None + for j, bin_i in enumerate(valid_bins): + self._calculate_Gr_for_bin(Ik_all[j], band & (bin_idx == bin_i), params) + y, x = self._extract_plot_curve() + if y is None: + continue + if stack is None: + axis = x + stack = _nan_stack(n_bins, len(y)) + _fill_row(stack, bin_i, y) + + if stack is None: + # Nothing to stack, e.g. g(r) without a density. + self._clear_linescan() + self.status_message = self._status_hint() + return + self.n_linescan = n_bins + self.linescan_n_points = stack.shape[1] + self.linescan_bytes = stack.tobytes() + self.linescan_axis_bytes = axis.tobytes() + self.status_message = self._status_hint() + except Exception as e: + self.status_message = f"Line-scan error: {e}" + self._clear_linescan() + finally: + self.computing = False + + def _compute_linescan_all_modes(self): + """Per-sample I(k), B(k), F(k), G(r) and g(r) along the line, for save_results. + + Runs the samples as _compute_linescan does but keeps every curve, and leaves the synced + single-mode stack alone. Returns None without a line or a non-empty sample, else a dict + of NaN-filled (n_bins, n) stacks and their axes. + """ + samples = self._linescan_samples() + if samples is None: + return None + band, bin_idx, n_bins, length, positions = samples + + Ik_all, valid_bins = self._linescan_bin_radial_means(band, bin_idx, n_bins) + if not valid_bins: + return None + params = self._curve_params() + density = params["density"] + + k_axis = np.asarray(self._pdf.qq, dtype=np.float32) + Ik_stack = _nan_stack(n_bins, k_axis.size) + bg_stack = _nan_stack(n_bins, k_axis.size) + # The F(k), G(r) and g(r) stacks are sized by the first sample that has the curve. + Fk_stack = None + Gr_stack = None + gr_stack = None + k_fk_axis = None + r_axis = None + + for j, bin_i in enumerate(valid_bins): + self._calculate_Gr_for_bin(Ik_all[j], band & (bin_idx == bin_i), params) + Ik_stack[bin_i] = _float32(self._pdf.Ik) + bg_stack[bin_i] = _float32(self._pdf.bg) + + fk_t = self._displayed_fk() + if fk_t is not None: + fk = _float32(fk_t) + if Fk_stack is None: + k_fk_axis = k_axis[: fk.size] + Fk_stack = _nan_stack(n_bins, fk.size) + _fill_row(Fk_stack, bin_i, fk) + + if self._pdf._r is not None and self._pdf._reduced_pdf is not None: + Gr = _float32(self._displayed_gr()) + if Gr_stack is None: + r_axis = _float32(self._pdf._r) + Gr_stack = _nan_stack(n_bins, r_axis.size) + _fill_row(Gr_stack, bin_i, Gr) + + if density is not None: + self._pdf.calculate_gr( + density=density, r_cut=self.r_cut, max_iter=self.max_iter, + set_pdf_positive=True, + ) + gr = _float32(self._pdf._pdf) + if gr_stack is None: + gr_stack = _nan_stack(n_bins, Gr_stack.shape[1]) + _fill_row(gr_stack, bin_i, gr) + + # Sample positions along the line in scan pixels (evenly spaced, from A). + bin_positions_px = positions.astype(np.float32) + + return { + "n_bins": n_bins, + "bin_positions_px": bin_positions_px, + "line_length_px": length, + "k": k_axis, + "Ik": Ik_stack, + "bg": bg_stack, + "k_fk": k_fk_axis if k_fk_axis is not None else np.zeros(0, dtype=np.float32), + "Fk": Fk_stack if Fk_stack is not None else np.zeros((n_bins, 0), dtype=np.float32), + "r": r_axis if r_axis is not None else np.zeros(0, dtype=np.float32), + "Gr": Gr_stack if Gr_stack is not None else np.zeros((n_bins, 0), dtype=np.float32), + "gr": gr_stack, # None without a density + } + + # ========================================================================= + # Public API + # ========================================================================= + @property + def pdf(self): + """The PairDistributionFunction the curves are computed with.""" + return self._pdf + + @property + def mask(self) -> np.ndarray: + """Boolean mask of the selected scan positions (all True for the full scan).""" + m = self._get_mask() + if m is None: + return np.ones((self.scan_rows, self.scan_cols), dtype=bool) + return m + + def set_mask(self, mask) -> Self: + """Set the painted scan mask and switch to mask mode. + + ``mask`` is a boolean array of shape ``(scan_rows, scan_cols)``; True + includes a scan position. + """ + mask_np = to_numpy(mask).astype(bool) + if mask_np.shape != (self.scan_rows, self.scan_cols): + raise ValueError( + f"Mask shape {mask_np.shape} does not match scan shape " + f"({self.scan_rows}, {self.scan_cols})" + ) + raw = mask_np.astype(np.uint8).tobytes() + self._initializing = True + try: + self.mask_bytes = raw + # mask_b64 is what _get_mask() reads and the canvas draws. + self.mask_b64 = base64.b64encode(raw).decode("ascii") + self.analysis_mode = "mask" + finally: + self._initializing = False + self._on_mask_change() + return self + + def clear_mask(self) -> Self: + """Clear the painted mask, which selects the full scan.""" + self.mask_bytes = b"" + self.mask_b64 = "" + self._on_mask_change() + return self + + def set_line( + self, + row0: float, + col0: float, + row1: float, + col1: float, + width: int | None = None, + ) -> Self: + """Set the line-band selection and switch to line mode. + + Endpoints are in scan (row, col) coordinates; the sampled line's ends + snap to the nearest scan positions. ``width`` is the band thickness, an + odd number of scan pixels (1, 3, 5, ...): a horizontal or vertical band + of width w covers exactly w rows or columns (clipped at the scan edge). + When omitted the current ``line_width`` is kept. A width that is not an + odd integer raises ``TraitError`` and an endpoint that is NaN, infinite + or beyond ±1e9 raises ``ValueError``; either leaves the widget unchanged. + """ + # Check every argument first, so a bad one raises before anything changes. + row0 = _line_end_float("row0", row0) + col0 = _line_end_float("col0", col0) + row1 = _line_end_float("row1", row1) + col1 = _line_end_float("col1", col1) + if width is not None: + width = _line_width_int(width) + self._initializing = True + try: + # Width first, so a rejected width raises before anything changes. + if width is not None: + self.line_width = width + self.line_row0 = row0 + self.line_col0 = col0 + self.line_row1 = row1 + self.line_col1 = col1 + self.line_active = True + self.analysis_mode = "line" + finally: + self._initializing = False + self._on_line_change() + return self + + def clear_line(self) -> Self: + """Remove the line selection.""" + self._initializing = True + try: + self.line_active = False + self.line_row0 = self.line_col0 = self.line_row1 = self.line_col1 = 0.0 + finally: + self._initializing = False + self._on_line_change() + return self + + def set_data( + self, data, *, nav_image=None, find_origin=True, pixel_size=None, **pdf_kwargs + ) -> Self: + """Replace the dataset, keeping the PDF parameters where they still fit. + + Accepts the same inputs as the constructor; ``pdf_kwargs`` go to + ``PairDistributionFunction.from_data`` (``device`` defaults to + ``"cpu"`` as in the constructor). The painted mask, line and probe + are reset because they refer to the old scan. A fit range that spans + the old data's full k range spans the new data's full range too. A + narrower fit range and a set window are kept if they lie inside the + new k range; otherwise the fit range is reset to the full range and the + window to 0 (follow the fit range). ``pixel_size`` overrides the scan + calibration as in the constructor; without it, an override given to + the constructor (or an earlier ``set_data``) stays in effect. + """ + pixel_size = _finite_pixel_size(pixel_size) # raises before anything changes + pdf, _title = _load_pdf(data, find_origin=find_origin, **pdf_kwargs) + qq = _finite_k_axis(pdf) # raises before anything changes + self._pdf = pdf + self._reset_pdf_cache() + self._data_version += 1 + + # Update traits without triggering one recompute per trait. + self._initializing = True + try: + polar_shape = self._pdf.polar.shape + self.scan_rows = int(polar_shape[0]) + self.scan_cols = int(polar_shape[1]) + was_full_range = (self.k_min_fit, self.k_max_fit) == ( + self.k_min_available, self.k_max_available + ) + self.k_min_available = float(qq[0]) + self.k_max_available = float(qq[-1]) + self._carry_over_k_ranges(was_full_range) + + # Selections refer to the old scan grid. + self.mask_bytes = b"" + self.mask_b64 = "" + self.probe_row = self.scan_rows // 2 + self.probe_col = self.scan_cols // 2 + self.line_active = False + self.line_row0 = self.line_col0 = self.line_row1 = self.line_col1 = 0.0 + self._clear_linescan() + finally: + self._initializing = False + + self._set_nav_image(nav_image, pixel_size) + self._update_mask_stats() + self._recompute_full() + return self + + # ========================================================================= + # Saved-notebook preview + # ========================================================================= + def _static_png_b64(self, max_px: int = 512) -> str | None: + """The scan image beside the shown curve, or line-scan stack, as a base64 PNG max_px wide. + + A notebook saved with save_state=False keeps this picture instead of the scan image, mask + and stack. It draws the synced traits and never recomputes; None while there is no data. + """ + if getattr(self, "_pdf", None) is None or not self.nav_image_bytes: + return None + # A standalone Figure renders off screen without switching the notebook's backend. + from matplotlib.backends.backend_agg import FigureCanvasAgg + from matplotlib.figure import Figure + + fig = Figure(figsize=(max_px / 100, max_px / 250), dpi=100) + FigureCanvasAgg(fig) + nav_ax, curve_ax = fig.subplots(1, 2, gridspec_kw={"width_ratios": (1, 2)}) + nav = self._nav_image + finite = nav[np.isfinite(nav)] + vmin, vmax = np.percentile(finite, (2, 98)) if finite.size and self.auto_contrast else (None, None) + nav_ax.imshow(nav, cmap=self.cmap, vmin=vmin, vmax=vmax) + nav_ax.set_axis_off() + xlabel, ylabel = _CURVE_AXIS_LABELS[self.plot_mode] + if self._is_linescan() and self.n_linescan: + stack = np.frombuffer(self.linescan_bytes, np.float32).reshape(self.n_linescan, -1) + axis = np.frombuffer(self.linescan_axis_bytes, np.float32) + extent = (axis[0], axis[-1], 0, self.n_linescan - 1) + curve_ax.imshow(stack, aspect="auto", origin="lower", cmap="inferno", extent=extent) + ylabel = "line position" + else: + x_trait, y_trait = _CURVE_TRAITS[self.plot_mode] + x = np.frombuffer(getattr(self, x_trait), np.float32) + y = np.frombuffer(getattr(self, y_trait), np.float32) + curve_ax.plot(x, y, color="steelblue", linewidth=1.0) + if not y.size: # g(r) without a density, or a failed compute + curve_ax.text(0.5, 0.5, self.status_message, ha="center", va="center", transform=curve_ax.transAxes) + curve_ax.set_xlabel(xlabel) + curve_ax.set_ylabel(ylabel) + if self.title: + fig.suptitle(self.title, fontsize=9) + fig.tight_layout() + buffer = io.BytesIO() + fig.savefig(buffer, format="png") + return base64.b64encode(buffer.getvalue()).decode("ascii") + + def _invalidate_preview(self, change=None): + """Drop the stored preview, so the next notebook save draws the current view.""" + self._static_fallback_jpeg = "" + + # ========================================================================= + # Saving results + # ========================================================================= + def save_image( + self, + path: str | pathlib.Path, + *, + plot_mode: str | None = None, + format: str | None = None, + dpi: int = 150, + ) -> pathlib.Path: + """Save the current PDF curve as PNG, PDF, or TIFF. + + Parameters + ---------- + path : str or pathlib.Path + Output file path. + plot_mode : {"Ik", "Fk", "Gr", "gr"}, optional + Curve to render. Defaults to the widget's current ``plot_mode``. + format : {"png", "pdf", "tiff"}, optional + Inferred from the file extension when omitted. + dpi : int, default 150 + Output resolution. + + Returns + ------- + pathlib.Path + The written file path. + """ + # A standalone Figure renders off screen without switching the notebook's + # matplotlib backend (matplotlib.use("Agg") would). + from matplotlib.backends.backend_agg import FigureCanvasAgg + from matplotlib.figure import Figure + + path = pathlib.Path(path) + fmt = (format or path.suffix.lstrip(".").lower() or "png").lower() + if fmt == "tif": + fmt = "tiff" + if fmt not in ("png", "pdf", "tiff"): + raise ValueError(f"Unsupported format: {fmt!r}. Use 'png', 'pdf', or 'tiff'.") + + mode = plot_mode if plot_mode is not None else self.plot_mode + if mode not in ("Ik", "Fk", "Gr", "gr"): + raise ValueError(f"Unknown plot_mode: {mode!r}. Use 'Ik', 'Fk', 'Gr', or 'gr'.") + if self._is_linescan(): + raise ValueError( + "save_image saves one curve; a line scan has one per position. " + "Use save_results, or line_mode='averaged' for the band average." + ) + + self._refresh_pdf() + bg = None + if mode == "Ik": + x = np.asarray(self._pdf.qq, dtype=np.float32) + y = _float32(self._pdf.Ik) + if self._pdf.bg is not None and self.show_background: + bg = _float32(self._pdf.bg) + elif mode == "Fk": + y = _float32(self._displayed_fk()) + x = np.asarray(self._pdf.qq, dtype=np.float32)[: len(y)] + elif mode == "Gr": + x = _float32(self._pdf._r) + y = _float32(self._displayed_gr()) + else: # "gr" + if self._pdf._pdf is None: + raise ValueError("g(r) is not computed yet: set density_value (atoms/ų).") + x = _float32(self._pdf._r) + y = _float32(self._pdf._pdf) + xlabel, ylabel = _CURVE_AXIS_LABELS[mode] + + fig = Figure(figsize=(6, 3.5), dpi=dpi) + FigureCanvasAgg(fig) + ax = fig.add_subplot() + ax.plot(x, y, color="steelblue", linewidth=1.0, label=ylabel) + if bg is not None: + ax.plot(x, bg, color="orange", linewidth=1.0, linestyle="--", label="B(k)") + ax.legend(fontsize=8, framealpha=0.8) + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + if self.title: + ax.set_title(self.title, fontsize=11) + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(path), format=fmt, dpi=dpi, bbox_inches="tight") + return path + + def save_results( + self, + path: str | pathlib.Path, + *, + overwrite: bool = False, + ) -> pathlib.Path: + """Save the current PDF results to a self-contained zip file. + + The zip holds a plain-text README, a plain-text metadata file and CSV + curve files; only the mask (mask mode) is saved as ``mask.npy``, since a + scan-shaped boolean CSV is unwieldy. + + Layout (always present): + README.txt + metadata.txt + curves_k.csv k, I(k), B(k), F(k) (selected region) + curves_r.csv r, G(r), g(r) (g(r) only if density_value is set) + + Extra files by mode: + mask mode: mask.npy + line scan: linescan_position.csv, linescan_Ik.csv, + linescan_Ik_bg.csv, linescan_Fk.csv, + linescan_Gr.csv, linescan_gr.csv (if g(r) computed) + + Parameters + ---------- + path : str or pathlib.Path + Output zip path. Should end in ``.zip``; the extension is added if + missing. + overwrite : bool, default False + If False and the file already exists, raises FileExistsError. + + Returns + ------- + pathlib.Path + The written zip path. + """ + path = pathlib.Path(path) + if path.suffix.lower() != ".zip": + path = path.with_suffix(path.suffix + ".zip" if path.suffix else ".zip") + if path.exists() and not overwrite: + raise FileExistsError(f"{path} already exists; pass overwrite=True to replace it.") + + is_linescan = self._is_linescan() + linescan = None + if is_linescan: + linescan = self._compute_linescan_all_modes() + if linescan is None: + raise RuntimeError( + "Line scan has no valid bins yet — draw a line and let the " + "widget compute before saving." + ) + # Recompute the selection first, since the PDF object may be shared. In a line scan the + # curves_*.csv files hold the band average, and its I(k) stays cached under the band + # mask, so the next recompute stays correct. + self._refresh_pdf() + k, Ik, bg, Fk, r, Gr, gr = self._extract_1d_curves() + metadata = self._results_metadata(linescan) + + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("metadata.txt", _format_metadata_txt(metadata)) + zf.writestr("curves_k.csv", _columns_to_csv(["k", "Ik", "bg", "Fk"], [k, Ik, bg, Fk])) + zf.writestr("curves_r.csv", _columns_to_csv(["r", "Gr", "gr"], [r, Gr, gr])) + + if self.analysis_mode == "mask": + buf = io.BytesIO() + np.save(buf, self.mask, allow_pickle=False) + zf.writestr("mask.npy", buf.getvalue()) + + if linescan is not None: + k_axis, k_fk_axis, r_axis = linescan["k"], linescan["k_fk"], linescan["r"] + zf.writestr("linescan_position.csv", _positions_to_csv(linescan["bin_positions_px"])) + zf.writestr("linescan_Ik.csv", _stack_to_csv_with_axis(k_axis, linescan["Ik"])) + zf.writestr("linescan_Ik_bg.csv", _stack_to_csv_with_axis(k_axis, linescan["bg"])) + if linescan["Fk"].size: + zf.writestr("linescan_Fk.csv", _stack_to_csv_with_axis(k_fk_axis, linescan["Fk"])) + if linescan["Gr"].size: + zf.writestr("linescan_Gr.csv", _stack_to_csv_with_axis(r_axis, linescan["Gr"])) + if linescan["gr"] is not None and linescan["gr"].size: + zf.writestr("linescan_gr.csv", _stack_to_csv_with_axis(r_axis, linescan["gr"])) + + damped = self.damp_origin_oscillations and self._density() is not None + zf.writestr("README.txt", _build_readme(is_linescan, self.analysis_mode, damped)) + + return path.resolve() + + def _extract_1d_curves(self): + """(k, I(k), B(k), F(k), r, G(r), g(r)) as float32 arrays; a curve not computed is empty. + + F(k) and G(r) are the curves the transform used (damped when the low-r correction + ran); g(r) is empty without a density. + """ + empty = np.zeros(0, dtype=np.float32) + k = np.asarray(self._pdf.qq, dtype=np.float32) + Ik = _float32(self._pdf.Ik) if self._pdf.Ik is not None else empty + bg = _float32(self._pdf.bg) if self._pdf.bg is not None else empty + fk_t = self._displayed_fk() + Fk = _float32(fk_t) if fk_t is not None else empty + if self._pdf._r is not None and self._pdf._reduced_pdf is not None: + r = _float32(self._pdf._r) + Gr = _float32(self._displayed_gr()) + else: + r = empty + Gr = empty + if self._pdf._pdf is not None and r.size: + gr = _float32(self._pdf._pdf) + else: + gr = empty + return k, Ik, bg, Fk, r, Gr, gr + + def _results_metadata(self, linescan): + """The metadata.txt content of save_results: mode, selection and fit parameters.""" + metadata = { + "widget_version": self.widget_version, + "saved_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "title": self.title, + "scan_shape": [self.scan_rows, self.scan_cols], + "analysis_mode": self.analysis_mode, + "line_mode": self.line_mode if self.analysis_mode == "line" else None, + "plot_mode_at_save": self.plot_mode, + "region": self._region_metadata(), + "fit_parameters": { + "k_min_fit": self.k_min_fit, + "k_max_fit": self.k_max_fit, + "k_min_window": self.k_min_window, + "k_max_window": self.k_max_window, + "k_lowpass": self.k_lowpass, + "k_highpass": self.k_highpass, + "r_max": self.r_max, + "r_step": self.r_step, + "damp_origin_oscillations": self.damp_origin_oscillations, + "r_cut": self.r_cut, + "max_iter": int(self.max_iter), + "density_value": self.density_value, + }, + } + if linescan is not None: + metadata["region"]["n_linescan_bins"] = linescan["n_bins"] + metadata["region"]["line_length_px"] = linescan["line_length_px"] + return metadata + + def _region_metadata(self): + """The selected scan positions, for metadata.txt.""" + if self.analysis_mode == "mask": + return { + "kind": "mask", + "mask_pixel_count": self.mask_pixel_count, + "mask_fraction": self.mask_fraction, + "mask_file": "mask.npy", + } + if self.analysis_mode == "probe": + return { + "kind": "probe", + "probe_row": int(self.probe_row), + "probe_col": int(self.probe_col), + "probe_size": int(self.probe_size), + "mask_pixel_count": self.mask_pixel_count, + "mask_fraction": self.mask_fraction, + } + if self.analysis_mode == "line": + return { + "kind": "line", + "line_row0": self.line_row0, + "line_col0": self.line_col0, + "line_row1": self.line_row1, + "line_col1": self.line_col1, + "line_width": self.line_width, + "line_perpendicular": self.line_perpendicular, + "sampled_line": list(self._effective_line()), + "mask_pixel_count": self.mask_pixel_count, + "mask_fraction": self.mask_fraction, + } + return {"kind": self.analysis_mode} + + # ========================================================================= + # State persistence + # ========================================================================= + def state_dict(self) -> dict: + """The parameters, selection and display settings, as restored by load_state_dict.""" + return { + "title": self.title, + "k_min_fit": self.k_min_fit, + "k_max_fit": self.k_max_fit, + "k_min_window": self.k_min_window, + "k_max_window": self.k_max_window, + "k_lowpass": self.k_lowpass, + "k_highpass": self.k_highpass, + "r_max": self.r_max, + "r_step": self.r_step, + "damp_origin_oscillations": self.damp_origin_oscillations, + "r_cut": self.r_cut, + "max_iter": self.max_iter, + "density_value": self.density_value, + "analysis_mode": self.analysis_mode, + "probe_row": self.probe_row, + "probe_col": self.probe_col, + "probe_size": self.probe_size, + "line_row0": self.line_row0, + "line_col0": self.line_col0, + "line_row1": self.line_row1, + "line_col1": self.line_col1, + "line_width": self.line_width, + "line_active": self.line_active, + "line_mode": self.line_mode, + "line_perpendicular": self.line_perpendicular, + "linescan_max_bins": self.linescan_max_bins, + "linescan_view": self.linescan_view, + "waterfall_count": self.waterfall_count, + # The painted mask (base64 uint8, scan_rows * scan_cols); "" means the full scan. + "mask_b64": self.mask_b64, + "plot_mode": self.plot_mode, + "show_background": self.show_background, + "cmap": self.cmap, + "log_scale": self.log_scale, + "auto_contrast": self.auto_contrast, + "show_stats": self.show_stats, + "show_controls": self.show_controls, + } + + def load_state_dict(self, state: dict) -> None: + """Restore a state dict from state_dict and recompute; unknown keys are ignored.""" + state, width_notice = _migrate_state(state) + self._initializing = True + try: + # All or nothing: when a value is rejected with TraitError, + # hold_trait_notifications rolls back every value set before it. + with self.hold_trait_notifications(): + allowed_keys = set(self.state_dict().keys()) + for key, val in state.items(): + if key in allowed_keys and hasattr(self, key): + try: + setattr(self, key, val) + except OverflowError as e: + # An int too large for a Float trait: reject it as + # TraitError too, so the rollback applies. + raise traitlets.TraitError(f"{key}: {e}") from e + self._restore_mask_bytes() + finally: + # A rejected value raises; the widget must still respond to later changes. + self._initializing = False + if width_notice: # only now: a rejected load leaves the width unchanged + warnings.warn(width_notice, UserWarning, stacklevel=2) + self._update_mask_stats() + self._recompute_full() + + def _restore_mask_bytes(self): + """Mirror a restored mask_b64 into mask_bytes, or drop a mask saved for another scan shape.""" + if not self.mask_b64: + return + try: + raw = base64.b64decode(self.mask_b64) + except Exception: + raw = b"" + if len(raw) == self.scan_rows * self.scan_cols: + self.mask_bytes = raw + else: + self.mask_b64 = "" + self.mask_bytes = b"" + + # ========================================================================= + # Standalone interactive HTML export + # ========================================================================= + def export_html( + self, + path: str | pathlib.Path | None = None, + *, + title: str | None = None, + mode: str = "single", + encoding: str = "full", + downsample: int | None = None, + ) -> pathlib.Path: + """Write a standalone interactive HTML viewer for this widget. + + The file mounts the widget's JS bundle with its current state (the + navigation image, painted mask, all curves, any line-scan stack and + every setting), so it opens in any browser with no Jupyter kernel. The + k-space, damping and density controls recompute the curves in the + browser from the exported I(k). A line-scan stack needs the scan + profiles to be recomputed, so with ``encoding="full"`` its controls + are locked. A ``"full"`` export made when no curves were computed for + the shown selection (its compute failed) keeps its parameter controls + locked too and shows the baked error. A float trait that is NaN or + infinite, which is not valid JSON, raises ``ValueError`` and no file + is written. + + Only the synced traits are embedded, never the raw 4D-STEM dataset: + ``encoding="full"`` holds the reduced products (a few MB) and + ``encoding="uint8"`` adds one byte per scan position and k bin. + + Parameters + ---------- + path : str or pathlib.Path, optional + Destination HTML path. Defaults to the current working directory + with the widget title, scan shape and encoding ("exact" or + "quantized") in the name. + title : str, optional + Browser page title. Defaults to the widget ``title`` or "ShowPDF". + mode : {"single"}, default "single" + File layout. Only a single self-contained file is supported. + encoding : {"full", "uint8"}, default "full" + ``"full"`` embeds the float32 curves only; the selection is fixed. + ``"uint8"`` also embeds every scan position's radial profile, + quantized to 8 bits per k bin, so the mask, probe and line can be + changed in the exported file. + downsample : None + Not supported; accepted for the shared export interface. + + Returns + ------- + pathlib.Path + The written HTML file. + """ + encoding = _export_encoding(mode, encoding, downsample) + export_path = ( + pathlib.Path(path) if path is not None else self._default_html_export_path(encoding=encoding) + ) + self._write_html_export(export_path, title=title, encoding=encoding) + size_mb = export_path.stat().st_size / (1024 * 1024) + self.export_status = ( + f"Exported {export_path.name} ({size_mb:.1f} MB, {self._export_mode_label(encoding)})" + ) + return export_path + + def _html_export_options(self, payload: dict, mode: str) -> dict: + """``export_html`` keywords from a toolbar request: the encoding.""" + encoding = payload.get("encoding") or "full" + return {"encoding": _export_encoding(mode, encoding, payload.get("downsample"))} + + def _export_mode_label(self, encoding: str = "full") -> str: + """What the page holds, for the toolbar status line.""" + return _export_label(encoding == "uint8") + + def _default_html_export_path(self, *, encoding: str = "full") -> pathlib.Path: + """``<title>_<rows>x<cols>_<exact|quantized>.html`` in the working directory.""" + kind = "quantized" if encoding == "uint8" else "exact" + slug = export_slug(self.title, "showpdf") + return pathlib.Path.cwd() / f"{slug}_{self.scan_rows}x{self.scan_cols}_{kind}.html" + + def _write_html_export( + self, + path: str | pathlib.Path, + *, + title: str | None = None, + encoding: str = "full", + ) -> pathlib.Path: + """Embed this widget's current state into a self-contained HTML file. + + ``self`` is embedded directly (no clone), so the multi-GB source data is never + recomputed, and only the serialized state is edited, never the live traits: a + temporary ``self.export_enabled = False`` would race the restore over the comm and + could leave the live Export HTML button hidden in JupyterLab. + """ + from ipywidgets.embed import dependency_state, embed_minimal_html + + export_path = pathlib.Path(path) + page_title = title or self.title or "ShowPDF" + # The page needs the scan image, mask and stack that a saved notebook leaves out. + save_state, self._save_state = self._save_state, True + try: + state = dependency_state([self], drop_defaults=False) + finally: + self._save_state = save_state + model_entry = state.get(self.model_id, {}) + if isinstance(model_entry.get("state"), dict): + self._bake_standalone_state(model_entry, profiles=encoding == "uint8") + # Only now, so a refused export leaves no new directories behind. + export_path.parent.mkdir(parents=True, exist_ok=True) + embed_minimal_html( + str(export_path), + views=[self], + title=page_title, + drop_defaults=False, + state=state, + ) + ensure_mobile_viewport(export_path) + return export_path + + def _bake_standalone_state(self, model_entry, profiles): + """Edit this widget's serialized state into the standalone page's. + + The page has no kernel, so its export button is disabled and no export request, + status or payload is kept (a previous export's HTML must not be nested in this one). + It gets the exact k axis and, with ``profiles``, the quantized per-position radial + profiles that let it recompute mask, probe and line selections. + """ + model_state = model_entry["state"] + _refuse_non_finite_floats(model_state) + model_state["export_enabled"] = False + model_state["export_request"] = "" + model_state["export_status"] = "" + model_state["export_payload_id"] = "" + model_state["export_filename"] = "" + model_state["_static_fallback_jpeg"] = "" # the page shows the widget, not a saved picture + model_state["offline"] = True + replaced = {"export_payload", "offline_qq"} + if profiles: + replaced |= {"offline_profiles", "offline_profiles_lo", "offline_profiles_hi"} + # Bytes traits are serialized as buffers; export_payload stays as an empty buffer so + # the frontend still sees a (zero-length) value. + buffers = [ + b for b in model_entry.get("buffers", []) + if (b.get("path") or [None])[0] not in replaced + ] + buffers.append(_base64_buffer("export_payload", b"")) + # The exact float64 k axis: the browser recompute must select the same k bins as the + # library (the synced curve axes are float32). + qq = np.asarray(self._pdf.qq, dtype=np.float64).tobytes() + buffers.append(_base64_buffer("offline_qq", qq)) + if profiles: + q, lo, hi, rows, cols, nk = self._offline_profiles() + model_state["offline_recompute"] = True + model_state["offline_nk"] = nk + model_state["offline_rows"] = rows + model_state["offline_cols"] = cols + buffers.append(_base64_buffer("offline_profiles", q)) + buffers.append(_base64_buffer("offline_profiles_lo", lo)) + buffers.append(_base64_buffer("offline_profiles_hi", hi)) + model_entry["buffers"] = buffers + + def _offline_profiles(self): + """Per-position radial profiles (mean over phi), quantized to uint8 per k bin. + + Each k bin keeps its own min and max, so the high-k tail is not crushed by the + central beam. Averaging the profiles over the selected positions reproduces + calculate_radial_mean, which lets the standalone page recompute I(k) for any + selection. Returns (q_uint8, lo_f32, hi_f32, rows, cols, nk), the arrays as bytes. + """ + pol = self._pdf.polar + arr = pol.array if pol.array is not None else to_numpy(pol.tensor) + arr = np.asarray(arr, dtype=np.float32) # (rows, cols, phi, k) + profiles = arr.mean(axis=2) # (rows, cols, k) + rows, cols, nk = profiles.shape + flat = profiles.reshape(rows * cols, nk) + lo = flat.min(axis=0) + hi = flat.max(axis=0) + rng = np.where(hi > lo, hi - lo, np.float32(1.0)) + q = np.clip(np.round((flat - lo) / rng * 255.0), 0, 255).astype(np.uint8) + return q.tobytes(), lo.tobytes(), hi.tobytes(), rows, cols, nk + + # ========================================================================= + # Summary + # ========================================================================= + def summary(self) -> None: + """Print the scan, k ranges, selection and density settings.""" + name = self.title or "ShowPDF" + lines = [name, "═" * 32] + lines.append(f"Scan: {self.scan_rows} × {self.scan_cols}") + lines.append(f"k range: [{self.k_min_available:.2f}, {self.k_max_available:.2f}] Å⁻¹") + lines.append(f"Fit: k=[{self.k_min_fit:.2f}, {self.k_max_fit:.2f}]") + k_min_window = self.k_min_window if self.k_min_window > 0 else self.k_min_fit + k_max_window = self.k_max_window if self.k_max_window > 0 else self.k_max_fit + lines.append(f"Window: k=[{k_min_window:.2f}, {k_max_window:.2f}]") + lines.append(f"Output: r=[0.00, {self.r_max:.2f}], step={self.r_step}") + lines.append(f"Plot: {self.plot_mode}") + lines += self._summary_selection_lines() + if self._density() is not None: + lines.append(f"Density: {self.density_value:g} atoms/ų") + else: + lines.append("Density: not set (g(r) and damping need it)") + if self.damp_origin_oscillations: + skipped = "" if self._density() is not None else ", skipped until the density is set" + lines.append(f"Damping: ON (r_cut={self.r_cut}, max_iter={self.max_iter}{skipped})") + print("\n".join(lines)) + + def _summary_selection_lines(self): + """summary() lines for the line, probe or mask selection.""" + selected = f"{self.mask_pixel_count} px ({self.mask_fraction * 100:.1f}%)" + if self.analysis_mode == "line": + if not self.line_active: + return ["Line: none drawn"] + er0, ec0, er1, ec1 = self._effective_line() + length = float(np.hypot(er1 - er0, ec1 - ec0)) + perpendicular = ", perpendicular" if self.line_perpendicular else "" + start = f"({self.line_row0:.1f}, {self.line_col0:.1f})" + end = f"({self.line_row1:.1f}, {self.line_col1:.1f})" + sampled = f"({er0:.0f}, {ec0:.0f}) → ({er1:.0f}, {ec1:.0f})" + return [ + f"Line: {start} → {end}", + f"Sampled: {sampled} (ends on scan positions{perpendicular})", + f" len={length:.1f} px, width={self.line_width} px, {selected}", + ] + if self.analysis_mode == "probe": + side = 2 * max(1, self.probe_size) - 1 + return [f"Probe: ({self.probe_row}, {self.probe_col}), {side} × {side}, {selected}"] + if self.mask_b64: + return [f"Mask: {selected}"] + return ["Mask: full scan (no mask)"] + + def __repr__(self) -> str: + mask_info = ( + f", mask={self.mask_pixel_count}px" + if self.analysis_mode == "mask" and self.mask_b64 + else "" + ) + return ( + f"ShowPDF(scan=({self.scan_rows}, {self.scan_cols}), " + f"k=[{self.k_min_fit:.1f}, {self.k_max_fit:.1f}]{mask_info})" + ) + + +# ============================================================================= +# HTML export helpers +# ============================================================================= +def _export_encoding(mode, encoding, downsample): + """Check the shared export options; return the encoding, "full" or "uint8".""" + if str(mode or "single").strip().lower() != "single": + raise ValueError(f"ShowPDF HTML export supports mode='single', got {mode!r}") + if downsample not in (None, 1): + raise NotImplementedError("ShowPDF HTML export does not support downsample") + raw = str(encoding or "full").strip().lower() + if raw in ("full", "float32"): + return "full" + if raw == "uint8": + return "uint8" + raise ValueError(f"unknown ShowPDF export encoding {encoding!r}; expected 'full' or 'uint8'") + + +def _export_label(profiles): + """Export status suffix describing what the standalone HTML contains.""" + return "exact float32 curves + quantized uint8 profiles" if profiles else "exact float32 curves" + + +def _base64_buffer(name, data): + """Embedded-state buffer holding ``data`` for the Bytes trait ``name``.""" + return {"encoding": "base64", "path": [name], "data": base64.b64encode(data).decode("ascii")} + + +def _refuse_non_finite_floats(model_state): + """Raise ValueError for a NaN or infinite float trait, which is not valid JSON in the page. + + The validators keep them out of the parameters; this refuses one in any other float trait. + """ + for name, value in model_state.items(): + if isinstance(value, float) and not math.isfinite(value): + raise ValueError( + f"Cannot export: {name} is {value!r}, which is not valid JSON " + "in the exported page." + ) + + +# ============================================================================= +# save_results files. The CSV files use comma separators, dot decimals and empty +# cells for missing values, so they open cleanly in Excel and pandas. +# ============================================================================= +def _format_cell(v): + """Float as a short decimal; NaN or a non-number as an empty cell.""" + try: + f = float(v) + except (TypeError, ValueError): + return "" + if math.isnan(f): + return "" + return f"{f:.7g}" + + +def _columns_to_csv(headers, columns): + """Ragged 1D columns as CSV, shorter columns padded with empty cells (g(r) is empty without a density).""" + n_rows = max((len(c) for c in columns), default=0) + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(headers) + for i in range(n_rows): + w.writerow([_format_cell(col[i]) if i < len(col) else "" for col in columns]) + return buf.getvalue() + + +def _stack_to_csv_with_axis(axis, stack): + """2D stack as CSV: the axis values as the header row, then one row per stack row. + + NaN cells are written empty (line-scan stacks are NaN for samples without a curve). + """ + buf = io.StringIO() + w = csv.writer(buf) + w.writerow([_format_cell(v) for v in axis[: stack.shape[1]]]) + for row in stack: + w.writerow([_format_cell(v) for v in row]) + return buf.getvalue() + + +def _positions_to_csv(positions): + """linescan_position.csv: each sample's index and distance from the line start in scan pixels.""" + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(["bin_index", "position_px"]) + for i, p in enumerate(positions): + w.writerow([i, f"{float(p):.6g}"]) + return buf.getvalue() + + +def _format_metadata_txt(meta): + """metadata.txt: a title and underlined sections of ``key: value`` lines. + + The 0.0 sentinels read as "disabled" for the filters, as the fit range for the window + and as "not set" for the density; the fit bounds are numbers. + """ + + def section(title): + return [title, "-" * len(title)] + + def filter_value(v): + return "disabled" if v == 0.0 else f"{v:g}" + + def window_value(v): + return "fit range" if v <= 0.0 else f"{v:g}" + + def on_off(v): + return "on" if v else "off" + + lines = [ + "quantem ShowPDF — exported results", + "===================================", + "", + f"Saved: {meta['saved_at']}", + f"Widget version: {meta['widget_version']}", + f"Title: {meta['title']}", + "", + ] + + lines += section("Scan") + sr, sc = meta["scan_shape"] + lines.append(f" shape: {sr} x {sc} pixels") + lines.append("") + + lines += section("Analysis") + lines.append(f" mode: {meta['analysis_mode']}") + if meta["line_mode"]: + lines.append(f" line mode: {meta['line_mode']}") + lines.append(f" plot mode at save: {meta['plot_mode_at_save']}") + lines.append("") + + region = meta["region"] + kind = region["kind"] + lines += section(f"Region ({kind})") + if kind == "mask": + lines.append( + f" pixels selected: {region['mask_pixel_count']} " + f"({region['mask_fraction'] * 100:.1f}% of scan)" + ) + lines.append(f" mask file: {region['mask_file']}") + elif kind == "probe": + lines.append(f" centre (row, col): ({region['probe_row']}, {region['probe_col']})") + side = 2 * region["probe_size"] - 1 + lines.append(f" size: {side} x {side} px") + lines.append( + f" pixels selected: {region['mask_pixel_count']} " + f"({region['mask_fraction'] * 100:.1f}% of scan)" + ) + elif kind == "line": + lines.append(f" start (row, col): ({region['line_row0']:.2f}, {region['line_col0']:.2f})") + lines.append(f" end (row, col): ({region['line_row1']:.2f}, {region['line_col1']:.2f})") + sr0, sc0, sr1, sc1 = region["sampled_line"] + lines.append( + f" sampled line: ({sr0:.0f}, {sc0:.0f}) to ({sr1:.0f}, {sc1:.0f}), " + "ends on scan positions; position_px is measured from its start" + ) + lines.append(f" width: {region['line_width']} px") + lines.append(f" perpendicular: {'yes' if region['line_perpendicular'] else 'no'}") + lines.append( + f" pixels selected: {region['mask_pixel_count']} " + f"({region['mask_fraction'] * 100:.1f}% of scan)" + ) + if "n_linescan_bins" in region: + lines.append(f" linescan bins: {region['n_linescan_bins']}") + lines.append(f" line length: {region['line_length_px']:.2f} px") + lines.append("") + + lines += section("Fit parameters") + fp = meta["fit_parameters"] + density = fp["density_value"] + density_text = f"{density:g}" if density > 0 else "not set" + damping = on_off(fp["damp_origin_oscillations"]) + # Same test as ShowPDF._density, so a negative density also counts as not set. + if fp["damp_origin_oscillations"] and not density > 0: + damping += " (not applied: density not set)" + lines.append(f" k_min_fit: {fp['k_min_fit']:g}") + lines.append(f" k_max_fit: {fp['k_max_fit']:g}") + lines.append(f" k_min_window: {window_value(fp['k_min_window'])}") + lines.append(f" k_max_window: {window_value(fp['k_max_window'])}") + lines.append(f" k_lowpass: {filter_value(fp['k_lowpass'])}") + lines.append(f" k_highpass: {filter_value(fp['k_highpass'])}") + lines.append(f" r_max: {fp['r_max']:g}") + lines.append(f" r_step: {fp['r_step']:g}") + lines.append(f" damp_origin_oscillations: {damping}") + lines.append(f" r_cut: {fp['r_cut']:g}") + lines.append(f" max_iter: {fp['max_iter']}") + lines.append(f" density_value: {density_text}") + lines.append("") + + return "\n".join(lines) + + +def _build_readme(is_linescan, analysis_mode, damped): + """README.txt of the results zip: what each file holds and how to load it.""" + if damped: + fk_correction = " (with the low-r correction applied)." + else: + fk_correction = " (no low-r correction)." + lines = [ + "quantem ShowPDF — exported results", + "===================================", + "", + "Files in this archive:", + "", + " metadata.txt Mode, region selection, fit parameters, axis info,", + " timestamp. Plain text — open in any editor.", + "", + " curves_k.csv Reciprocal-space curves for the selected region:", + " columns are k, I(k), B(k), F(k).", + " Background B(k) is the fit; F(k) is the windowed", + " reduced structure factor used for the transform", + fk_correction, + "", + " curves_r.csv Real-space curves: columns are r, G(r), g(r).", + " g(r) is only present when a density was set.", + "", + ] + if analysis_mode == "mask": + lines += [ + " mask.npy Boolean 2D array (scan_rows × scan_cols) marking", + " the scan positions used. Load with numpy:", + " import numpy as np", + " mask = np.load('mask.npy')", + "", + ] + if is_linescan: + lines += [ + " linescan_position.csv", + " Two columns: bin_index, position_px.", + " position_px is the distance from the line start", + " (A) in scan pixels; positions are evenly spaced.", + "", + " linescan_Ik.csv, linescan_Ik_bg.csv, linescan_Fk.csv,", + " linescan_Gr.csv, linescan_gr.csv", + " 2D stacks of per-bin curves. The FIRST row of", + " each file is the axis (k for Ik/Ik_bg/Fk; r for", + " Gr/gr). Each subsequent row is one bin (same", + " order as linescan_position.csv). Empty cells", + " indicate bins with no scan positions inside.", + "", + ] + lines += [ + "Quick load in Python:", + "", + " import numpy as np", + "", + " # 1D curves (Excel-style: skip the header row, blank cells → NaN)", + " curves_k = np.genfromtxt('curves_k.csv', delimiter=',',", + " names=True, missing_values='', filling_values=np.nan)", + " k = curves_k['k']", + " Ik = curves_k['Ik']", + " bg = curves_k['bg']", + " Fk = curves_k['Fk']", + "", + " # metadata.txt is plain text — read it with open() or just look at it", + " # in a text editor; the values are also visible in any browser.", + "", + ] + if is_linescan: + lines += [ + " # 2D linescan stack — first row is the axis, rest is data", + " raw = np.genfromtxt('linescan_Gr.csv', delimiter=',',", + " missing_values='', filling_values=np.nan)", + " r_axis = raw[0]", + " Gr_stack = raw[1:] # shape (n_bins, n_r)", + "", + ] + lines += [ + "Excel / Google Sheets: open any .csv file directly.", + "", + ] + return "\n".join(lines) diff --git a/tests/browser/test_showpdf_browser.py b/tests/browser/test_showpdf_browser.py new file mode 100644 index 000000000..45189c96d --- /dev/null +++ b/tests/browser/test_showpdf_browser.py @@ -0,0 +1,1144 @@ +"""Browser tests of ShowPDF's standalone HTML export. + +Each test exports a widget, opens the file in headless Chromium (CHROME_EXECUTABLE, else Google +Chrome) and drives its controls, so the curves are recomputed in the browser. Opt in with +QUANTEM_WIDGET_BROWSER=1. No display or GPU is needed, but the page loads its scripts from CDNs. +""" + +from __future__ import annotations + +import inspect +import os +import time +from pathlib import Path + +import numpy as np +import pytest + +pytestmark = pytest.mark.browser + +_diffraction = pytest.importorskip("quantem.diffraction") +_PDF = getattr(_diffraction, "PairDistributionFunction", None) +if _PDF is None or not {"density", "max_iter"} <= set(inspect.signature(_PDF.calculate_Gr).parameters): + pytest.skip( + "ShowPDF needs PairDistributionFunction.calculate_Gr(density=..., max_iter=...)", + allow_module_level=True, + ) +sync_playwright = pytest.importorskip("playwright.sync_api").sync_playwright + +from quantem.core.datastructures.dataset4dstem import Dataset4dstem # noqa: E402 +from quantem.diffraction import PairDistributionFunction # noqa: E402 +from quantem.widget import ShowPDF # noqa: E402 +from quantem.widget.showpdf import _DENSITY_HINT_GR, _WINDOW_HINT # noqa: E402 + +DENSITY_FIELD = 'input[aria-label="Number density in atoms per cubic ångström"]' +PLOT_CANVAS = '[data-quantem-scientific-output="showpdf-curves"] canvas' +NAV_CANVAS = '[data-quantem-scientific-output="showpdf-navigation"] canvas' +R_CUT_SLIDER = "r cut (upper end of the low-r correction)" +PARAMETER_SLIDERS = [ + "k fit range (background fit)", + "k window range", + "r max", + "k lowpass filter", + "k highpass filter", +] + + +# --------------------------------------------------------------------------- +# JS run in the page +# --------------------------------------------------------------------------- + +# Reading the page. + +# Text of the status line in the header (empty when nothing is shown). +STATUS_JS = """() => { + const header = document.querySelector(".showpdf-root").firstElementChild; + const el = header.children[1].querySelector("[title]"); + return el ? el.getAttribute("title") : ""; +}""" + +# Whether the element at a page point (x, y) is part of a slider thumb. +ON_THUMB_JS = "([x, y]) => !!document.elementFromPoint(x, y).closest('.MuiSlider-thumb')" + +# Fraction of each scan cell (its inner 80 %, at scan zoom 1) that the scan +# image's overlay paints in the translucent band colour rgba(100,200,255,0.2). +BAND_COVER_JS = """([rows, cols]) => { + const canvas = document.querySelectorAll('[data-quantem-scientific-output="showpdf-navigation"] canvas')[1]; + const { width, height } = canvas; + const data = canvas.getContext("2d").getImageData(0, 0, width, height).data; + const sx = width / cols, sy = height / rows; + return Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_, c) => { + let n = 0, hit = 0; + for (let y = Math.ceil((r + 0.1) * sy); y < (r + 0.9) * sy; y++) { + for (let x = Math.ceil((c + 0.1) * sx); x < (c + 0.9) * sx; x++) { + const i = (y * width + x) * 4; + n++; + if (Math.abs(data[i + 3] - 51) <= 2 && Math.abs(data[i] - 100) <= 8 + && Math.abs(data[i + 1] - 200) <= 8 && data[i + 2] >= 245) hit++; + } + } + return hit / n; + })); +}""" + +# The widget's model: HOOK_MODEL_JS finds it, the snippets after it use it. + +# Finds the anywidget model from the @anywidget/react context provider above the element +# `sel`, keeps it as window.__model and counts the g(r) updates from then on (__grUpdates). +HOOK_MODEL_JS = """(sel) => { + const el = document.querySelector(sel); + const key = Object.keys(el).find((k) => k.startsWith("__reactFiber")); + for (let f = el[key]; f; f = f.return) { + const v = f.memoizedProps && f.memoizedProps.value; + if (v && v.model && typeof v.model.get === "function") { + window.__model = v.model; + window.__grUpdates = 0; + v.model.on("change:pdf_y_bytes", () => { window.__grUpdates += 1; }); + return true; + } + } + return false; +}""" + +# Counts the F(k) and G(r) updates of the page's model (__curveUpdates). +CURVE_UPDATES_JS = """() => { + window.__curveUpdates = 0; + for (const t of ["fk_y_bytes", "gr_y_bytes"]) { + window.__model.on(`change:${t}`, () => { window.__curveUpdates += 1; }); + } +}""" + +# Records the values the model takes for a trait from now on (__changes). +CHANGES_JS = """(t) => { + window.__changes = []; + window.__model.on(`change:${t}`, () => window.__changes.push(window.__model.get(t))); +}""" + +# Sets the given line traits of the page's model, all in one call. +SET_LINE_JS = "(line) => Object.entries(line).forEach(([t, v]) => window.__model.set(t, v))" + +# Init scripts, added before the page loads. + +# Records the strings drawn on each canvas since its last redraw (a redraw +# starts by setting canvas.width), with their text alignment. +FILLTEXT_JS = """(() => { + const texts = new Map(); + window.__canvasTexts = () => [...texts.values()].flat().map((d) => d.text); + window.__canvasDraws = (sel) => (texts.get(document.querySelector(sel)) || []).map((d) => [d.text, d.align]); + const fillText = CanvasRenderingContext2D.prototype.fillText; + CanvasRenderingContext2D.prototype.fillText = function (t, ...rest) { + if (!texts.has(this.canvas)) texts.set(this.canvas, []); + texts.get(this.canvas).push({ text: String(t), align: this.textAlign }); + return fillText.call(this, t, ...rest); + }; + const width = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "width"); + Object.defineProperty(HTMLCanvasElement.prototype, "width", { + get() { return width.get.call(this); }, + set(v) { texts.set(this, []); width.set.call(this, v); }, + }); +})();""" + +# Counts the ImageData buffers created (one per band image built) and the scan +# overlay's redraws in line mode (each draws the start label "A"). +OVERLAY_BUILDS_JS = """(() => { + window.__imageData = 0; + window.__lineRedraws = 0; + const create = CanvasRenderingContext2D.prototype.createImageData; + CanvasRenderingContext2D.prototype.createImageData = function (...args) { + window.__imageData += 1; + return create.apply(this, args); + }; + const fillText = CanvasRenderingContext2D.prototype.fillText; + CanvasRenderingContext2D.prototype.fillText = function (t, ...rest) { + if (t === "A") window.__lineRedraws += 1; + return fillText.call(this, t, ...rest); + }; +})();""" + + +# --------------------------------------------------------------------------- +# Fixtures and helpers +# --------------------------------------------------------------------------- + +# Browser and page. + + +def _chrome_executable() -> str | None: + """CHROME_EXECUTABLE, else the first Chrome/Chromium found in a usual place.""" + candidates = [ + os.environ.get("CHROME_EXECUTABLE"), + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/opt/google/chrome/chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ] + for path in candidates: + if path and Path(path).exists(): + return path + return None + + +@pytest.fixture(scope="module") +def browser(): + chrome = _chrome_executable() + if chrome is None: + pytest.skip("Chrome/Chromium executable not found") + with sync_playwright() as playwright: + browser = playwright.chromium.launch(executable_path=chrome, headless=True) + yield browser + browser.close() + + +@pytest.fixture +def open_export(browser): + """open_export(path, init_script=None, has_touch=False) opens an exported file in a new context + and waits for the widget; at teardown, any error a page raised fails the test.""" + pages = [] + + def open_(path, init_script=None, has_touch=False): + context = browser.new_context(viewport={"width": 1400, "height": 1100}, has_touch=has_touch) + page = context.new_page() + page.errors = [] + page.on("pageerror", lambda error: page.errors.append(str(error))) + if init_script: + page.add_init_script(init_script) + page.goto(Path(path).as_uri()) + page.wait_for_selector(".showpdf-root", timeout=60_000) + pages.append(page) + return page + + yield open_ + for page in pages: + assert page.errors == [] + page.context.close() + + +def _poll(page, read, done, timeout=5.0): + """read() every 100 ms until done(value) or the timeout; returns the last value.""" + deadline = time.monotonic() + timeout + while True: + value = read() + if done(value) or time.monotonic() > deadline: + return value + page.wait_for_timeout(100) + + +def _poll_status(page, done): + """_poll the status line's text.""" + return _poll(page, lambda: page.evaluate(STATUS_JS), done) + + +# Test data. + + +def _calibrated_pdf(scan=(4, 4), det=64, dq=0.05, radial_min=0.0): + """k-calibrated amorphous-like data (dq 1/A per detector pixel), as in tests/showpdf/test_showpdf.py. + + The k axis starts at radial_min detector pixels.""" + rr, cc = np.mgrid[0:det, 0:det].astype(np.float64) + c = (det - 1) / 2.0 + rad = np.hypot(rr - c, cc - c) + beam = 400.0 * np.exp(-(rad**2) / (2 * 2.0**2)) + 30.0 * np.exp(-rad / 9.0) + 0.2 + rings = ( + 6.0 * np.exp(-((rad - 9.0) ** 2) / 5.0) + + 2.5 * np.exp(-((rad - 17.0) ** 2) / 9.0) + + 1.2 * np.exp(-((rad - 25.0) ** 2) / 12.0) + ) + rng = np.random.default_rng(0) + data = np.empty((*scan, det, det), dtype=np.float32) + for iy in range(scan[0]): + for ix in range(scan[1]): + amp = 1.0 + 0.15 * (iy + 2 * ix) + data[iy, ix] = beam + amp * rings + 0.01 * rng.standard_normal((det, det)) + ds = Dataset4dstem.from_array( + data, sampling=(1.0, 1.0, dq, dq), units=("A", "A", "A^-1", "A^-1") + ) + return PairDistributionFunction.from_data( + ds, + find_origin=False, + origin_row=c, + origin_col=c, + num_annular_bins=90, + radial_min=radial_min, + device="cpu", + ) + + +def _r_grid(w): + """First and last r of the widget's G(r).""" + r = np.frombuffer(w.gr_x_bytes, dtype=np.float32) + return float(r[0]), float(r[-1]) + + +# The page's model (after HOOK_MODEL_JS). + + +def _model_value(page, trait): + """A trait of the page's model.""" + return page.evaluate("(t) => window.__model.get(t)", trait) + + +def _model_set(page, trait, value): + """Set a trait of the page's model, as Python would.""" + page.evaluate("([t, v]) => window.__model.set(t, v)", [trait, value]) + + +def _poll_model(page, trait, done): + """_poll a trait of the page's model.""" + return _poll(page, lambda: _model_value(page, trait), done) + + +# Sliders: MUI renders one input per thumb, labelled with the slider's aria-label. + + +def _input(page, label, nth=0): + """The nth input labelled `label` (the nth thumb of a slider).""" + return page.locator(f'input[aria-label="{label}"]').nth(nth) + + +def _press(page, label, key, times=1, nth=0): + """Focus the nth input labelled `label` and press `key` `times` times.""" + _input(page, label, nth).focus() + for _ in range(times): + page.keyboard.press(key) + + +def _slider_value(page, label, nth=0): + """The value a slider thumb shows.""" + return float(_input(page, label, nth).get_attribute("aria-valuenow")) + + +def _poll_slider(page, label, done, nth=0): + """_poll the value a slider thumb shows.""" + return _poll(page, lambda: _slider_value(page, label, nth), done) + + +def _set_trait_and_wait(page, trait, value, label, nth=0): + """Set a trait as Python would, wait until its slider shows it, and let the thumb's move finish.""" + _model_set(page, trait, value) + assert _poll_slider(page, label, lambda v: v == value, nth) == value + page.wait_for_timeout(300) + + +def _slider_track(page, label, nth): + """The slider's track box (MUI maps the pointer's x on it to a value), min and max.""" + thumb = _input(page, label, nth) + track = thumb.locator("xpath=ancestor::span[contains(@class, 'MuiSlider-root')]").bounding_box() + return track, float(thumb.get_attribute("min")), float(thumb.get_attribute("max")) + + +def _x_of_value(track, lo, hi, value): + """The page x of a slider value on its track.""" + return track["x"] + (value - lo) / (hi - lo) * track["width"] + + +def _value_at_x(track, lo, hi, x): + """The slider value under page x, before MUI rounds it to a step.""" + return lo + (x - track["x"]) / track["width"] * (hi - lo) + + +def _thumb_centre(page, label, nth): + thumb = _input(page, label, nth) + box = thumb.locator("xpath=ancestor::span[contains(@class, 'MuiSlider-thumb')]").bounding_box() + return box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 + + +def _value_on_pixel(page, label, near, step=1): + """The value within 5 steps of `near` whose position on the slider's track is nearest a whole + pixel (a mouse position), for a slider with less than a pixel per step.""" + track, lo, hi = _slider_track(page, label, 0) + + def off_pixel(value): + x = _x_of_value(track, lo, hi, value) + return abs(x - round(x)) + + candidates = [round(near + i * step, 10) for i in range(-5, 6)] + return min(candidates, key=off_pixel) + + +def _drag_thumb(page, label, nth, value): + """Mouse drag of a slider thumb to `value`.""" + track, lo, hi = _slider_track(page, label, nth) + x, y = _thumb_centre(page, label, nth) + page.mouse.move(x, y) + page.mouse.down() + page.mouse.move(_x_of_value(track, lo, hi, value), y, steps=10) + page.mouse.up() + + +def _drag_away_and_back(page, x, y, away_x, steps): + """Mouse drag from (x, y) to away_x and back to x, then release.""" + page.mouse.move(x, y) + page.mouse.down() + page.mouse.move(away_x, y, steps=steps) + page.mouse.move(x, y, steps=steps) + page.mouse.up() + + +def _readout(page, label): + """The value readout next to a slider.""" + return _input(page, label).locator( + "xpath=ancestor::span[contains(@class, 'MuiSlider-root')]/following-sibling::p[1]" + ) + + +# The curve plot (after FILLTEXT_JS). + + +def _canvas_texts(page): + """The strings drawn on every canvas since its last redraw.""" + return page.evaluate("() => window.__canvasTexts()") + + +def _plot_texts(page): + """The strings drawn in the curve plot's last redraw.""" + return [text for text, _ in page.evaluate("(sel) => window.__canvasDraws(sel)", PLOT_CANVAS)] + + +def _plot_ticks(page): + """Tick values of the curve plot's last redraw: (x ticks, y ticks). + + The x tick labels are centred, the y tick labels right aligned; the axis labels + and messages are not numbers.""" + ticks = {"center": [], "right": []} + for text, align in page.evaluate("(sel) => window.__canvasDraws(sel)", PLOT_CANVAS): + try: + ticks[align].append(float(text)) + except (KeyError, ValueError): + pass + return ticks["center"], ticks["right"] + + +def _wheel_plot(page, notches): + """Zoom the curve plot in about its centre.""" + box = page.locator(PLOT_CANVAS).bounding_box() + page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + for _ in range(notches): + page.mouse.wheel(0, -100) + page.wait_for_timeout(50) + page.mouse.move(5, 5) # off the plot: no hover readout + + +# Touch. + + +def _touch(cdp, kind, points=()): + """Send one touch event through CDP, which gives every touch the same id (browsers reuse ids too).""" + touch_points = [{"x": x, "y": y} for x, y in points] + cdp.send("Input.dispatchTouchEvent", {"type": kind, "touchPoints": touch_points}) + + +def _below_widget(page): + """A page point 100 px below the widget, off it.""" + root = page.locator(".showpdf-root").bounding_box() + return root["x"] + root["width"] / 2, root["y"] + root["height"] + 100 + + +# Exports shared by several tests. + + +def _open_density_export(open_export, tmp_path): + """A g(r) export with density 0.0456789 and its model hooked; returns the page and the field.""" + w = ShowPDF(_calibrated_pdf(), k_min_fit=0.3, density_value=0.0456789, plot_mode="gr") + page = open_export(w.export_html(tmp_path / "d.html", encoding="uint8")) + assert page.evaluate(HOOK_MODEL_JS, DENSITY_FIELD) + field = page.locator(DENSITY_FIELD) + assert field.input_value() == "0.04568" # shown rounded to 4 significant figures + return page, field + + +def _assert_density_kept(page, field): + """The density is still the exported 0.0456789, g(r) was not recomputed, no status.""" + page.wait_for_timeout(300) + assert _model_value(page, "density_value") == 0.0456789 + assert page.evaluate("() => window.__grUpdates") == 0 + assert field.input_value() == "0.04568" + assert page.evaluate(STATUS_JS) == "" + + +def _open_window_export(open_export, tmp_path): + """An F(k) export whose k window ends both follow the fit range [0.3, 1.2].""" + w = ShowPDF(_calibrated_pdf(), k_min_fit=0.3, k_max_fit=1.2, density_value=0.06, plot_mode="Fk") + assert (w.k_min_window, w.k_max_window) == (0.0, 0.0) # both ends follow the fit range + assert w.k_min_available == 0.0 + return open_export(w.export_html(tmp_path / "win.html", encoding="uint8")) + + +def _open_slider_export(open_export, tmp_path, label): + """A uint8 export that shows the slider, its model hooked: damping on (for r cut), in + probe mode for Probe size, else with a line (for Line width).""" + w = ShowPDF( + _calibrated_pdf(scan=(6, 6)), + k_min_fit=0.3, + k_max_fit=1.2, + damp_origin_oscillations=True, + density_value=0.06, + ) + if label == "Probe size": + w.analysis_mode = "probe" + else: + w.set_line(0, 0, 5, 5, width=3) + page = open_export(w.export_html(tmp_path / "set.html", encoding="uint8")) + assert page.evaluate(HOOK_MODEL_JS, f'input[aria-label="{label}"]') + return page + + +# --------------------------------------------------------------------------- +# Status line +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("encoding", ["full", "uint8"]) +def test_showpdf_export_shows_baked_error_until_recompute(open_export, tmp_path, encoding): + w = ShowPDF(_calibrated_pdf(scan=(6, 6)), k_min_fit=0.3) + w.k_lowpass = 0.02 + w.k_highpass = 0.05 # highpass above lowpass: calculate_Gr refuses + assert w.status_message.startswith("Error:") + page = open_export(w.export_html(tmp_path / "err.html", encoding=encoding)) + # The file shows the error Python reported when it was exported ... + assert _poll_status(page, lambda s: s == w.status_message) == w.status_message + # ... until the browser recomputes; from then on its own status. + _press(page, "k highpass filter", "Home") # highpass off: valid + assert _poll_status(page, lambda s: s == "") == "" + page.keyboard.press("End") # highpass 0.1 above lowpass 0.02 + assert _poll_status(page, lambda s: s != "").startswith("Error: k highpass must not exceed") + + +def test_showpdf_export_clearing_line_clears_linescan_error(open_export, tmp_path): + w = ShowPDF(_calibrated_pdf(scan=(6, 6)), k_min_fit=0.3) + w.set_line(0, 0, 5, 5, width=3) + w.line_mode = "linescan" + assert w.status_message == "" + page = open_export(w.export_html(tmp_path / "ls.html", encoding="uint8")) + _press(page, "k lowpass filter", "ArrowRight", times=20) # lowpass 0.02 + _press(page, "k highpass filter", "End") # highpass 0.1: the line scan is refused + assert _poll_status(page, lambda s: s != "").startswith("Line-scan error: k highpass must not exceed") + page.get_by_role("button", name="Clear", exact=True).click() + # As in Python, where clearing the line drops the line-scan error. + w.k_lowpass, w.k_highpass = 0.02, 0.1 + assert w.status_message.startswith("Line-scan error:") + w.clear_line() + assert _poll_status(page, lambda s: s == w.status_message) == w.status_message == "" + + +def _failed_from_the_start(): + """No compute succeeded: the widget has no I(k) at all.""" + return ShowPDF(_calibrated_pdf(scan=(6, 6)), k_min_fit=0.3, k_lowpass=0.02, k_highpass=0.05) + + +def _failed_for_a_new_selection(): + """A compute succeeded for one mask and then failed for another, so Python cleared the curves.""" + w = ShowPDF(_calibrated_pdf(scan=(6, 6)), k_min_fit=0.3, plot_mode="Gr") + left = np.zeros((6, 6), dtype=bool) + left[:, :2] = True + w.set_mask(left) + w.k_lowpass, w.k_highpass = 0.02, 0.05 # refused: highpass above lowpass + w.set_mask(~left) # refused too, for other scan positions + return w + + +@pytest.mark.parametrize( + "widget", [_failed_from_the_start, _failed_for_a_new_selection], ids=["from the start", "for a new selection"] +) +def test_showpdf_export_keeps_baked_error_without_curves_to_recompute(open_export, tmp_path, widget): + # A curves-only file without the I(k) of the selection it shows cannot recompute (and must + # not show G(r) of another mask's I(k)): its parameter controls are locked, so the file keeps + # its exported state and no control contradicts the error. + w = widget() + w.damp_origin_oscillations = True # shows the r cut slider and the density field + assert w.status_message.startswith("Error:") and len(w.ik_y_bytes) == 0 + page = open_export(w.export_html(tmp_path / "noik.html", encoding="full")) + assert _poll_status(page, lambda s: s == w.status_message) == w.status_message + for label in PARAMETER_SLIDERS + [R_CUT_SLIDER, "Damp low-r oscillations"]: + assert _input(page, label).is_disabled(), label + assert page.locator(DENSITY_FIELD).is_disabled() + for mode in ["I(k)", "F(k)", "G(r)", "g(r)"]: + assert page.get_by_role("button", name=mode, exact=True).is_disabled(), mode + assert page.evaluate(HOOK_MODEL_JS, DENSITY_FIELD) + page.locator(".showpdf-root").focus() + page.keyboard.press("1") # the I(k) key changes nothing + _press(page, "k highpass filter", "Home") # highpass off would be valid: changes nothing + page.wait_for_timeout(500) + assert _model_value(page, "plot_mode") == w.plot_mode + # Nothing was recomputed, so the file still shows why there are no curves. + assert page.evaluate(STATUS_JS) == w.status_message + + +def test_showpdf_export_status_says_window_empty(open_export, tmp_path): + # The lower window end follows the fit range (0.6), above the upper end 0.4. + w = ShowPDF(_calibrated_pdf(), k_min_fit=0.6, k_max_fit=1.2, k_max_window=0.4, plot_mode="gr") + assert w.status_message == _WINDOW_HINT # the browser decides as Python does + page = open_export(w.export_html(tmp_path / "win.html", encoding="uint8")) + # The window hint comes before the density hint (g(r) without a density) ... + assert _poll_status(page, lambda s: s == _WINDOW_HINT) == _WINDOW_HINT + # ... and returns once an error is gone. + _press(page, "k lowpass filter", "ArrowRight", times=20) # lowpass 0.02 + _press(page, "k highpass filter", "End") # highpass 0.1: refused + error = _poll_status(page, lambda s: s.startswith("Error:")) + assert error.startswith("Error: k highpass must not exceed") + page.keyboard.press("Home") # highpass off + assert _poll_status(page, lambda s: s == _WINDOW_HINT) == _WINDOW_HINT + _press(page, "k fit range (background fit)", "ArrowLeft", times=30) # fit min and window start 0.3 + assert _poll_status(page, lambda s: s == _DENSITY_HINT_GR) == _DENSITY_HINT_GR + # Python, in the same state, shows the same hint. + w.k_lowpass = 0.02 + w.k_min_fit = 0.3 + assert w.status_message == _DENSITY_HINT_GR + + +# --------------------------------------------------------------------------- +# Density field +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("entry", ["blur", "Enter", "1e"]) +def test_showpdf_export_density_field_keeps_density_unless_a_new_one_is_typed(open_export, tmp_path, entry): + page, field = _open_density_export(open_export, tmp_path) + field.click() + if entry == "blur": + field.blur() + else: + if entry != "Enter": # a malformed number, which reads as an empty field + page.keyboard.press("ControlOrMeta+A") + page.keyboard.type(entry) + assert field.evaluate("el => el.validity.badInput") + page.keyboard.press("Enter") + # Not rounded to the shown 0.04568 or unset, g(r) not recomputed, and the field restored. + _assert_density_kept(page, field) + + +def test_showpdf_export_density_field_typing_the_shown_value_sets_it(open_export, tmp_path): + page, field = _open_density_export(open_export, tmp_path) + field.click() + page.keyboard.press("ControlOrMeta+A") + page.keyboard.type("0.04568") # exactly the rounded value the field shows + page.keyboard.press("Enter") + assert _poll_model(page, "density_value", lambda v: v == 0.04568) == 0.04568 + assert page.evaluate("() => window.__grUpdates") >= 1 + assert field.input_value() == "0.04568" + + +# --------------------------------------------------------------------------- +# g(r) plot +# --------------------------------------------------------------------------- + + +def test_showpdf_export_empty_gr_plot_says_density_missing(open_export, tmp_path): + w = ShowPDF(_calibrated_pdf(), k_min_fit=0.3, plot_mode="Ik") # no density + page = open_export(w.export_html(tmp_path / "gr.html", encoding="uint8"), init_script=FILLTEXT_JS) + page.get_by_role("button", name="g(r)", exact=True).click() + drawn = _poll(page, lambda: _canvas_texts(page), lambda texts: _DENSITY_HINT_GR in texts) + assert _DENSITY_HINT_GR in drawn + # On the r grid of G(r) and a neutral g(r) range, not the I(k) axes. + r0, r1 = _r_grid(w) + x, y = _plot_ticks(page) + assert x and r0 - 1e-6 <= min(x) and max(x) <= r1 + 1e-6 and max(x) - min(x) >= 0.75 * (r1 - r0) + assert y and min(y) == 0 and max(y) == 2 + + +def test_showpdf_export_clearing_density_clears_gr_plot(open_export, tmp_path): + w = ShowPDF(_calibrated_pdf(), k_min_fit=0.3, density_value=0.06, plot_mode="gr") + page = open_export(w.export_html(tmp_path / "grc.html", encoding="uint8"), init_script=FILLTEXT_JS) + _wheel_plot(page, 1) # zoomed: clearing the density does not refit (and redraw) the plot + page.locator(DENSITY_FIELD).click() + page.keyboard.press("ControlOrMeta+A") + page.keyboard.press("Backspace") + page.keyboard.press("Enter") + # The plot is redrawn without the old g(r), with the message, at once. + drawn = _poll(page, lambda: _canvas_texts(page), lambda texts: _DENSITY_HINT_GR in texts) + assert _DENSITY_HINT_GR in drawn + + +# --------------------------------------------------------------------------- +# k window +# --------------------------------------------------------------------------- + + +def test_showpdf_export_window_end_follows_fit_range(open_export, tmp_path): + page = _open_window_export(open_export, tmp_path) + # An end at 0 follows the fit range: the slider shows the window in effect. + _press(page, "k window range", "Home") # the lower end to 0, which it already is + assert _poll_slider(page, "k window range", lambda v: v == pytest.approx(0.3)) == pytest.approx(0.3) + assert _slider_value(page, "k window range", 1) == pytest.approx(1.2) + _press(page, "k window range", "ArrowLeft", times=10) # lower end set to 0.2 + lower = _poll_slider(page, "k window range", lambda v: v == pytest.approx(0.2)) + assert lower == pytest.approx(0.2) + _press(page, "k fit range (background fit)", "ArrowLeft", times=20, nth=1) # fit max 1.0 + assert _slider_value(page, "k fit range (background fit)", 1) == pytest.approx(1.0) + # The upper end was left alone, so it still follows the fit range. + upper = _poll_slider(page, "k window range", lambda v: v == pytest.approx(1.0), nth=1) + assert upper == pytest.approx(1.0) + assert _slider_value(page, "k window range", 0) == pytest.approx(0.2) + + +@pytest.mark.parametrize( + "ends, action", [({"k_max_window": 1.546}, "End"), ({"k_max_fit": 1.546}, "drag")], ids=["set", "follows-fit"] +) +def test_showpdf_export_window_end_below_max_moves_to_max(open_export, tmp_path, ends, action): + # The upper window end is less than a step (0.01) below the slider's max. + w = ShowPDF(_calibrated_pdf(), k_min_fit=0.3, **ends, density_value=0.06, plot_mode="Fk") + top = w.k_max_available + page = open_export(w.export_html(tmp_path / "top.html", encoding="uint8")) + assert page.evaluate(HOOK_MODEL_JS, 'input[aria-label="k window range"]') + if action == "drag": + _drag_thumb(page, "k window range", 1, 1.7) # past the max + else: + _press(page, "k window range", action, nth=1) + assert _poll_model(page, "k_max_window", lambda v: v == top) == top + assert _slider_value(page, "k window range", 1) == top + + +def test_showpdf_export_negative_window_end_shows_fit_range(open_export, tmp_path): + # Python takes a window end <= 0, as 0, to follow the fit range. + w = ShowPDF( + _calibrated_pdf(), k_min_fit=0.3, k_max_fit=1.2, k_min_window=-0.5, density_value=0.06, plot_mode="Fk" + ) + page = open_export(w.export_html(tmp_path / "neg.html", encoding="uint8"), init_script=FILLTEXT_JS) + label = "k window range" + assert page.evaluate(HOOK_MODEL_JS, f'input[aria-label="{label}"]') + shown = _readout(page, label) + # The slider, its readout and the F(k) plot's window marker show the window in effect. + assert _poll(page, shown.inner_text, lambda t: t == "[0.30, 1.20]") == "[0.30, 1.20]" + assert _slider_value(page, label, 0) == pytest.approx(0.3) + drawn = _poll(page, lambda: _plot_texts(page), lambda texts: "k window" in texts) + assert "k window" in drawn + # So does a drag of the upper end away and back, which changes nothing ... + x, y = _thumb_centre(page, label, 1) + _drag_away_and_back(page, x, y, x - 60, steps=5) + page.wait_for_timeout(300) + ends = (_model_value(page, "k_min_window"), _model_value(page, "k_max_window")) + assert (ends, shown.inner_text()) == ((-0.5, 0), "[0.30, 1.20]") + # ... and a key on the upper end leaves the lower one following the fit range. + _press(page, label, "ArrowLeft", nth=1) + assert _poll_model(page, "k_max_window", lambda v: v != 0) == pytest.approx(1.19) + page.wait_for_timeout(300) + assert (_model_value(page, "k_min_window"), shown.inner_text()) == (-0.5, "[0.30, 1.19]") + + +# --------------------------------------------------------------------------- +# Sliders: mouse and keys +# --------------------------------------------------------------------------- + +# Every slider that commits to a trait: label, thumb, trait, a value to drag +# it to, and a value Python sets (on the slider's step grid and, for a mouse +# click at its thumb's centre, on the pixel of that value; for Probe size, a +# size near it on a pixel is picked from the layout). +SLIDER_CASES = [ + ("r max", 0, "r_max", 30.0, 12.5), + ("k fit range (background fit)", 1, "k_max_fit", 1.0, 1.4), + ("k window range", 1, "k_max_window", 1.0, 1.4), + ("k lowpass filter", 0, "k_lowpass", 0.05, 0.02), + ("k highpass filter", 0, "k_highpass", 0.05, 0.02), + (R_CUT_SLIDER, 0, "r_cut", 3.0, 1.5), + ("Line width", 0, "line_width", 9, 5), + ("Probe size", 0, "probe_size", 30, 20), +] + + +def _slider_cases(*labels): + """The SLIDER_CASES of the given sliders.""" + return [case for case in SLIDER_CASES if case[0] in labels] + + +# A range slider's upper thumb, and Probe size, with less than a pixel per step. +@pytest.mark.parametrize( + ("label", "nth", "trait", "drag_to", "python_value"), _slider_cases("k fit range (background fit)", "Probe size") +) +def test_showpdf_export_slider_click_keeps_value_set_from_python( + open_export, tmp_path, label, nth, trait, drag_to, python_value +): + page = _open_slider_export(open_export, tmp_path, label) + if label == "Probe size": # less than a pixel per size: not every size has a pixel + python_value = _value_on_pixel(page, label, python_value) + exported = _model_value(page, trait) + _drag_thumb(page, label, nth, drag_to) + dragged = _poll_model(page, trait, lambda v: v != exported) + assert dragged not in (exported, python_value) + # Python sets the trait (here from the page's model) ... + _set_trait_and_wait(page, trait, python_value, label, nth) + # ... and a click on the thumb, on the pixel of that value, moves nothing. MUI + # commits its last onChange value (the drag) also then, which must not undo the change. + x, y = _thumb_centre(page, label, nth) + page.mouse.move(round(x), y) # the whole pixel nearest the thumb's centre + page.mouse.down() + pressed = _slider_value(page, label, nth) + page.mouse.up() + assert pressed == python_value, (x, _slider_track(page, label, nth)) # the press is on the value + page.wait_for_timeout(300) + clicked = _model_value(page, trait) + # Nor does End at the end of the range, once Python set the trait there: MUI + # then changes nothing, but commits that value again. + top = float(_input(page, label, nth).get_attribute("max")) + _model_set(page, trait, top) + assert _poll_slider(page, label, lambda v: v == top, nth) == top + _press(page, label, "End", nth=nth) + page.wait_for_timeout(300) + assert (clicked, _model_value(page, trait)) == (python_value, top) + + +# A range slider's upper thumb, and r cut, with more than one step per pixel. +@pytest.mark.parametrize( + ("label", "nth", "trait", "drag_to"), + [case[:4] for case in _slider_cases("k fit range (background fit)", R_CUT_SLIDER)], +) +def test_showpdf_export_slider_commit_without_change_recomputes_nothing( + open_export, tmp_path, label, nth, trait, drag_to +): + page = _open_slider_export(open_export, tmp_path, label) + if label == R_CUT_SLIDER: # more than one r step per pixel: start from an r cut on a pixel + step = float(_input(page, label, nth).get_attribute("step")) + start = _value_on_pixel(page, label, _model_value(page, trait), step) + _set_trait_and_wait(page, trait, start, label, nth) + page.evaluate(CURVE_UPDATES_JS) + exported = _model_value(page, trait) + # A drag away and back to where it started commits the value the trait has. + track, lo, hi = _slider_track(page, label, nth) + x, y = _thumb_centre(page, label, nth) + x = round(x) # the whole pixel nearest the thumb's centre (a fractional one is truncated) + _drag_away_and_back(page, x, y, _x_of_value(track, lo, hi, drag_to), steps=10) + page.wait_for_timeout(500) + assert _model_value(page, trait) == exported + # The file keeps its exact curves (a recompute starts from the 8-bit profiles). + assert page.evaluate("() => window.__curveUpdates") == 0 + + +@pytest.mark.parametrize(("label", "nth", "trait", "drag_to", "python_value"), SLIDER_CASES) +def test_showpdf_export_slider_rail_click_near_thumb_commits_pointer_value( + open_export, tmp_path, label, nth, trait, drag_to, python_value +): + # A click 8 or 15 px from the 10 px thumb's centre, within MUI's 42 px hit circle around + # the thumb, commits the value under the pointer. Every slider, so each one's commit reaches + # its trait. + page = _open_slider_export(open_export, tmp_path, label) + track, lo, hi = _slider_track(page, label, nth) + step = float(_input(page, label, nth).get_attribute("step")) + pixel = (hi - lo) / track["width"] # the value one pixel spans (a mouse position is a whole pixel) + for dx in (8, 15): + _set_trait_and_wait(page, trait, python_value, label, nth) + x, y = _thumb_centre(page, label, nth) + x += -dx if python_value > lo + 0.7 * (hi - lo) else dx # towards the middle + assert page.evaluate(ON_THUMB_JS, [x, y]), (label, dx) + page.mouse.click(x, y) + under = _value_at_x(track, lo, hi, x) + value = _poll_model(page, trait, lambda v: v != python_value) + assert abs(value - under) <= step / 2 + pixel + 1e-9, (label, dx, value, under) + assert _slider_value(page, label, nth) == value, (label, dx) + + +def test_showpdf_export_small_thumb_drag_commits(open_export, tmp_path): + # A 2 px drag moves the thumb: a step of this slider is less than 2 px. + label, trait, start = "Line width", "line_width", 21 + page = _open_slider_export(open_export, tmp_path, label) + track, lo, hi = _slider_track(page, label, 0) + step = float(_input(page, label).get_attribute("step")) + assert step / (hi - lo) * track["width"] < 2 # Line width: about 1.63 px + _set_trait_and_wait(page, trait, start, label) + x, y = _thumb_centre(page, label, 0) + page.mouse.move(x, y) + page.mouse.down() + page.mouse.move(x + 2, y) + shown = _slider_value(page, label) + page.mouse.up() + assert shown != start + assert _poll_model(page, trait, lambda v: v == shown) == shown + page.wait_for_timeout(300) + assert (_model_value(page, trait), _slider_value(page, label)) == (shown, shown) + + +@pytest.mark.parametrize( + ("label", "trait", "value", "readout"), + [("r max", "r_max", 60.0, "60.0 Å"), ("r max", "r_max", 0.5, "0.5 Å")], + ids=["above", "below"], +) +def test_showpdf_export_key_towards_slider_bound_keeps_value_beyond_it( + open_export, tmp_path, label, trait, value, readout +): + # Python set the trait beyond the slider's fixed bound: the thumb sits at the bound. + page = _open_slider_export(open_export, tmp_path, label) + slider = _input(page, label) + lo = float(slider.get_attribute("min")) + hi = float(slider.get_attribute("max")) + step = float(slider.get_attribute("step")) + shown = _readout(page, label) + _model_set(page, trait, value) + assert _poll(page, shown.inner_text, lambda t: t == readout) == readout + page.wait_for_timeout(300) + page.evaluate(CURVE_UPDATES_JS) + above = value > hi + if above: + keys = ["ArrowRight", "ArrowUp", "PageUp", "End"] + else: + keys = ["ArrowLeft", "ArrowDown", "PageDown", "Home"] + for key in keys: + _press(page, label, key) + page.wait_for_timeout(300) + # A key towards the bound does not set the bound, and the readout keeps the value. + assert (_model_value(page, trait), shown.inner_text()) == (value, readout), key + assert page.evaluate("() => window.__curveUpdates") == 0 + page.keyboard.press("ArrowLeft" if above else "ArrowRight") # away from the bound: a step inside it + inside = _poll_model(page, trait, lambda v: v != value) + assert inside == pytest.approx(hi - step if above else lo + step) + + +# --------------------------------------------------------------------------- +# Touch and pen +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("where", ["rail", "thumb"]) +def test_showpdf_export_touch_tap_commits_once(open_export, tmp_path, where): + # A tap commits the value under the finger, once. The browser's mouse events + # repeating the tap, at a whole pixel and off a thumb moved towards it by its + # touch adjustment, would commit a second value. + label, nth, trait, start = "Probe size", 0, "probe_size", 50 + w = ShowPDF(_calibrated_pdf(scan=(6, 6)), k_min_fit=0.3, k_max_fit=1.2, analysis_mode="probe") + page = open_export(w.export_html(tmp_path / "tap.html", encoding="uint8"), has_touch=True) + assert page.evaluate(HOOK_MODEL_JS, f'input[aria-label="{label}"]') + page.evaluate(CHANGES_JS, trait) + track, lo, hi = _slider_track(page, label, nth) + step = float(_input(page, label, nth).get_attribute("step")) + pixel = (hi - lo) / track["width"] # the value one pixel spans (a mouse position is a whole pixel) + + def step_at(px): # the step of MUI's value at a position + return round((px - track["x"]) / track["width"] * (hi - lo) / step) + + def reset(): + _set_trait_and_wait(page, trait, start, label, nth) + page.evaluate("() => { window.__changes = []; }") + + reset() + x0, y = _thumb_centre(page, label, nth) + if where == "rail": + taps = [x0 - 25 - 0.37 * i for i in range(5)] + else: + # Five taps across the 10 px thumb, off the start and at another step than the + # nearest whole pixel, where the browser's mouse events land. + start_step = round((start - lo) / step) + across_thumb = [x0 - 4.5 + 0.05 * i for i in range(181)] + taps = [x for x in across_thumb if step_at(x) not in (step_at(round(x)), start_step)] + taps = taps[:: max(1, len(taps) // 5)][:5] + assert len(taps) == 5, taps + for i, x in enumerate(taps): + reset() + page.touchscreen.tap(x, y) + under = _value_at_x(track, lo, hi, x) + _poll(page, lambda: page.evaluate("() => window.__changes"), lambda c: len(c) > 0) + page.wait_for_timeout(300) # time for a second commit + changes = page.evaluate("() => window.__changes") + assert len(changes) == 1, (i, under, changes) + assert abs(changes[0] - under) <= step / 2 + pixel, (i, under, changes) + assert _slider_value(page, label, nth) == changes[0], i + + +@pytest.mark.parametrize("gesture", ["touch drag", "pen drag"]) +def test_showpdf_export_touch_and_pen_drag_commit(open_export, tmp_path, gesture): + w = ShowPDF(_calibrated_pdf(scan=(6, 6)), k_min_fit=0.3, analysis_mode="probe") + page = open_export(w.export_html(tmp_path / "probe.html", encoding="uint8"), has_touch=True) + assert page.evaluate(HOOK_MODEL_JS, 'input[aria-label="Probe size"]') + _set_trait_and_wait(page, "probe_size", 20, "Probe size") + track, lo, hi = _slider_track(page, "Probe size", 0) + x, y = _thumb_centre(page, "Probe size", 0) + end = x + 30 + cdp = page.context.new_cdp_session(page) + path = [x + (end - x) * i / 5 for i in range(6)] + if gesture == "touch drag": + for i, px in enumerate(path): + _touch(cdp, "touchStart" if i == 0 else "touchMove", [(px, y)]) + _touch(cdp, "touchEnd") + else: # a pen's only mouse events are the compatibility ones + pen = {"button": "left", "clickCount": 1, "pointerType": "pen"} + for i, px in enumerate(path): + kind = "mousePressed" if i == 0 else "mouseMoved" + cdp.send("Input.dispatchMouseEvent", {"type": kind, "x": px, "y": y, "buttons": 1, **pen}) + cdp.send("Input.dispatchMouseEvent", {"type": "mouseReleased", "x": end, "y": y, "buttons": 0, **pen}) + size = _poll_model(page, "probe_size", lambda v: v != 20) + under = _value_at_x(track, lo, hi, end) + assert abs(size - under) <= 0.5 + (hi - lo) / track["width"], (size, under) + assert _slider_value(page, "Probe size") == size + + +@pytest.mark.parametrize( + "then", ["tap on the thumb", "click on the thumb", "End at the max", "tap elsewhere", "swipe elsewhere"] +) +def test_showpdf_export_cancelled_touch_press_commits_nothing(open_export, tmp_path, then): + # A touch press that moves the thumb and ends in touchcancel commits nothing (MUI + # ignores touchcancel), and the slider shows its old value again. Once Python has + # changed the trait, an interaction that changes nothing must keep it, although MUI + # then commits the cancelled press's value and a swipe reusing its touch id would move it. + label, trait, start, later = "Probe size", "probe_size", 50, 20 + w = ShowPDF(_calibrated_pdf(scan=(6, 6)), k_min_fit=0.3, k_max_fit=1.2, analysis_mode="probe") + page = open_export(w.export_html(tmp_path / "cancel.html", encoding="uint8"), has_touch=True) + assert page.evaluate(HOOK_MODEL_JS, f'input[aria-label="{label}"]') + _set_trait_and_wait(page, trait, start, label) + page.evaluate(CHANGES_JS, trait) + x, y = _thumb_centre(page, label, 0) + cdp = page.context.new_cdp_session(page) + _touch(cdp, "touchStart", [(x + 25, y)]) + pressed = _slider_value(page, label) # the thumb moves to the finger on the rail + _touch(cdp, "touchCancel") + page.wait_for_timeout(300) + assert pressed != start and _model_value(page, trait) == start + assert _poll_slider(page, label, lambda v: v == start) == start + if then == "End at the max": + later = float(_input(page, label).get_attribute("max")) + elif label == "Probe size": # a click on the thumb then lands on its size + later = _value_on_pixel(page, label, later) + _set_trait_and_wait(page, trait, later, label) + if then == "tap on the thumb": + page.touchscreen.tap(*_thumb_centre(page, label, 0)) + elif then == "click on the thumb": + x, y = _thumb_centre(page, label, 0) + page.mouse.click(round(x), y) + elif then == "End at the max": + _press(page, label, "End") + elif then == "tap elsewhere": # off the widget, where MUI would still listen for the touch + page.touchscreen.tap(*_below_widget(page)) + else: # a swipe off the widget with the cancelled touch's id (CDP's default id) + sx, sy = _below_widget(page) + _touch(cdp, "touchStart", [(sx, sy)]) + for k in range(1, 8): + _touch(cdp, "touchMove", [(sx - 40 * k, sy - 5 * k)]) + _touch(cdp, "touchEnd") + page.wait_for_timeout(500) + assert (_model_value(page, trait), page.evaluate("() => window.__changes")) == (later, [later]), then + + +# --------------------------------------------------------------------------- +# Line band and line width +# --------------------------------------------------------------------------- + + +def test_showpdf_export_band_overlay_is_the_selection_with_bisector_end_half_way(open_export, tmp_path): + # The bisector of (0, 0) -> (12, 7) has an end exactly half way between two scan + # positions, so the last bit of the line's length decides which way it snaps. + w = ShowPDF(_calibrated_pdf(scan=(16, 16)), k_min_fit=0.3) + w.set_line(0, 0, 12, 7, width=3) + w.line_perpendicular = True + assert w._effective_line() == (9, 0, 2, 10) + band = w._line_band_mask() + selected = int(band.sum()) + page = open_export(w.export_html(tmp_path / "perp.html", encoding="uint8")) + assert page.evaluate(HOOK_MODEL_JS, 'input[aria-label="Line width"]') + cover = _poll( + page, + lambda: np.array(page.evaluate(BAND_COVER_JS, [16, 16])), + lambda c: c[band].min() > 0.5 and c[~band].max() < 0.1, + ) + assert cover[band].min() > 0.5 and cover[~band].max() < 0.1, np.round(cover, 2) + # The file's own recompute averages the same scan positions as Python. + _press(page, "Line width", "ArrowRight") # width 5 + assert _poll_model(page, "mask_pixel_count", lambda n: n != selected) != selected + page.keyboard.press("ArrowLeft") # width 3 again + assert _poll_model(page, "mask_pixel_count", lambda n: n == selected) == selected + + +def test_showpdf_export_band_overlay_is_the_selection_at_every_angle(open_export, tmp_path): + w = ShowPDF(_calibrated_pdf(scan=(16, 16)), k_min_fit=0.3) + w.set_line(8, 8, 8, 14, width=3) + page = open_export(w.export_html(tmp_path / "angles.html", encoding="full")) + assert page.evaluate(HOOK_MODEL_JS, 'input[aria-label="Line width"]') + for i, deg in enumerate(range(0, 360, 10)): + th = np.deg2rad(deg) + w.set_line(7.6, 8.3, 7.6 - 6.5 * np.sin(th), 8.3 + 6.5 * np.cos(th), width=(1, 3, 5)[i % 3]) + band = w._line_band_mask() + line = {t: getattr(w, t) for t in ("line_row0", "line_col0", "line_row1", "line_col1", "line_width")} + page.evaluate(SET_LINE_JS, line) + # The overlay of each new line paints every selected scan position and no other one + # (on these smaller cells the line and its end markers cover part of some band cells). + cover = _poll( + page, + lambda: np.array(page.evaluate(BAND_COVER_JS, [16, 16])), + lambda c: c[band].min() > 0.5 and c[~band].max() < 0.1, + ) + assert cover[band].min() > 0.5 and cover[~band].max() < 0.1, (deg, np.round(cover, 2)) + + +def test_showpdf_export_scan_zoom_reuses_band_overlay(open_export, tmp_path): + w = ShowPDF(_calibrated_pdf(scan=(8, 8)), k_min_fit=0.3) + w.set_line(1, 1, 6, 4, width=3) + page = open_export(w.export_html(tmp_path / "zoom.html", encoding="full"), init_script=OVERLAY_BUILDS_JS) + + def counts(): + return page.evaluate("() => [window.__imageData, window.__lineRedraws]") + + built, redraws = _poll(page, counts, lambda c: c[1] > 0) + assert built >= 1 + box = page.locator('[data-quantem-scientific-output="showpdf-navigation"]').bounding_box() + page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + for _ in range(3): + page.mouse.wheel(0, -100) + page.wait_for_timeout(50) + # Each zoom step redraws the overlay, from the band image already built. + redraws_after = _poll(page, counts, lambda c: c[1] >= redraws + 3)[1] + assert redraws_after >= redraws + 3 + assert counts()[0] == built + + +@pytest.mark.parametrize("leave", ["probe", "clear"]) +def test_showpdf_export_band_overlay_released_without_line(open_export, tmp_path, leave): + w = ShowPDF(_calibrated_pdf(scan=(8, 8)), k_min_fit=0.3) + w.set_line(1, 1, 6, 4, width=3) + line = {t: getattr(w, t) for t in ("line_row0", "line_col0", "line_row1", "line_col1")} + page = open_export(w.export_html(tmp_path / "rel.html", encoding="uint8"), init_script=OVERLAY_BUILDS_JS) + assert page.evaluate(HOOK_MODEL_JS, 'input[aria-label="Line width"]') + built = _poll(page, lambda: page.evaluate("() => window.__imageData"), lambda n: n >= 1) + assert built >= 1 # the band image exists before it is released + if leave == "probe": # probe mode draws no band; then back to the line + page.get_by_role("button", name="probe", exact=True).click() + page.wait_for_timeout(300) + page.get_by_role("button", name="line", exact=True).click() + else: # no line; then the same line again (line_active stays off, so no empty band is drawn between) + page.get_by_role("button", name="Clear", exact=True).click() + page.wait_for_timeout(300) + page.evaluate(SET_LINE_JS, line) + # The band image was not kept while no band was drawn, so it is built again. + rebuilt = _poll(page, lambda: page.evaluate("() => window.__imageData"), lambda n: n > built) + assert rebuilt == built + 1 + + +def test_showpdf_export_line_width_range_keeps_the_widest_width(open_export, tmp_path): + w = ShowPDF(_calibrated_pdf(scan=(6, 6)), k_min_fit=0.3) + w.set_line(0, 0, 5, 5, width=151) # set from Python, above the slider's usual 99 + page = open_export(w.export_html(tmp_path / "wide.html", encoding="uint8")) + assert page.evaluate(HOOK_MODEL_JS, 'input[aria-label="Line width"]') + slider = _input(page, "Line width") + assert _slider_value(page, "Line width") == 151 # the thumb at the width, not at 99 + assert slider.get_attribute("aria-valuemax") == "151" + _press(page, "Line width", "ArrowLeft") # narrower + assert _poll_model(page, "line_width", lambda v: v == 149) == 149 + # The range still ends at 151 (the thumb does not jump to its end), so 151 can be set again. + assert slider.get_attribute("aria-valuemax") == "151" + page.keyboard.press("ArrowRight") + assert _poll_model(page, "line_width", lambda v: v == 151) == 151 + + +# --------------------------------------------------------------------------- +# Mask painting +# --------------------------------------------------------------------------- + + +def test_showpdf_export_brush_stroke_that_leaves_the_image_is_kept(open_export, tmp_path): + # A brush stroke released outside the scan image still selects the positions it painted. + w = ShowPDF( + _calibrated_pdf(scan=(8, 8)), + k_min_fit=0.3, + analysis_mode="mask", + mask_tool="freeform", + mask_brush_size=1, + ) + page = open_export(w.export_html(tmp_path / "paint.html", encoding="uint8")) + assert page.evaluate(HOOK_MODEL_JS, NAV_CANVAS) + page.evaluate(CURVE_UPDATES_JS) + before = _model_value(page, "mask_pixel_count") + box = page.locator(NAV_CANVAS).first.bounding_box() + cell = box["width"] / 8 + y = box["y"] + 2.5 * cell + page.mouse.move(box["x"] + 2.5 * cell, y) + page.mouse.down() + page.mouse.move(box["x"] + 4.5 * cell, y, steps=4) + page.mouse.move(box["x"] + box["width"] + 40, y, steps=8) # out of the image, button still down + page.mouse.up() + painted = _poll_model(page, "mask_pixel_count", lambda n: n != before) + assert 0 < painted < 64, (before, painted) + assert page.evaluate("() => window.__curveUpdates") > 0 diff --git a/tests/browser/test_widget_entries.py b/tests/browser/test_widget_entries.py index 1e64a967b..d2867ba2b 100644 --- a/tests/browser/test_widget_entries.py +++ b/tests/browser/test_widget_entries.py @@ -13,6 +13,7 @@ import quantem.widget as qw from quantem.widget.export import write_widget_html +from quantem.widget.showpdf import _pdf_class pytestmark = pytest.mark.browser @@ -30,6 +31,20 @@ "showdiffraction": lambda: qw.ShowDiffraction(RNG.random((32, 32)).astype(np.float32), device="cpu", verbose=False), "showptycho": lambda: qw.ShowPtycho(np.exp(1j * IMAGE).astype(np.complex64), sampling=0.2), } + + +def _showpdf(): + rr, cc = np.mgrid[0:16, 0:16] + pattern = (np.exp(-((np.hypot(rr - 7.5, cc - 7.5) - 4.0) ** 2) / 3.0) + 0.1).astype(np.float32) + data = np.broadcast_to(pattern, (2, 2, 16, 16)).copy() + return qw.ShowPDF(data, find_origin=False, origin_row=7.5, origin_col=7.5) + + +try: # ShowPDF needs quantem's PairDistributionFunction API (quantem pull request 248) + _pdf_class() + WIDGETS["showpdf"] = _showpdf +except ImportError: + pass # The widget's own DOM inside the embed's view container: anywidget renders into it # (without a kernel, Show2D and Show3D draw their saved preview image). WIDGET_DRAWN = "() => Array.from(document.querySelectorAll('.widget-subarea')).some(area => area.querySelector('canvas, button, svg, input, img'))" diff --git a/tests/infrastructure/test_shared_modules.py b/tests/infrastructure/test_shared_modules.py index 66be107c2..21a737c1d 100644 --- a/tests/infrastructure/test_shared_modules.py +++ b/tests/infrastructure/test_shared_modules.py @@ -145,7 +145,7 @@ def test_every_widget_bundle_is_an_anywidget_default_export(): entries = sorted((WIDGETS.parents[2] / "js").glob("*/index.tsx")) named = [entry.parent.name for entry in entries if re.search(r"^export (const|function) render\b", entry.read_text(), re.M)] missing = [entry.parent.name for entry in entries if not re.search(r"^export default \{", entry.read_text(), re.M)] - assert len(entries) == 10 and named == [] and missing == [], (named, missing) + assert len(entries) == 11 and named == [] and missing == [], (named, missing) RNG = np.random.default_rng(0) PREVIEW_WIDGETS = { diff --git a/tests/showpdf/test_showpdf.py b/tests/showpdf/test_showpdf.py new file mode 100644 index 000000000..572ce7161 --- /dev/null +++ b/tests/showpdf/test_showpdf.py @@ -0,0 +1,1645 @@ +"""Unit tests for the ShowPDF widget.""" + +import base64 +import inspect +import io +import json +import math +import pathlib +import re +import warnings +import zipfile + +import numpy as np +import pytest +import traitlets + +# ShowPDF needs the PairDistributionFunction API of quantem PR #248 (calculate_Gr with density +# and max_iter); skip until the installed quantem has it. +_diffraction = pytest.importorskip("quantem.diffraction") +_PDF = getattr(_diffraction, "PairDistributionFunction", None) +if _PDF is None or not {"density", "max_iter"} <= set(inspect.signature(_PDF.calculate_Gr).parameters): + pytest.skip( + "ShowPDF needs PairDistributionFunction.calculate_Gr(density=..., max_iter=...)", + allow_module_level=True, + ) + +from quantem.core.datastructures.dataset4dstem import Dataset4dstem # noqa: E402 +from quantem.diffraction import PairDistributionFunction # noqa: E402 +import quantem.widget.showpdf as showpdf_module # noqa: E402 +from quantem.widget import ShowPDF # noqa: E402 +from quantem.widget.showpdf import ( # noqa: E402 + _DENSITY_HINT_DAMPING, + _DENSITY_HINT_GR, + _WINDOW_HINT, + _bf_nav_image, + _export_label, +) + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +DOCS_PAGE = REPO_ROOT / "docs" / "api" / "showpdf.md" +FRONTEND_SOURCE = REPO_ROOT / "js" / "showpdf" / "index.tsx" + + +# --------------------------------------------------------------------------- +# Test data and helpers +# --------------------------------------------------------------------------- + +DET = 32 # detector size of the uncalibrated test patterns +DET_CENTER = (DET - 1) / 2.0 +# from_data keywords for the uncalibrated patterns: the origin is fixed at the detector center +# (find_origin=False, for speed). +FROM_DATA_KWARGS = dict( + find_origin=False, origin_row=DET_CENTER, origin_col=DET_CENTER, num_annular_bins=36, radial_step=1.0 +) + + +def _detector_radius(): + """Distance of each pixel of the DET x DET detector from its center.""" + rows, cols = np.mgrid[0:DET, 0:DET] + return np.sqrt((rows - DET_CENTER) ** 2 + (cols - DET_CENTER) ** 2) + + +def _noisy_scan(pattern, scan, noise, seed): + """``pattern`` at every position of a ``scan`` grid, plus Gaussian noise of std ``noise``.""" + rng = np.random.RandomState(seed) + data = np.empty((*scan, DET, DET), dtype=np.float32) + for row in range(scan[0]): + for col in range(scan[1]): + data[row, col] = pattern + noise * rng.randn(DET, DET).astype(np.float32) + return data + + +def _uncalibrated_pdf(data): + """PairDistributionFunction of a DET x DET pattern array; k is in detector pixels.""" + return PairDistributionFunction.from_data( + Dataset4dstem.from_array(data), device="cpu", **FROM_DATA_KWARGS + ) + + +@pytest.fixture +def small_4d_array(): + """4x4 scan of patterns with one broad ring and a little noise, as a plain ndarray.""" + ring = np.exp(-((_detector_radius() - 5) ** 2) / 4).astype(np.float32) + return _noisy_scan(ring, scan=(4, 4), noise=0.01, seed=7) + + +@pytest.fixture +def small_pdf(): + """PairDistributionFunction of a 4x4 scan of patterns with two broad rings and a little noise.""" + radius = _detector_radius() + rings = np.exp(-((radius - 5) ** 2) / 4) + 0.3 * np.exp(-((radius - 10) ** 2) / 8) + rings = rings.astype(np.float32) + return _uncalibrated_pdf(_noisy_scan(rings, scan=(4, 4), noise=0.01, seed=42)) + + +def _calibrated_data(scan=(4, 4), det=64, seed=0): + """Amorphous-like 4D-STEM array and its detector center. + + A direct beam plus three rings whose amplitudes change across the scan, so different scan + selections give different I(k). + """ + rr, cc = np.mgrid[0:det, 0:det].astype(np.float64) + c = (det - 1) / 2.0 + rad = np.hypot(rr - c, cc - c) + beam = 400.0 * np.exp(-(rad**2) / (2 * 2.0**2)) + 30.0 * np.exp(-rad / 9.0) + 0.2 + rings = ( + 6.0 * np.exp(-((rad - 9.0) ** 2) / 5.0) + + 2.5 * np.exp(-((rad - 17.0) ** 2) / 9.0) + + 1.2 * np.exp(-((rad - 25.0) ** 2) / 12.0) + ) + rng = np.random.default_rng(seed) + data = np.empty((*scan, det, det), dtype=np.float32) + for iy in range(scan[0]): + for ix in range(scan[1]): + amp = 1.0 + 0.15 * (iy + 2 * ix) + data[iy, ix] = beam + amp * rings + 0.01 * rng.standard_normal((det, det)) + return data, c + + +def _calibrated_pdf(scan=(4, 4), det=64, seed=0, k_step=0.05, scan_step=1.0, scan_unit="A"): + """PairDistributionFunction of _calibrated_data with ``k_step`` 1/Å per detector pixel.""" + data, c = _calibrated_data(scan=scan, det=det, seed=seed) + ds = Dataset4dstem.from_array( + data, + sampling=(scan_step, scan_step, k_step, k_step), + units=(scan_unit, scan_unit, "A^-1", "A^-1"), + ) + return PairDistributionFunction.from_data( + ds, find_origin=False, origin_row=c, origin_col=c, num_annular_bins=90, device="cpu" + ) + + +@pytest.fixture +def cal_pdf(): + """A 4x4 scan whose k axis runs from 0 to 1.55 1/Å in steps of 0.05.""" + return _calibrated_pdf() + + +def _f32(buffer): + """The float32 values of a synced bytes trait.""" + return np.frombuffer(buffer, dtype=np.float32) + + +def _synced(w): + """Every synced trait of ``w`` by name, to check that a rejected call changed nothing.""" + return {name: getattr(w, name) for name in w.traits(sync=True)} + + +def _read_zip(path): + with zipfile.ZipFile(path) as zf: + return {name: zf.read(name) for name in zf.namelist()} + + +def _read_csv(files, name): + """CSV file ``name`` of a save_results zip as a structured array with named columns.""" + return np.genfromtxt(files[name].decode().splitlines(), delimiter=",", names=True) + + +# --------------------------------------------------------------------------- +# Construction and input types +# --------------------------------------------------------------------------- + + +# Each input type of the constructor, built from the same 4D array. +_WIDGET_FROM = { + "PairDistributionFunction": lambda array: ShowPDF(_uncalibrated_pdf(array)), + "ndarray": lambda array: ShowPDF(array, device="cpu", **FROM_DATA_KWARGS), + "torch tensor": lambda array: ShowPDF( + pytest.importorskip("torch").from_numpy(array), device="cpu", **FROM_DATA_KWARGS + ), + "Dataset4dstem": lambda array: ShowPDF(Dataset4dstem.from_array(array), device="cpu", **FROM_DATA_KWARGS), +} + + +@pytest.mark.parametrize("kind", list(_WIDGET_FROM)) +def test_showpdf_accepts_each_input_type(small_4d_array, kind): + w = _WIDGET_FROM[kind](small_4d_array) + assert (w.scan_rows, w.scan_cols) == (4, 4) + for name in ("ik_y_bytes", "ik_bg_y_bytes", "fk_y_bytes", "gr_y_bytes", "nav_image_bytes"): + assert getattr(w, name), name + # The navigation image is the bright field, also of a tensor-backed dataset (no .array). + np.testing.assert_allclose(w._nav_image, _bf_nav_image(small_4d_array), rtol=1e-6) + + +def test_showpdf_accepts_a_single_pattern(): + # A 2D pattern is analyzed as a 1 x 1 scan. + pattern = np.random.RandomState(1).rand(DET, DET).astype(np.float32) + w = ShowPDF(pattern, device="cpu", **FROM_DATA_KWARGS) + assert (w.scan_rows, w.scan_cols) == (1, 1) + + +def test_showpdf_rejects_unsupported_ndim(): + with pytest.raises(ValueError, match="4D.*or 2D"): + ShowPDF(np.zeros((4, 4, 4), dtype=np.float32), device="cpu") + + +def test_showpdf_reports_missing_library_api(monkeypatch): + # quantem.widget imports without quantem; constructing ShowPDF names the API it needs. + import quantem.diffraction + + class OldPairDistributionFunction: + def calculate_Gr(self, k_min_fit=None, damp_origin_oscillations=False): + pass + + monkeypatch.setattr(quantem.diffraction, "PairDistributionFunction", OldPairDistributionFunction) + with pytest.raises(ImportError, match="ShowPDF needs quantem's PairDistributionFunction"): + ShowPDF(np.zeros((2, 2, 8, 8), dtype=np.float32)) + + +def test_showpdf_lazy_export(): + import quantem.widget as qw + + assert "ShowPDF" in qw.__all__ + assert qw.ShowPDF is ShowPDF + + +# --------------------------------------------------------------------------- +# Constructor keywords and parameter validation +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kwargs, hint", + [ + ({"density_mode": "manual"}, "never estimated"), + ({"line_max_bins": 64}, "renamed to linescan_max_bins"), + ({"theme": "dark"}, "follows the notebook theme"), + ({"k_width": 0.25}, "taper width is fixed"), + ({"r_min": 0.5}, "r grid always starts at 0"), + ({"k_widht": 0.3}, "typos"), + ({"_offline": True}, "renamed to offline"), + ], +) +def test_showpdf_rejects_unknown_kwargs(small_pdf, kwargs, hint): + # traitlets would silently ignore an unknown keyword; ShowPDF raises with a hint instead. + with pytest.raises(TypeError, match=hint): + ShowPDF(small_pdf, **kwargs) + + +@pytest.mark.parametrize( + "name", ["max_iter", "probe_row", "probe_col", "probe_size", "linescan_max_bins", "waterfall_count"] +) +def test_showpdf_integer_options_must_be_whole_numbers(small_pdf, name): + # The constructor rejects 2.5, as an assignment does, instead of truncating it to 2. + with pytest.raises(traitlets.TraitError, match=rf"^{name} must be a whole number, got 2\.5\.$"): + ShowPDF(small_pdf, **{name: 2.5}) + for whole in (2.0, np.int64(2)): + value = getattr(ShowPDF(small_pdf, **{name: whole}), name) + assert value == 2 and type(value) is int + + +# Float parameters that must be finite. Every finite value is accepted, and 0 keeps its meaning +# (full range, follow the fit range, off, or not set). +_FINITE_PARAMS = [ + "k_min_fit", "k_max_fit", "k_min_window", "k_max_window", "k_lowpass", "k_highpass", + "r_max", "r_step", "r_cut", "density_value", +] + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")], ids=repr) +@pytest.mark.parametrize("name", _FINITE_PARAMS) +def test_showpdf_parameter_must_be_finite(cal_pdf, name, bad): + # NaN or infinity would make the export's state JSON invalid, so every way of setting one is + # rejected and changes nothing. + message = rf"^{name} must be a finite number, got {bad!r}\.$" + # A saved non-finite density_value loads as not set (see test_showpdf_old_state_nonfinite_density). + saved_rejected = name != "density_value" + with pytest.raises(traitlets.TraitError, match=message): + ShowPDF(cal_pdf, **{"k_min_fit": 0.3, name: bad}) + if saved_rejected: + with pytest.raises(traitlets.TraitError, match=message): + ShowPDF(cal_pdf, k_min_fit=0.3, state={name: bad}) + + w = ShowPDF(cal_pdf, k_min_fit=0.3, density_value=0.05) + before = _synced(w) + with pytest.raises(traitlets.TraitError, match=message): + setattr(w, name, bad) + assert _synced(w) == before + if saved_rejected: + # The bad key first and last: the keys loaded before it are undone too. + for state in ( + {name: bad, "analysis_mode": "probe", "probe_size": 2, "plot_mode": "Fk"}, + {"analysis_mode": "probe", "probe_size": 2, "plot_mode": "Fk", name: bad}, + ): + with pytest.raises(traitlets.TraitError, match=message): + w.load_state_dict(state) + assert _synced(w) == before + + for value in (0.0, -1.0): # finite values, zero and negative ones included, are accepted + setattr(w, name, value) + assert getattr(w, name) == value + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf")], ids=repr) +def test_showpdf_pixel_size_must_be_finite(cal_pdf, bad): + # pixel_size sets nav_pixel_size, which the export writes into its state JSON. + message = rf"^pixel_size must be a finite number, got {bad!r}\.$" + with pytest.raises(ValueError, match=message): + ShowPDF(cal_pdf, k_min_fit=0.3, pixel_size=bad) + w = ShowPDF(cal_pdf, k_min_fit=0.3, pixel_size=2.0) + before = _synced(w) + with pytest.raises(ValueError, match=message): + w.set_data(_calibrated_pdf(scan=(6, 6)), pixel_size=bad) + assert w.pdf is cal_pdf + assert _synced(w) == before + + +@pytest.mark.parametrize("size, unit", [(float("nan"), "A"), (1e308, "nm")], ids=["nan", "nm overflow"]) +def test_showpdf_nonfinite_scan_calibration_counts_as_none(tmp_path, size, unit): + # A NaN scan sampling, or an nm sampling that overflows in Å, means no calibration (as for + # the frontend's scale bar), so nav_pixel_size stays finite and the export works. + w = ShowPDF(_calibrated_pdf(scan_step=size, scan_unit=unit), k_min_fit=0.3) + assert (w.nav_pixel_size, w.nav_unit) == (1.0, "px") + assert w.export_html(tmp_path / "uncalibrated.html").exists() + + +@pytest.mark.filterwarnings("ignore:(invalid value|overflow) encountered:RuntimeWarning") +@pytest.mark.parametrize("k_step", [float("nan"), float("inf"), 1e300], ids=repr) +def test_showpdf_nonfinite_k_axis_names_the_calibration(cal_pdf, k_step): + # A k axis that is not finite in float32 (1e300 overflows it) raises an error naming the + # calibration, not the default fit range; set_data raises it before anything changes. + pdf = _calibrated_pdf(k_step=k_step) + message = ( + r"^The dataset's k axis is not finite \(or too large for float32\); " + r"check the diffraction calibration\.$" + ) + for kwargs in ({}, {"k_min_fit": 0.3}, {"k_min_fit": 0.3, "k_max_fit": 1.5}): + with pytest.raises(ValueError, match=message): + ShowPDF(pdf, **kwargs) + w = ShowPDF(cal_pdf, k_min_fit=0.3) + before = _synced(w) + with pytest.raises(ValueError, match=message): + w.set_data(pdf) + assert w.pdf is cal_pdf + assert _synced(w) == before + + +# --------------------------------------------------------------------------- +# Curves: the widget shows exactly what calculate_Gr computes +# --------------------------------------------------------------------------- + + +def test_showpdf_curves_equal_library(cal_pdf): + params = dict( + k_min_fit=0.3, k_max_fit=1.5, k_min_window=0.35, k_max_window=1.45, + k_lowpass=0.06, r_max=12.0, r_step=0.05, r_cut=0.9, max_iter=3, + ) + w = ShowPDF(cal_pdf, damp_origin_oscillations=True, density_value=0.05, **params) + ref = _calibrated_pdf() + ref.calculate_Gr(damp_origin_oscillations=True, density=0.05, **params) + ref.calculate_gr( + density=0.05, r_cut=params["r_cut"], max_iter=params["max_iter"], set_pdf_positive=True + ) + np.testing.assert_array_equal(_f32(w.ik_y_bytes), ref.Ik.numpy()) + np.testing.assert_array_equal(_f32(w.ik_bg_y_bytes), ref.bg.numpy()) + # F(k) and G(r) are the damped ones, which the transform used. + np.testing.assert_array_equal(_f32(w.fk_y_bytes), ref.Fk_damped.numpy()) + np.testing.assert_array_equal(_f32(w.gr_y_bytes), ref.reduced_pdf_damped.numpy()) + np.testing.assert_array_equal(_f32(w.pdf_y_bytes), ref.pdf) + + +def test_showpdf_default_curves_equal_library_defaults(cal_pdf): + w = ShowPDF(cal_pdf) + ref = _calibrated_pdf() + ref.calculate_Gr() + np.testing.assert_array_equal(_f32(w.ik_bg_y_bytes), ref.bg.numpy()) + np.testing.assert_array_equal(_f32(w.gr_y_bytes), ref.reduced_pdf) + + +def test_showpdf_shared_pdf_object(cal_pdf, tmp_path): + # The PairDistributionFunction may be shared: another widget's selection, or a direct library + # call, must not leak into this widget's g(r) or saved results. + w1 = ShowPDF(cal_pdf, k_min_fit=0.3, density_value=0.05) + w2 = ShowPDF(cal_pdf, k_min_fit=0.3) + column = np.zeros((4, 4), dtype=bool) + column[:, 3] = True + w2.set_mask(column) + w1.density_value = 0.08 + ref = ShowPDF(_calibrated_pdf(), k_min_fit=0.3, density_value=0.08) + np.testing.assert_array_equal(_f32(w1.pdf_y_bytes), _f32(ref.pdf_y_bytes)) + np.testing.assert_array_equal(_f32(w1.gr_y_bytes), _f32(ref.gr_y_bytes)) + + cal_pdf.calculate_Gr(k_min_fit=0.6, k_max_fit=1.0) # direct library call + files = _read_zip(w1.save_results(tmp_path / "res.zip")) + curves_r = _read_csv(files, "curves_r.csv") + np.testing.assert_allclose(curves_r["Gr"], _f32(w1.gr_y_bytes), rtol=1e-6, atol=1e-6) + + +# --------------------------------------------------------------------------- +# Mask and probe selection +# --------------------------------------------------------------------------- + + +def test_showpdf_mask_selection(small_pdf): + w = ShowPDF(small_pdf) + gr_full = _f32(w.gr_y_bytes).copy() + w.analysis_mode = "probe" + mask = np.zeros((4, 4), dtype=bool) + mask[:2, :2] = True + w.set_mask(mask) # switches back to mask mode + assert w.analysis_mode == "mask" + assert w.mask_pixel_count == 4 + assert w.mask_fraction == pytest.approx(0.25) + assert not np.allclose(_f32(w.gr_y_bytes), gr_full, atol=1e-10) + w.clear_mask() + np.testing.assert_allclose(_f32(w.gr_y_bytes), gr_full, rtol=0, atol=1e-6) + + +def test_showpdf_probe_selection(small_pdf): + w = ShowPDF(small_pdf, analysis_mode="probe", probe_row=1, probe_col=1, probe_size=2) + assert w.mask_pixel_count == 9 # a square of side 2 * probe_size - 1 = 3: rows and cols 0..2 + w.probe_row = 10 # outside the scan: no square, so the full scan is computed and counted + assert w.mask_pixel_count == 16 + + +# --------------------------------------------------------------------------- +# Line selection: band geometry +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("perp", [False, True]) +@pytest.mark.parametrize("width", [1, 3, 5, 7]) +def test_showpdf_line_band_covers_exactly_width_rows(width, perp): + # An interior horizontal or vertical band of odd width w covers exactly w rows or columns; + # with line_perpendicular, a drawn horizontal line samples a vertical one. + w = ShowPDF(_calibrated_pdf(scan=(20, 20), det=32), k_min_fit=0.3, line_perpendicular=perp) + half = width // 2 + for line, drawn_horizontal in (((10, 4, 10, 15), True), ((3, 9, 16, 9), False)): + w.set_line(*line, width=width) + assert w.analysis_mode == "line" and w.line_active + r0, c0, r1, c1 = (int(v) for v in w._effective_line()) + expected = np.zeros((20, 20), dtype=bool) + sampled_horizontal = drawn_horizontal != perp + if sampled_horizontal: + assert r0 == r1 + expected[r0 - half : r0 + half + 1, min(c0, c1) : max(c0, c1) + 1] = True + else: + assert c0 == c1 + expected[min(r0, r1) : max(r0, r1) + 1, c0 - half : c0 + half + 1] = True + np.testing.assert_array_equal(w.mask, expected) + assert w.mask_pixel_count == int(w._line_band_mask().sum()) == int(expected.sum()) + + +@pytest.mark.parametrize("line_mode", ["averaged", "linescan"]) +def test_showpdf_clear_line(small_pdf, line_mode): + # Without a line, the averaged line mode computes (and counts) the full scan, and a line scan + # computes nothing. + w = ShowPDF(small_pdf, plot_mode="Gr", line_mode=line_mode) + gr_full = _f32(w.gr_y_bytes).copy() + w.set_line(1, 0, 1, 3, width=3) + assert (w.n_linescan > 0) == (line_mode == "linescan") + w.clear_line() + assert not w.line_active + if line_mode == "averaged": + assert w.mask_pixel_count == 16 + np.testing.assert_allclose(_f32(w.gr_y_bytes), gr_full, rtol=0, atol=1e-6) + else: + assert (w.mask_pixel_count, w.n_linescan, w.linescan_bytes) == (0, 0, b"") + + +def test_showpdf_line_perpendicular_ends_round_as_in_the_frontend(monkeypatch): + # This line's first perpendicular end is an ulp below column 1.5 with linescan.ts's length + # sqrt(dr * dr + dc * dc), so it snaps to 1; a hypot one ulp short (as macOS np.hypot is + # here) would snap it to 2. Both hypots are patched one ulp short to catch either on any OS. + w = ShowPDF(_calibrated_pdf(scan=(8, 8)), k_min_fit=0.3) + w.set_line(2.4097257447429, 4.714252327103168, 5.5902742552571, 1.466296183411032, width=1) + w.line_perpendicular = True + + def hypot_one_ulp_short(dr, dc): + return math.nextafter(math.sqrt(dr * dr + dc * dc), 0.0) + + monkeypatch.setattr(showpdf_module.np, "hypot", hypot_one_ulp_short) + monkeypatch.setattr(showpdf_module.math, "hypot", hypot_one_ulp_short) + assert w._effective_line() == (2.0, 1.0, 6.0, 5.0) + + +# --------------------------------------------------------------------------- +# Line width validation: an odd number of scan pixels +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("width", [1, 3, 99, 3.0, np.float64(7.0), np.int64(5), np.int32(9)], ids=repr) +def test_showpdf_line_width_accepts_odd_whole_numbers(small_pdf, width): + # The constructor and set_line take any odd whole number and store it as an int. + w = ShowPDF(small_pdf, line_width=width) + assert w.line_width == width and type(w.line_width) is int + w = ShowPDF(small_pdf) + w.set_line(1, 0, 1, 3, width=width) + assert w.line_width == width and type(w.line_width) is int + + +@pytest.mark.parametrize("bad", [2, 4, 0, -1]) +def test_showpdf_line_width_must_be_odd(small_pdf, bad): + with pytest.raises(traitlets.TraitError, match=r"line_width must be an odd number of scan pixels"): + ShowPDF(small_pdf, line_width=bad) + w = ShowPDF(small_pdf) + gr = w.gr_y_bytes + with pytest.raises(traitlets.TraitError, match="line_width"): + w.line_width = bad + + # A rejected set_line changes nothing: not the endpoints, the mode or the width. + with pytest.raises(traitlets.TraitError, match="line_width"): + w.set_line(1, 0, 1, 3, width=bad) + assert (w.line_row0, w.line_col0, w.line_row1, w.line_col1) == (0.0, 0.0, 0.0, 0.0) + assert not w.line_active + assert w.analysis_mode == "mask" + assert w.line_width == 3 + assert w.gr_y_bytes == gr + + # Nor with a line already set, in probe mode. + w.set_line(0, 0, 3, 3, width=5) + w.analysis_mode = "probe" + with pytest.raises(traitlets.TraitError, match="line_width"): + w.set_line(1, 0, 1, 3, width=bad) + assert (w.line_row0, w.line_col0, w.line_row1, w.line_col1) == (0.0, 0.0, 3.0, 3.0) + assert w.line_active + assert w.analysis_mode == "probe" + assert w.line_width == 5 + + +@pytest.mark.parametrize( + "bad", + [5.5, 4.9, np.float64(2.5), float("nan"), float("inf"), 10**400 + 1, 10**400, -(10**400) - 1], + ids=["5.5", "4.9", "np.float64(2.5)", "nan", "inf", "huge odd", "huge even", "huge negative"], +) +def test_showpdf_line_width_rejects_other_numbers(small_pdf, bad): + # int() would truncate 5.5 to 5, and a width too large for a float would overflow the band + # test's division by 2.0. The TraitError shows the width as given, and nothing changes. + message = re.escape(f"line_width must be an odd number of scan pixels (1, 3, 5, ...), got {bad!r}.") + with pytest.raises(traitlets.TraitError, match=message): + ShowPDF(small_pdf, line_width=bad) + w = ShowPDF(small_pdf) + w.set_line(0, 0, 3, 3, width=5) + before = _synced(w) + with pytest.raises(traitlets.TraitError, match=message): + w.set_line(1, 0, 1, 3, width=bad) + with pytest.raises(traitlets.TraitError): + w.line_width = bad + assert _synced(w) == before + w.line_width = 7 # the band works afterwards + assert w.mask_pixel_count == int(w._line_band_mask().sum()) + + +# --------------------------------------------------------------------------- +# Line end validation: finite and within ±1e9 +# --------------------------------------------------------------------------- + +_LINE_ENDS = ["line_row0", "line_col0", "line_row1", "line_col1"] + + +@pytest.mark.parametrize("line", [(None, 0, 1, 3), (1, "x", 1, 3), (1, 0, 1, None)]) +def test_showpdf_set_line_bad_endpoint_changes_nothing(cal_pdf, line): + # Every argument is converted before anything is assigned, so a bad endpoint next to a valid + # new width leaves every synced trait unchanged. + w = ShowPDF(cal_pdf, k_min_fit=0.3) + w.set_line(1, 0, 1, 3, width=3) + before = _synced(w) + with pytest.raises((TypeError, ValueError)): + w.set_line(*line, width=7) + assert _synced(w) == before + + +@pytest.mark.parametrize( + "bad", [float("nan"), float("inf"), float("-inf"), 10**400, -(10**400)], ids=["nan", "inf", "-inf", "huge", "-huge"] +) +@pytest.mark.parametrize("position, name", enumerate(["row0", "col0", "row1", "col1"])) +def test_showpdf_set_line_end_must_be_finite_within_1e9(cal_pdf, position, name, bad): + # float() accepts NaN and infinity, and an int too large for a float is beyond ±1e9 (a + # ValueError, not an OverflowError): no band can be computed from them, so set_line rejects + # them before assigning anything, the width included. + w = ShowPDF(cal_pdf, k_min_fit=0.3) + w.set_line(1, 0, 1, 3, width=3) + before = _synced(w) + line = [2, 0, 2, 3] + line[position] = bad + message = re.escape(f"{name} must be a finite number within ±1e9, got {bad!r}.") + with pytest.raises(ValueError, match=message): + w.set_line(*line, width=7) + assert _synced(w) == before + + +@pytest.mark.parametrize("perp", [False, True]) +@pytest.mark.parametrize( + "line", [(0, 0, 1e200, 1e200), (1e308, 0, -1e308, 0), (0, 0, 1e154, 1e154), (0, 0, 5, 1e300)] +) +def test_showpdf_set_line_with_overflowing_ends_changes_nothing(cal_pdf, line, perp): + # Finite ends this large would overflow the perpendicular bisector's length or an end + # difference; set_line rejects them before switching to the line, so later recomputes work. + w = ShowPDF(cal_pdf, k_min_fit=0.3, line_perpendicular=perp) + before = _synced(w) + with pytest.raises(ValueError, match=r"^(row|col)[01] must be a finite number within ±1e9, got "): + w.set_line(*line) + assert _synced(w) == before + w.k_lowpass = 0.05 + assert not w.status_message.startswith("Error") + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf"), 1e9 + 1, -1e154, 1e308], ids=repr) +@pytest.mark.parametrize("name", _LINE_ENDS) +def test_showpdf_line_end_trait_must_be_finite_within_1e9(cal_pdf, name, bad): + # A NaN or infinite end would make the export's state JSON invalid, and a larger one could + # overflow the band geometry. The constructor, assignment, load_state_dict and an update from + # the frontend (the end with a line_version bump) each reject it and change nothing. + message = re.escape(f"{name} must be a finite number within ±1e9, got {bad!r}.") + with pytest.raises(traitlets.TraitError, match=message): + ShowPDF(cal_pdf, k_min_fit=0.3, analysis_mode="line", **{name: bad}) + w = ShowPDF(cal_pdf, k_min_fit=0.3, line_perpendicular=True) + w.set_line(1, 0, 1, 3) + before = _synced(w) + with pytest.raises(traitlets.TraitError, match=message): + setattr(w, name, bad) + for state in ({name: bad, "line_mode": "linescan"}, {"line_mode": "linescan", name: bad}): + with pytest.raises(traitlets.TraitError, match=message): + w.load_state_dict(state) + with pytest.raises(traitlets.TraitError, match=message): + w.set_state({name: bad, "line_active": True, "line_version": w.line_version + 1}) + assert _synced(w) == before + # An end of exactly ±1e9 is accepted. + setattr(w, name, math.copysign(1e9, bad)) + w.line_version += 1 + assert getattr(w, name) == math.copysign(1e9, bad) + assert not w.status_message.startswith("Error") + + +def test_showpdf_line_end_error_states_the_limit(): + # The limit and its message come from one text, spelled "1e9" as in the docs and the + # set_line docstring (a :g format would give "1e+09"). + text = showpdf_module._LINE_END_LIMIT_TEXT + limit = showpdf_module._LINE_END_LIMIT + assert limit == float(text) + assert showpdf_module._LINE_END_ERROR.format("line_row0", 2 * limit) == ( + f"line_row0 must be a finite number within ±{text}, got {2 * limit!r}." + ) + docs = DOCS_PAGE.read_text(encoding="utf-8") + for source in (docs, ShowPDF.set_line.__doc__): + assert f"±{text}" in source + + +# --------------------------------------------------------------------------- +# Line scan: one curve per sample along the line +# --------------------------------------------------------------------------- + +# The synced y values of each plot_mode's curve. +_CURVE_Y_TRAIT = {"Ik": "ik_y_bytes", "Fk": "fk_y_bytes", "Gr": "gr_y_bytes", "gr": "pdf_y_bytes"} + + +def _line_bins(w, n_bins): + """Each line-scan sample's band positions, reconstructed independently of _linescan_samples. + + n_bins evenly spaced samples from A to B; each band position goes to its nearest sample. + """ + r0, c0, r1, c1 = w._effective_line() + dr, dc = r1 - r0, c1 - c0 + seg = dr * dr + dc * dc + length = np.sqrt(seg) + rows = np.arange(w.scan_rows)[:, None].astype(float) + cols = np.arange(w.scan_cols)[None, :].astype(float) + pr, pc = rows - r0, cols - c0 + t = (pr * dr + pc * dc) / seg + perp = np.abs(pr * dc - pc * dr) / length + band = (t >= 0) & (t <= 1) & (perp <= max(0.5, w.line_width / 2)) + if n_bins > 1: + bin_idx = np.minimum(np.floor(np.clip(t, 0, 1) * (n_bins - 1) + 0.5).astype(int), n_bins - 1) + else: + bin_idx = np.zeros(t.shape, dtype=int) + return band, bin_idx + + +def _assert_linescan_matches_masked(pdf, w, **kwargs): + """Every line-scan sample equals a ShowPDF computed on that sample's positions alone.""" + N, M = w.n_linescan, w.linescan_n_points + stack = _f32(w.linescan_bytes).reshape(N, M) + assert len(w.linescan_axis_bytes) == M * 4 + band, bin_idx = _line_bins(w, N) + checked = 0 + for i in range(N): + bin_mask = band & (bin_idx == i) + if not bin_mask.any(): + continue + ref_w = ShowPDF(pdf, plot_mode=w.plot_mode, **kwargs) + ref_w.set_mask(bin_mask) + ref = _f32(getattr(ref_w, _CURVE_Y_TRAIT[w.plot_mode]))[:M] + # Each sample runs the same calculate_Gr as a masked compute; only the summation order of + # the radial mean differs (float32 rounding). + scale = max(float(np.abs(ref).max()), 1e-6) + assert np.abs(stack[i] - ref).max() / scale < 1e-4, f"bin {i}" + checked += 1 + assert checked > 0 + + +def test_showpdf_linescan_bin_matches_averaged(): + # Well-conditioned data: a central beam keeps I(k) away from zero, so the background fit has + # a unique minimum. + radius = _detector_radius() + pattern = ( + 500.0 * np.exp(-(radius**2) / (2 * 2.5**2)) + + np.exp(-((radius - 5) ** 2) / 4) + + 0.3 * np.exp(-((radius - 10) ** 2) / 8) + ).astype(np.float32) + pdf = _uncalibrated_pdf(_noisy_scan(pattern, scan=(4, 4), noise=0.05, seed=0)) + + w = ShowPDF(pdf, plot_mode="Gr") + w.set_line(0, 0, 3, 3, width=3) + assert w.n_linescan == 0 # nothing is computed in averaged mode + w.line_mode = "linescan" + _assert_linescan_matches_masked(pdf, w) + + +def test_showpdf_linescan_after_masked_compute_uses_bin_data(cal_pdf): + # A masked compute leaves the radial-mean cache keyed to its mask; every sample must still use + # its own I(k). The data vary across the scan, so a stale I(k) would show. + w = ShowPDF(cal_pdf, plot_mode="Ik") + mask = np.zeros((4, 4), dtype=bool) + mask[:2] = True + w.set_mask(mask) + w.set_line(0, 0, 0, 3, width=1) + w.line_mode = "linescan" + _assert_linescan_matches_masked(cal_pdf, w) + + +def test_showpdf_linescan_leaves_the_band_average_consistent(cal_pdf, tmp_path): + # After a line scan, and after saving one, the averaged line computes the band average, not + # the last sample's I(k). + w = ShowPDF(cal_pdf, k_min_fit=0.3, density_value=0.05) + w.set_line(0, 0, 3, 3, width=3) + ref = ShowPDF(_calibrated_pdf(), k_min_fit=0.3, density_value=0.05) + ref.set_mask(w.mask) + for save in (False, True): + w.line_mode = "linescan" + assert w.n_linescan > 0 + if save: + w.save_results(tmp_path / "scan.zip") + w.line_mode = "averaged" + np.testing.assert_allclose(_f32(w.gr_y_bytes), _f32(ref.gr_y_bytes), rtol=0, atol=1e-5) + + +@pytest.mark.parametrize("plot_mode", ["Ik", "Fk", "Gr", "gr"]) +def test_showpdf_linescan_with_damping_and_density(cal_pdf, plot_mode): + kwargs = dict(k_min_fit=0.3, damp_origin_oscillations=True, density_value=0.05) + w = ShowPDF(cal_pdf, plot_mode=plot_mode, **kwargs) + w.set_line(0, 0, 3, 3, width=3) + w.line_mode = "linescan" + assert w.status_message == "" + assert w.n_linescan > 0 + _assert_linescan_matches_masked(cal_pdf, w, **kwargs) + + +def test_showpdf_linescan_with_lowpass(cal_pdf): + # Each sample's I(k) is float32 like calculate_radial_mean's, as the float32 Gaussian filter + # kernel needs. + w = ShowPDF(cal_pdf, k_min_fit=0.3, k_lowpass=0.05) + w.set_line(0, 0, 3, 3, width=3) + w.line_mode = "linescan" + assert not w.status_message.startswith("Line-scan error"), w.status_message + assert w.n_linescan > 0 + + +def test_showpdf_linescan_plot_mode_recompute(small_pdf): + w = ShowPDF(small_pdf, plot_mode="Gr") + w.set_line(1, 0, 1, 3, width=3) + w.line_mode = "linescan" + m_gr = w.linescan_n_points + assert m_gr > 0 + + w.plot_mode = "Ik" + assert w.n_linescan > 0 + assert w.linescan_n_points > 0 + # I(k) is sampled on the k grid, not the r grid, so the point count changes with the curve. + assert w.linescan_n_points != m_gr + + +def test_showpdf_linescan_clips_line_to_scan(cal_pdf, tmp_path, capsys): + # The perpendicular bisector of a line on the top edge leaves the scan; the sampled line stops + # at the edge, so its tip has data, and summary and metadata.txt report the clipped ends. + w = ShowPDF(cal_pdf, k_min_fit=0.3, line_perpendicular=True) + w.set_line(0, 0, 0, 3, width=1) + w.line_mode = "linescan" + er0, ec0, er1, ec1 = w._effective_line() + assert all(0 <= v <= 3 for v in (er0, ec0, er1, ec1)) + stack = _f32(w.linescan_bytes).reshape(w.n_linescan, w.linescan_n_points) + assert np.isfinite(stack).any(axis=1).all() + w.summary() + assert f"Sampled: ({er0:.0f}, {ec0:.0f}) → ({er1:.0f}, {ec1:.0f})" in capsys.readouterr().out + meta = _read_zip(w.save_results(tmp_path / "s.zip"))["metadata.txt"].decode() + assert f"sampled line: ({er0:.0f}, {ec0:.0f}) to ({er1:.0f}, {ec1:.0f})" in meta + + +def test_showpdf_linescan_thin_diagonal_takes_one_position_per_sample(cal_pdf): + w = ShowPDF(cal_pdf, k_min_fit=0.3, plot_mode="Ik") + w.set_line(0, 0, 3, 3, width=1) + w.line_mode = "linescan" + band, idx, n, _length, _positions = w._linescan_samples() + assert n == 4 + for i in range(n): + assert list(zip(*np.nonzero(band & (idx == i)))) == [(i, i)] + # Each row of the I(k) stack is exactly that scan position's I(k). + stack = _f32(w.linescan_bytes).reshape(n, w.linescan_n_points) + for i in range(n): + one = np.zeros((4, 4), dtype=bool) + one[i, i] = True + ref = ShowPDF(_calibrated_pdf(), k_min_fit=0.3, plot_mode="Ik") + ref.set_mask(one) + np.testing.assert_allclose(stack[i], _f32(ref.ik_y_bytes), rtol=1e-6) + + +def test_showpdf_linescan_max_bins(small_pdf): + w = ShowPDF(small_pdf) + w.set_line(0, 0, 3, 3, width=3) + w.line_mode = "linescan" + assert w.n_linescan == 4 # one sample per scan step along the longer axis + w.linescan_max_bins = 2 + assert w.n_linescan == 2 + restored = ShowPDF(small_pdf, state=w.state_dict()) + assert (restored.linescan_max_bins, restored.n_linescan) == (2, 2) + for bad in (0, -3): + with pytest.raises(traitlets.TraitError, match="^linescan_max_bins must be at least 1"): + ShowPDF(small_pdf, linescan_max_bins=bad) + + +@pytest.mark.parametrize( + "name, default, value, bad, message", + [ + ( + "linescan_view", "heatmap", "waterfall", "bogus", + "linescan_view must be 'heatmap' or 'waterfall', got 'bogus'", + ), + ("waterfall_count", 10, 5, 0, "waterfall_count must be at least 1, got 0"), + ], +) +def test_showpdf_linescan_view_options(small_pdf, name, default, value, bad, message): + w = ShowPDF(small_pdf) + assert getattr(w, name) == default + restored = ShowPDF(small_pdf, state=ShowPDF(small_pdf, **{name: value}).state_dict()) + assert getattr(restored, name) == value # saved, and so exported + with pytest.raises(traitlets.TraitError, match=message): + ShowPDF(small_pdf, **{name: bad}) + with pytest.raises(traitlets.TraitError, match=message): # an assignment is checked too + setattr(w, name, bad) + assert getattr(w, name) == default + + +# --------------------------------------------------------------------------- +# Line-scan sample positions +# --------------------------------------------------------------------------- + + +class _LineStub: + """ShowPDF's line geometry methods on a scan of the given size, without a dataset.""" + + _line_band_mask = ShowPDF._line_band_mask + _effective_line = ShowPDF._effective_line + _linescan_samples = ShowPDF._linescan_samples + + def __init__(self, line, width, perp, rows=40, cols=40, max_bins=256): + self.scan_rows, self.scan_cols = rows, cols + self.line_row0, self.line_col0, self.line_row1, self.line_col1 = line + self.line_width, self.line_perpendicular = width, perp + self.line_active, self.linescan_max_bins = True, max_bins + + +def _check_samples(band, idx, n, length, positions, eff, rows, cols): + """Assert that the samples run evenly from A to the tip B and none is empty.""" + r0, c0, r1, c1 = eff + assert all(float(v).is_integer() for v in eff) # the sampled line ends on scan positions + assert n == round(max(abs(r1 - r0), abs(c1 - c0))) + 1 + # evenly spaced from A (0) to the tip B (the line length) + np.testing.assert_allclose(positions, np.linspace(0.0, length, n), rtol=0, atol=1e-12) + assert positions[-1] == pytest.approx(np.hypot(r1 - r0, c1 - c0)) + # no empty samples; every band position goes to its nearest sample + rr = np.arange(rows, dtype=float)[:, None] + cc = np.arange(cols, dtype=float)[None, :] + s = ((rr - r0) * (r1 - r0) + (cc - c0) * (c1 - c0)) / length + assert (np.bincount(idx[band], minlength=n) > 0).all() + assert (np.abs(s[band] - positions[idx[band]]) <= length / (n - 1) / 2 + 1e-9).all() + + +@pytest.mark.parametrize( + "line, width, perp", + [ + ((0, 0, 0, 10), 1, False), + ((1.3, 0.4, 15.8, 12.1), 5, False), + ((19, 2, 3, 15), 3, False), + # the perpendicular bisector of a line with odd dr + dc has half-pixel ends + ((5, 3, 12, 9), 1, True), + ], +) +def test_showpdf_linescan_positions_are_evenly_spaced(line, width, perp): + # The widget's own samples; the sweep below checks the geometry for many more lines. + w = ShowPDF(_calibrated_pdf(scan=(20, 20), det=32), k_min_fit=0.3, line_perpendicular=perp) + w.set_line(*line, width=width) + w.line_mode = "linescan" + band, idx, n, length, positions = w._linescan_samples() + _check_samples(band, idx, n, length, positions, w._effective_line(), w.scan_rows, w.scan_cols) + assert w.n_linescan == n + stack = _f32(w.linescan_bytes).reshape(w.n_linescan, w.linescan_n_points) + assert np.isfinite(stack).any(axis=1).all() + + +def test_showpdf_linescan_sweep_ends_at_the_tip_without_empty_samples(): + # Every line from (20, 20) with integer ends up to 12 away, random fractional lines and lines + # that leave the 40x40 scan (clipped), as drawn and perpendicular, thin and wide. + rng = np.random.default_rng(3) + lines = [(20, 20, 20 + dr, 20 + dc) for dr in range(-12, 13) for dc in range(-12, 13) if dr or dc] + lines += [tuple(v) for v in rng.uniform(4, 36, size=(200, 4))] + lines += [tuple(v) for v in rng.uniform(-10, 50, size=(200, 4))] + checked = 0 + for line in lines: + for perp in (False, True): + for width in (1, 3): + stub = _LineStub(line, width, perp) + samples = stub._linescan_samples() + if samples is None or samples[2] < 2: # no band, or a single sample + continue + assert all(0 <= v <= 39 for v in stub._effective_line()) # clipped to the scan + _check_samples(*samples, stub._effective_line(), 40, 40) + checked += 1 + assert checked > 2000 + + +# --------------------------------------------------------------------------- +# Density (never estimated) and the hints that ask for it +# --------------------------------------------------------------------------- + + +def test_showpdf_density_is_needed_for_gr_and_damping(small_pdf): + # G(r) needs no density; g(r) and the low-r correction do, and the status line asks for it. + w = ShowPDF(small_pdf) + assert (w.density_value, w.n_points_pdf) == (0.0, 0) + assert w.n_points_gr > 0 + assert w.status_message.startswith("Ready") + w.plot_mode = "gr" + assert w.status_message == _DENSITY_HINT_GR + w.plot_mode = "Gr" + assert w.status_message == "" + w.damp_origin_oscillations = True + assert small_pdf.reduced_pdf_damped is None + assert w.status_message == _DENSITY_HINT_DAMPING + # With a density, the correction runs and g(r) is computed, clamped at 0. + w.density_value = 0.05 + assert small_pdf.reduced_pdf_damped is not None + assert small_pdf.rho0 == 0.05 + assert w.n_points_pdf == w.n_points_gr + assert _f32(w.pdf_y_bytes).min() >= 0.0 + assert w.status_message == "" + # Unsetting it empties g(r) again. + w.damp_origin_oscillations = False + w.plot_mode = "gr" + w.density_value = 0.0 + assert w.n_points_pdf == 0 + assert w.status_message == _DENSITY_HINT_GR + + +def test_showpdf_density_change_resyncs_damped_curves(cal_pdf): + # With damping on, a new density changes G(r) and F(k), not just g(r). + kwargs = dict(k_min_fit=0.3, damp_origin_oscillations=True) + w = ShowPDF(cal_pdf, density_value=0.05, **kwargs) + gr_05 = _f32(w.gr_y_bytes).copy() + w.density_value = 0.08 + ref = ShowPDF(_calibrated_pdf(), density_value=0.08, **kwargs) + assert not np.array_equal(_f32(w.gr_y_bytes), gr_05) + np.testing.assert_array_equal(_f32(w.gr_y_bytes), _f32(ref.gr_y_bytes)) + np.testing.assert_array_equal(_f32(w.fk_y_bytes), _f32(ref.fk_y_bytes)) + np.testing.assert_array_equal(_f32(w.pdf_y_bytes), _f32(ref.pdf_y_bytes)) + + +def test_showpdf_linescan_density_hints(cal_pdf): + w = ShowPDF(cal_pdf, plot_mode="Gr", damp_origin_oscillations=True, analysis_mode="line", line_mode="linescan") + assert w.n_linescan == 0 # no line yet + assert w.status_message == _DENSITY_HINT_DAMPING + w.set_line(0, 0, 3, 3, width=3) + assert w.n_linescan > 0 # the undamped G(r); the hint says why + assert w.status_message == _DENSITY_HINT_DAMPING + w.damp_origin_oscillations = False + w.plot_mode = "gr" # a g(r) stack needs the density + assert w.n_linescan == 0 + assert w.status_message == _DENSITY_HINT_GR + w.density_value = 0.05 + assert w.n_linescan > 0 + assert w.status_message == "" + + +# --------------------------------------------------------------------------- +# k window and the empty-window hint +# --------------------------------------------------------------------------- + + +def test_showpdf_window_defaults_follow_fit_range(cal_pdf): + w = ShowPDF(cal_pdf, k_min_fit=0.3, k_max_fit=1.4) + assert w.k_min_window == 0.0 and w.k_max_window == 0.0 + assert cal_pdf.kmin_window == pytest.approx(0.3) + assert cal_pdf.kmax_window == pytest.approx(1.4) + w.k_min_fit = 0.4 + assert cal_pdf.kmin_window == pytest.approx(0.4) + w.k_min_window = 0.5 + assert cal_pdf.kmin_window == pytest.approx(0.5) + + +# Windows with and without positive weight on cal_pdf's k axis (step 0.05 1/Å, ending at 1.55). +_EMPTY_WINDOWS = { + "lower end above the upper end, which follows the fit range": dict(k_max_fit=0.8, k_min_window=1.0), + "equal ends": dict(k_min_window=0.5, k_max_window=0.5), + "narrower than one k step": dict(k_min_window=0.51, k_max_window=0.54), + # a float64 hair outside two adjacent k bins: empty in the library's float32 + "ends on two adjacent k bins": dict(k_min_window=0.5 - 1e-9, k_max_window=0.55 + 1e-9), +} +_VALID_WINDOWS = { + "following the fit range": dict(), + "set inside the fit range": dict(k_min_window=0.35, k_max_window=1.45), + "narrower than one k step around a k bin": dict(k_min_window=0.49, k_max_window=0.51), +} + + +@pytest.mark.parametrize( + "window, empty", + [(w, True) for w in _EMPTY_WINDOWS.values()] + [(w, False) for w in _VALID_WINDOWS.values()], + ids=list(_EMPTY_WINDOWS) + list(_VALID_WINDOWS), +) +def test_showpdf_empty_window_hint(cal_pdf, window, empty): + # An empty window leaves F(k) and G(r) zero, but for the float32 Lorch weight at a k bin on the + # upper end (sin(pi) is about -8.7e-8); the hint uses the library's own window, so it agrees. + w = ShowPDF(cal_pdf, k_min_fit=0.3, density_value=0.05, **window) + fk, gr = _f32(w.fk_y_bytes), _f32(w.gr_y_bytes) + assert (w.status_message == _WINDOW_HINT) == empty + assert empty or w.status_message.startswith("Ready") + w.plot_mode = "gr" + assert (w.status_message == _WINDOW_HINT) == empty + # A valid window clears the hint; setting the window again brings it back. + w.k_max_fit, w.k_min_window, w.k_max_window = 1.5, 0.0, 0.0 + assert w.status_message == "" + full_fk, full_gr = _f32(w.fk_y_bytes), _f32(w.gr_y_bytes) + assert np.isfinite(full_fk).all() + # Zero relative to the full window's curves; the one k bin inside the narrowest valid window + # gives over 1e-3 of them. + assert (np.abs(fk).max() < 1e-6 * np.abs(full_fk).max()) == empty + assert (np.abs(gr).max() < 1e-6 * np.abs(full_gr).max()) == empty + for name, value in window.items(): + setattr(w, name, value) + assert w.status_message == (_WINDOW_HINT if empty else "") + + +def test_showpdf_window_hint_before_density_hint(cal_pdf): + w = ShowPDF(cal_pdf, k_min_fit=0.3, damp_origin_oscillations=True) + assert w.status_message == _DENSITY_HINT_DAMPING + w.k_min_window = 1.6 # above the upper end, which follows the fit range + assert w.status_message == _WINDOW_HINT + w.plot_mode = "gr" + w.damp_origin_oscillations = False + assert w.status_message == _WINDOW_HINT + # With the window valid again, the density hint is back. + w.k_min_window = 0.0 + assert w.status_message == _DENSITY_HINT_GR + w.damp_origin_oscillations = True + assert w.status_message == _DENSITY_HINT_DAMPING + + +def test_showpdf_linescan_empty_window_hint(cal_pdf): + w = ShowPDF(cal_pdf, k_min_fit=0.3, plot_mode="gr", analysis_mode="line", line_mode="linescan") + assert w.status_message == _DENSITY_HINT_GR # no line yet + w.k_min_window = 1.6 + assert w.status_message == _WINDOW_HINT + w.set_line(0, 0, 3, 3, width=3) + assert w.status_message == _WINDOW_HINT + w.k_min_window = 0.0 + assert w.status_message == _DENSITY_HINT_GR + w.density_value = 0.05 + assert w.status_message == "" + w.plot_mode = "Fk" + full = _f32(w.linescan_bytes) + w.k_max_window = 0.3 # equal to the lower end, which follows k_min_fit + assert w.status_message == _WINDOW_HINT + # Zero but for the library's float32 Lorch weight at k = 0.3 (about -3e-8). + assert w.n_linescan > 0 and np.abs(_f32(w.linescan_bytes)).max() < 1e-6 * np.abs(full).max() + w.k_max_window = 0.0 + assert w.status_message == "" + + +def test_showpdf_error_wins_over_window_hint(cal_pdf): + w = ShowPDF(cal_pdf, k_min_fit=0.3, k_min_window=1.6) + assert w.status_message == _WINDOW_HINT + w.r_max = -1.0 # empty r grid: calculate_Gr raises + assert w.status_message.startswith("Error") + w.r_max = 20.0 + assert w.status_message == _WINDOW_HINT + w.set_line(0, 0, 3, 3, width=3) + w.line_mode = "linescan" + assert w.status_message == _WINDOW_HINT + w.r_max = -1.0 + assert w.status_message.startswith("Line-scan error") + + +# --------------------------------------------------------------------------- +# Failed computes: the shown curves always belong to the shown selection +# --------------------------------------------------------------------------- + +_CURVE_TRAITS = ( + "n_points_ik", "ik_x_bytes", "ik_y_bytes", "ik_bg_y_bytes", "n_points_fk", "fk_x_bytes", + "fk_y_bytes", "n_points_gr", "gr_x_bytes", "gr_y_bytes", "n_points_pdf", "pdf_x_bytes", + "pdf_y_bytes", +) + + +def _curves(w): + """The synced curve traits of ``w`` by name.""" + return {name: getattr(w, name) for name in _CURVE_TRAITS} + + +def _columns(*cols): + """A 4x4 scan mask that selects the given columns.""" + mask = np.zeros((4, 4), dtype=bool) + mask[:, list(cols)] = True + return mask + + +# Each case sets up a selection, then selects other scan positions or data. +_SELECTION_CHANGES = { + "mask": (lambda w: w.set_mask(_columns(0, 1)), lambda w: w.set_mask(_columns(2, 3))), + "analysis mode": (lambda w: w.set_mask(_columns(0, 1)), lambda w: setattr(w, "analysis_mode", "probe")), + "probe position": (lambda w: setattr(w, "analysis_mode", "probe"), lambda w: setattr(w, "probe_col", 0)), + "probe size": (lambda w: setattr(w, "analysis_mode", "probe"), lambda w: setattr(w, "probe_size", 2)), + "line": (lambda w: w.set_line(0, 0, 0, 3, width=1), lambda w: w.set_line(3, 0, 3, 3)), + "line width": (lambda w: w.set_line(0, 0, 0, 3, width=1), lambda w: setattr(w, "line_width", 3)), + "perpendicular": ( + lambda w: w.set_line(1, 0, 1, 3, width=1), lambda w: setattr(w, "line_perpendicular", True) + ), + "data": (lambda w: None, lambda w: w.set_data(_calibrated_pdf(seed=1))), +} + + +@pytest.mark.parametrize("change", list(_SELECTION_CHANGES)) +def test_showpdf_failed_compute_clears_curves_of_another_selection(change): + setup, select_other = _SELECTION_CHANGES[change] + w = ShowPDF(_calibrated_pdf(), k_min_fit=0.3, density_value=0.05) + setup(w) + curves = _curves(w) + assert all(curves.values()) + # A refused parameter keeps the curves: they still belong to the shown selection. + w.r_max = -1.0 + assert w.status_message.startswith("Error") + assert _curves(w) == curves + # For other positions or data they would describe the previous selection (and a full export + # would recompute G(r) from its I(k)), so they are cleared. + select_other(w) + assert w.status_message.startswith("Error") + assert not any(_curves(w).values()) + # The next successful compute fills them for the new selection. + w.r_max = 20.0 + ref = ShowPDF(w.pdf, state=w.state_dict()) + assert w.status_message == ref.status_message == "" + assert _curves(w) == _curves(ref) + + +def test_showpdf_failed_compute_keeps_curves_of_the_same_positions(cal_pdf): + # Both lines snap to the same scan positions, so the curves still describe the shown selection. + w = ShowPDF(cal_pdf, k_min_fit=0.3) + w.set_line(1, 0, 1, 3, width=1) + w.r_max = -1.0 + curves = _curves(w) + w.set_line(1.2, 0, 0.8, 3) + assert w.status_message.startswith("Error") + assert _curves(w) == curves + + +# --------------------------------------------------------------------------- +# set_data: replacing the dataset +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("device", [{}, {"device": None}, {"device": "cpu"}], ids=["default", "None", "cpu"]) +def test_showpdf_set_data_takes_an_array_on_the_cpu(small_pdf, small_4d_array, device): + w = ShowPDF(small_pdf) + w.set_data(small_4d_array[:2, :3], **device, **FROM_DATA_KWARGS) + assert (w.scan_rows, w.scan_cols) == (2, 3) + assert w.n_points_gr > 0 + assert str(w.pdf.device) == "cpu" + + +def test_showpdf_set_data_resets_selection(cal_pdf): + # The mask, probe and line refer to the old scan grid, so they are reset (the 4x4 mask would + # not even reshape to the new 2x2 scan). + w = ShowPDF(cal_pdf, k_min_fit=0.3) + mask = np.zeros((4, 4), dtype=bool) + mask[:2] = True + w.set_mask(mask) + w.probe_row, w.probe_col = 3, 3 + small_2x2 = _calibrated_pdf(scan=(2, 2)) + w.set_data(small_2x2) + assert w.mask_b64 == "" and w.mask_bytes == b"" + assert w.mask_pixel_count == 4 + assert (w.probe_row, w.probe_col) == (1, 1) + assert not w.line_active + assert w.k_min_fit == 0.3 # still inside the new k range: kept + assert w.status_message == "" or w.status_message.startswith("Ready") + ref = ShowPDF(small_2x2, k_min_fit=0.3) + np.testing.assert_array_equal(_f32(w.gr_y_bytes), _f32(ref.gr_y_bytes)) + + +def test_showpdf_set_data_keeps_the_k_ranges_that_fit_the_new_data(small_pdf, cal_pdf): + # A fit range outside the new k range is reset to the full range, and a window end outside it + # to 0 (follow the fit range). + w = ShowPDF(small_pdf, k_min_fit=2.0, k_max_fit=14.0, k_max_window=13.0) + w.set_data(_calibrated_pdf()) + assert (w.k_min_fit, w.k_max_fit, w.k_max_window) == (w.k_min_available, w.k_max_available, 0.0) + # Ends typed at the edges of the same calibration are kept. + w = ShowPDF(cal_pdf, k_min_fit=0.3, k_max_fit=1.55, k_max_window=1.55) + w.set_data(_calibrated_pdf()) + assert (w.k_min_fit, w.k_max_fit, w.k_max_window) == (0.3, 1.55, 1.55) + # The default full range follows the new data. + w = ShowPDF(_calibrated_pdf()) + w.set_data(_calibrated_pdf(k_step=0.08)) + assert (w.k_min_fit, w.k_max_fit) == (w.k_min_available, w.k_max_available) + assert w.k_max_available > 2.0 + + +def test_showpdf_set_data_keeps_pixel_size_override(small_4d_array): + w = ShowPDF(small_4d_array, pixel_size=2.0, **FROM_DATA_KWARGS) + assert (w.nav_pixel_size, w.nav_unit) == (2.0, "Å") + w.set_data(small_4d_array, **FROM_DATA_KWARGS) + assert (w.nav_pixel_size, w.nav_unit) == (2.0, "Å") + w.set_data(small_4d_array, pixel_size=3.5, **FROM_DATA_KWARGS) + assert (w.nav_pixel_size, w.nav_unit) == (3.5, "Å") + + +# --------------------------------------------------------------------------- +# save_image +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name, kwargs", + [ + ("out.png", {}), + ("out.pdf", {}), + ("out.tiff", {}), + ("noext", {"format": "png"}), # format= for a path without an extension + ("Ik.png", {"plot_mode": "Ik"}), + ("Fk.png", {"plot_mode": "Fk"}), + ("Gr.png", {"plot_mode": "Gr"}), + ("gr.png", {"plot_mode": "gr"}), + ], +) +def test_showpdf_save_image(small_pdf, tmp_path, name, kwargs): + w = ShowPDF(small_pdf, density_value=0.05) # g(r) needs a density + assert w.save_image(tmp_path / name, **kwargs).stat().st_size > 0 + + +@pytest.mark.parametrize( + "name, kwargs, error", + [ + ("out.bmp", {}, "Unsupported format"), + ("out.png", {"plot_mode": "bogus"}, "Unknown plot_mode"), + ("gr.png", {"plot_mode": "gr"}, "density"), # g(r) without a density + ], +) +def test_showpdf_save_image_errors(small_pdf, tmp_path, name, kwargs, error): + with pytest.raises(ValueError, match=error): + ShowPDF(small_pdf).save_image(tmp_path / name, **kwargs) + + +def test_showpdf_save_image_rejects_linescan(small_pdf, tmp_path): + w = ShowPDF(small_pdf) + w.set_line(1, 0, 1, 3, width=3) + w.line_mode = "linescan" + with pytest.raises(ValueError, match="save_results"): + w.save_image(tmp_path / "x.png") + + +def test_showpdf_save_image_leaves_matplotlib_backend_alone(small_pdf, tmp_path, monkeypatch): + import matplotlib + + def no_switch(*args, **kwargs): + raise AssertionError("save_image must not switch the matplotlib backend") + + monkeypatch.setattr(matplotlib, "use", no_switch) + w = ShowPDF(small_pdf) + assert w.save_image(tmp_path / "x.png").stat().st_size > 0 + + +@pytest.mark.parametrize("view", ["g(r) without a density", "line scan"]) +def test_showpdf_saved_notebook_preview(cal_pdf, view): + # A notebook saved with save_state=False keeps a PNG of the scan image and the shown curve, also + # of an empty g(r) and of a line scan (which save_image refuses); a new view clears it. + from PIL import Image + + w = ShowPDF(cal_pdf, k_min_fit=0.3, plot_mode="gr") + if view == "line scan": + w.density_value = 0.05 + w.set_line(0, 0, 3, 3, width=3) + w.line_mode = "linescan" + assert "nav_image_bytes" not in w.get_state() # a notebook save, which stores the preview + assert w._static_fallback_mime == "image/png" + image = np.asarray(Image.open(io.BytesIO(base64.b64decode(w._static_fallback_jpeg)))) + assert image.shape[1] == 512 and image.std() > 5 + w.plot_mode = "Gr" + assert w._static_fallback_jpeg == "" + + +# --------------------------------------------------------------------------- +# save_results: a zip of the curves, metadata.txt and README.txt +# --------------------------------------------------------------------------- + + +def test_showpdf_save_results_mask(cal_pdf, tmp_path): + w = ShowPDF(cal_pdf, k_min_fit=0.3, damp_origin_oscillations=True, density_value=0.05) + files = _read_zip(w.save_results(tmp_path / "res")) + assert {"README.txt", "metadata.txt", "curves_k.csv", "curves_r.csv", "mask.npy"} <= set(files) + meta = files["metadata.txt"].decode() + assert "k_width" not in meta + assert "r_min" not in meta + assert "k_min_window: fit range" in meta + assert "density_value: 0.05\n" in meta + assert "density_mode" not in meta + curves_k = _read_csv(files, "curves_k.csv") + np.testing.assert_allclose(curves_k["Fk"], _f32(w.fk_y_bytes), rtol=1e-6) # damped F(k) + + +def test_showpdf_save_results_negative_window_end_follows_fit_range(cal_pdf, tmp_path): + # A window end at or below 0 follows the fit range, in the compute and in metadata.txt. + w = ShowPDF(cal_pdf, k_min_fit=0.3, k_max_fit=1.2, k_min_window=-0.5) + assert w._curve_params()["k_min_window"] is None + meta = _read_zip(w.save_results(tmp_path / "res"))["metadata.txt"].decode() + assert "k_min_window: fit range" in meta + + +@pytest.mark.parametrize("density", [0.0, -0.05]) +def test_showpdf_save_results_metadata_without_density(small_pdf, tmp_path, density): + # A zero or negative density is not set, in metadata.txt and README.txt alike; once it is set, + # README.txt says F(k) has the low-r correction. + w = ShowPDF(small_pdf, damp_origin_oscillations=True, density_value=density) + files = _read_zip(w.save_results(tmp_path / "res.zip")) + meta = files["metadata.txt"].decode() + assert "density_value: not set" in meta + assert "damp_origin_oscillations: on (not applied: density not set)" in meta + assert "k_min_fit: 0\n" in meta # a number, not "not set": the k axis starts at 0 + assert "k_lowpass: disabled" in meta + assert "(no low-r correction)" in files["README.txt"].decode() + w.density_value = 0.05 + readme = _read_zip(w.save_results(tmp_path / "damped.zip"))["README.txt"].decode() + assert "(with the low-r correction applied)" in readme + + +def test_showpdf_unset_density_clears_saved_gr(small_pdf, tmp_path): + w = ShowPDF(small_pdf, density_value=0.07) + assert small_pdf.pdf is not None + w.density_value = 0.0 + assert small_pdf.pdf is None + files = _read_zip(w.save_results(tmp_path / "res.zip")) + curves_r = _read_csv(files, "curves_r.csv") + assert np.isnan(curves_r["gr"]).all() + + +def test_showpdf_save_results_linescan(cal_pdf, tmp_path): + w = ShowPDF(cal_pdf, k_min_fit=0.3, density_value=0.05) + w.set_line(0, 0, 3, 2, width=3) + w.line_mode = "linescan" + files = _read_zip(w.save_results(tmp_path / "scan.zip")) + for name in ("linescan_Ik.csv", "linescan_Ik_bg.csv", "linescan_Fk.csv", "linescan_Gr.csv", "linescan_gr.csv"): + assert name in files, name + # The positions run evenly from A to B. + positions = _read_csv(files, "linescan_position.csv")["position_px"] + np.testing.assert_allclose(positions, np.linspace(0, np.hypot(3, 2), 4), atol=1e-5) + assert "evenly spaced" in files["README.txt"].decode() + + +def test_showpdf_save_refuses_after_compute_error(cal_pdf, tmp_path): + w = ShowPDF(cal_pdf, k_min_fit=0.3) + w.k_max_fit = 0.2 # below k_min_fit: the background fit fails + assert w.status_message.startswith("Error") + with pytest.raises(Exception): + w.save_results(tmp_path / "res.zip") + + +# --------------------------------------------------------------------------- +# Saved state: state_dict, save and state= +# --------------------------------------------------------------------------- + + +def _select_mask(w): + mask = np.zeros((4, 4), dtype=bool) + mask[:2, :2] = True + w.set_mask(mask) + + +def _select_perpendicular_line(w): + w.set_line(0, 1, 3, 2, width=3) + w.line_perpendicular = True + + +@pytest.mark.parametrize("select", [_select_mask, _select_perpendicular_line], ids=["mask", "line"]) +@pytest.mark.parametrize("via", ["state_dict", "file"]) +def test_showpdf_state_roundtrip(small_pdf, tmp_path, select, via): + w = ShowPDF(small_pdf, title="Test PDF", k_min_fit=1.5, r_max=15.0, plot_mode="Ik") + select(w) + state = w.state_dict() + if via == "file": + path = str(tmp_path / "state.json") + w.save(path) + envelope = json.loads(pathlib.Path(path).read_text()) + assert envelope["widget_name"] == "ShowPDF" + assert {"metadata_version", "widget_version", "state"} <= set(envelope) + state = path + restored = ShowPDF(small_pdf, state=state) + for key, value in w.state_dict().items(): + assert getattr(restored, key) == value, key + # Restoring rebuilds both the analysis mask and the frontend's repaint bytes. + np.testing.assert_array_equal(restored.mask, w.mask) + assert restored.mask_pixel_count == w.mask_pixel_count + assert restored.mask_bytes == w.mask_bytes + + +def test_showpdf_state_drops_mismatched_mask(small_pdf): + w1 = ShowPDF(small_pdf) + mask = np.zeros((4, 4), dtype=bool) + mask[:2, :2] = True + w1.set_mask(mask) + state = w1.state_dict() + + # Restore the 4x4 mask onto a 2x2 scan. + scan_y, scan_x = 2, 2 + ring = np.exp(-((_detector_radius() - 5) ** 2) / 4).astype(np.float32) + pdf2 = _uncalibrated_pdf(np.broadcast_to(ring, (scan_y, scan_x, DET, DET)).copy()) + w2 = ShowPDF(pdf2, state=state) + # The mismatched mask is dropped, not applied: the full scan is included. + assert w2.mask_b64 == "" + assert w2.mask_pixel_count == scan_y * scan_x + + +def test_showpdf_state_rejects_other_widget_file(small_pdf, tmp_path): + path = tmp_path / "other.json" + path.write_text(json.dumps({ + "metadata_version": "1.0", + "widget_name": "Show2D", + "widget_version": "0", + "state": {"title": "x"}, + })) + with pytest.raises(ValueError): + ShowPDF(small_pdf, state=str(path)) + + +@pytest.mark.parametrize( + "bad", + [{"linescan_max_bins": 0}, {"line_width": 2.5}, {"k_min_fit": 10**400}, {"line_width": 10**400 + 1}], + ids=["linescan_max_bins", "line_width", "k_min_fit too large", "line_width too large"], +) +def test_showpdf_rejected_state_changes_nothing(cal_pdf, bad): + # The valid keys listed before the rejected one are not kept either, and the widget still + # recomputes afterwards. + w = ShowPDF(cal_pdf, k_min_fit=0.3) + before = _synced(w) + state = { + "analysis_mode": "line", "line_col1": 3.0, "line_active": True, "line_width": 5, + "density_value": 0.05, "mask_b64": base64.b64encode(bytes(16)).decode(), **bad, + } + with pytest.raises(traitlets.TraitError): + w.load_state_dict(state) + assert _synced(w) == before + w.r_max = 7.0 + assert w.n_points_gr == int(round(7.0 / w.r_step)) + + +# --------------------------------------------------------------------------- +# States saved by earlier versions +# --------------------------------------------------------------------------- + + +def _old_state_file(path, widget, **saved): + """Save ``widget`` to ``path`` with the ``saved`` keys written into its state.""" + widget.save(str(path)) + envelope = json.loads(path.read_text()) + envelope["state"].update(saved) + path.write_text(json.dumps(envelope)) + return path + + +@pytest.mark.parametrize("saved", [float("nan"), float("inf"), float("-inf")], ids=repr) +def test_showpdf_old_state_nonfinite_density(cal_pdf, tmp_path, saved): + # Earlier versions saved an unset density as a NaN literal. A saved non-finite float density + # loads as 0 (not set) along with the other keys; assigning one is still rejected. + w = ShowPDF(cal_pdf, k_min_fit=0.3, density_value=0.05) + before = _synced(w) + # A saved string is rejected as any string is, and a mapped density is undone with the rest + # of a rejected load. + for state, rejected in ( + ({"density_value": str(saved)}, "'density_value' trait"), + ({"density_value": saved, "linescan_max_bins": 0}, "^linescan_max_bins"), + ): + with pytest.raises(traitlets.TraitError, match=rejected): + w.load_state_dict(state) + assert _synced(w) == before + w.load_state_dict({"density_value": saved, "r_max": 12.0}) + assert (w.density_value, w.r_max) == (0.0, 12.0) + + saved_widget = ShowPDF(cal_pdf, k_min_fit=0.3, r_max=12.0, plot_mode="Fk") + path = _old_state_file(tmp_path / "old_state.json", saved_widget, density_value=saved) + assert re.search(r'"density_value": -?(NaN|Infinity)', path.read_text()) + restored = ShowPDF(cal_pdf, state=str(path)) + assert (restored.density_value, restored.r_max, restored.plot_mode) == (0.0, 12.0, "Fk") + with pytest.raises(traitlets.TraitError, match="^density_value must be a finite number"): + restored.density_value = saved + + +@pytest.mark.parametrize("old_mode, expected", [("estimated", 0.0), ("manual", 0.06)]) +def test_showpdf_old_state_density_mode(small_pdf, old_mode, expected): + # An "estimated" density_value came from the removed estimator, not from the user, so it is + # not adopted as the fixed density. + w = ShowPDF(small_pdf) + w.load_state_dict({"density_mode": old_mode, "density_value": 0.06}) + assert w.density_value == expected + assert not hasattr(w, "density_mode") + + +@pytest.mark.parametrize("key, value", [("k_width", 1.0), ("r_min", 0.5)]) +def test_showpdf_old_state_removed_keys_are_ignored(small_pdf, tmp_path, key, value): + # The window's taper width is fixed and the r grid always starts at 0, so a saved k_width or + # r_min is ignored, from a dict or from a file. + ref = ShowPDF(small_pdf, r_max=12.0) + assert _f32(ref.gr_x_bytes)[0] == 0.0 + w = ShowPDF(small_pdf) + w.load_state_dict({key: value, "r_max": 12.0}) + path = _old_state_file(tmp_path / "old_state.json", ref, **{key: value}) + for restored in (w, ShowPDF(small_pdf, state={key: value, "r_max": 12.0}), ShowPDF(small_pdf, state=str(path))): + assert restored.r_max == 12.0 + for curve in ("fk_y_bytes", "gr_x_bytes", "gr_y_bytes"): + assert getattr(restored, curve) == getattr(ref, curve), curve + assert not hasattr(restored, key) and key not in restored.state_dict() + + +@pytest.mark.parametrize("saved, expected", [(4, 5), (2, 3), (0, 1), (-3, 1), (1, 1), (5, 5)]) +def test_showpdf_old_state_line_width(small_pdf, saved, expected): + # Widths did not have to be odd: an even width w selected w + 1 rows of a horizontal line and + # a width below 1 acted as 1, so they load as the odd width that selects the same rows. An + # even width warns, since a diagonal band of width w + 1 can include more positions. + line = (10, 4, 10, 15) + np.testing.assert_array_equal( + _LineStub(line, saved, False)._line_band_mask(), _LineStub(line, expected, False)._line_band_mask() + ) + w = ShowPDF(small_pdf) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + w.load_state_dict({"line_width": saved}) + restored = ShowPDF(small_pdf, state={"line_width": saved}) + assert w.line_width == restored.line_width == expected + notices = [c for c in caught if "saved line_width" in str(c.message)] + if saved >= 2 and saved % 2 == 0: + assert len(notices) == 2 # one per load + assert notices[0].filename == __file__ # load_state_dict's points at the caller + for notice in notices: + assert re.search( + rf"saved line_width {saved} is even; it loads as line_width {saved + 1}.*" + r"diagonal band may include more positions than before", + str(notice.message), + ) + else: + assert notices == [] + + +def test_showpdf_rejected_state_with_even_line_width_does_not_warn(small_pdf): + # A load that another key rejects keeps the old width, so it must not say the width loads + # as w + 1. + w = ShowPDF(small_pdf) + state = {"line_width": 4, "linescan_max_bins": 0} + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(traitlets.TraitError, match="^linescan_max_bins must be at least 1"): + w.load_state_dict(state) + with pytest.raises(traitlets.TraitError, match="^linescan_max_bins must be at least 1"): + ShowPDF(small_pdf, state=state) + assert not [c for c in caught if "saved line_width" in str(c.message)] + assert w.line_width == 3 + with pytest.warns(UserWarning, match="saved line_width 4 is even"): + w.load_state_dict({**state, "linescan_max_bins": 7}) + assert (w.line_width, w.linescan_max_bins) == (5, 7) + + +# --------------------------------------------------------------------------- +# summary and repr +# --------------------------------------------------------------------------- + + +def test_showpdf_summary(small_pdf, capsys): + ShowPDF(small_pdf, title="My PDF", damp_origin_oscillations=True).summary() + out = capsys.readouterr().out + assert "My PDF" in out + assert "4 × 4" in out # scan shape + assert "Mask:" in out + assert "Window:" in out and "k_width" not in out + assert "Output: r=[0.00, 20.00]" in out + assert "Density: not set" in out + assert "skipped until the density is set" in out + ShowPDF(small_pdf, damp_origin_oscillations=True, density_value=0.05).summary() + out = capsys.readouterr().out + assert "Density: 0.05 atoms/ų" in out + assert "skipped" not in out + + +def test_showpdf_summary_and_repr_describe_the_selection(small_pdf, capsys): + w = ShowPDF(small_pdf) + assert "ShowPDF" in repr(w) and "scan=(4, 4)" in repr(w) + # A mask painted in the frontend, which writes mask_b64 and then bumps mask_version. + painted = np.ones((4, 4), dtype=np.uint8) + painted[:2] = 0 + w.mask_b64 = base64.b64encode(painted.tobytes()).decode("ascii") + w.mask_version += 1 + assert w.mask_pixel_count == 8 + w.summary() + assert "Mask: 8 px (50.0%)" in capsys.readouterr().out + assert "mask=8px" in repr(w) + # In probe mode both describe the probe, not the painted mask. + w.analysis_mode = "probe" + w.probe_size = 2 + w.summary() + out = capsys.readouterr().out + assert "Probe: (2, 2), 3 × 3, 9 px" in out + assert "Mask:" not in out + assert "mask=" not in repr(w) + + +# --------------------------------------------------------------------------- +# Text shared with the frontend and the docs +# --------------------------------------------------------------------------- + + +def test_showpdf_status_hints_match_frontend(): + source = FRONTEND_SOURCE.read_text(encoding="utf-8") + assert f'"{_DENSITY_HINT_DAMPING}"' in source + assert f'"{_DENSITY_HINT_GR}"' in source + assert re.search(rf'\bWINDOW_HINT = "{re.escape(_WINDOW_HINT)}"', source) + # The docs quote the window hint too, wrapped across lines. + docs = DOCS_PAGE.read_text(encoding="utf-8") + assert f'"{_WINDOW_HINT}"' in " ".join(docs.split()) + + +def test_showpdf_export_labels_match_frontend(): + source = FRONTEND_SOURCE.read_text(encoding="utf-8") + assert f'"{_export_label(False)}"' in source + assert f'"{_export_label(True)}"' in source + + +def test_showpdf_docs_say_a_full_export_without_curves_is_locked(): + docs = DOCS_PAGE.read_text(encoding="utf-8") + for source in (docs, ShowPDF.export_html.__doc__): + text = " ".join(source.split()).replace("``", "`") + assert ( + '`"full"` export made when no curves were computed for the shown selection ' + "(its compute failed) keeps its parameter controls locked too and shows the baked error." + ) in text diff --git a/tests/showpdf/test_showpdf_export.py b/tests/showpdf/test_showpdf_export.py new file mode 100644 index 000000000..a0b08b691 --- /dev/null +++ b/tests/showpdf/test_showpdf_export.py @@ -0,0 +1,374 @@ +"""Tests for the standalone interactive HTML export of ShowPDF.""" + +import base64 +import inspect +import json +import re + +import numpy as np +import pytest +import traitlets + +# ShowPDF needs the PairDistributionFunction API of quantem PR #248 (calculate_Gr with density +# and max_iter); skip until the installed quantem has it. +_diffraction = pytest.importorskip("quantem.diffraction") +_PDF = getattr(_diffraction, "PairDistributionFunction", None) +if _PDF is None or not {"density", "max_iter"} <= set(inspect.signature(_PDF.calculate_Gr).parameters): + pytest.skip( + "ShowPDF needs PairDistributionFunction.calculate_Gr(density=..., max_iter=...)", + allow_module_level=True, + ) + +from quantem.core.datastructures.dataset4dstem import Dataset4dstem # noqa: E402 +from quantem.diffraction import PairDistributionFunction # noqa: E402 +from quantem.widget import ShowPDF # noqa: E402 + + +@pytest.fixture +def small_pdf(): + """PairDistributionFunction of a 4x4 scan of 32x32 patterns with two broad rings and a little noise. + + The origin is fixed at the detector center (find_origin=False, for speed). + """ + scan_y, scan_x, det_h, det_w = 4, 4, 32, 32 + rr, cc = np.mgrid[0:det_h, 0:det_w] + center_r, center_c = (det_h - 1) / 2.0, (det_w - 1) / 2.0 + radius = np.sqrt((rr - center_r) ** 2 + (cc - center_c) ** 2) + + dp = np.exp(-((radius - 5) ** 2) / 4) + 0.3 * np.exp(-((radius - 10) ** 2) / 8) + dp = dp.astype(np.float32) + + rng = np.random.RandomState(42) + data = np.empty((scan_y, scan_x, det_h, det_w), dtype=np.float32) + for iy in range(scan_y): + for ix in range(scan_x): + data[iy, ix] = dp + 0.01 * rng.randn(det_h, det_w).astype(np.float32) + + ds = Dataset4dstem.from_array(data) + return PairDistributionFunction.from_data( + ds, + find_origin=False, + origin_row=center_r, + origin_col=center_c, + num_annular_bins=36, + radial_step=1.0, + device="cpu", + ) + + +def _varied_pdf(): + """PairDistributionFunction of a 4x4 scan whose ring moves outwards and brightens across the scan. + + Every k bin then spans a wide range of intensities over the scan positions. + """ + scan_y, scan_x, det = 4, 4, 32 + rr, cc = np.mgrid[0:det, 0:det] + center = (det - 1) / 2.0 + radius = np.hypot(rr - center, cc - center) + data = np.empty((scan_y, scan_x, det, det), dtype=np.float32) + for iy in range(scan_y): + for ix in range(scan_x): + i = iy * scan_x + ix + data[iy, ix] = (1.0 + 0.5 * i) * np.exp(-((radius - (5.0 + 0.4 * i)) ** 2) / 4) + return PairDistributionFunction.from_data( + Dataset4dstem.from_array(data), + find_origin=False, + origin_row=center, + origin_col=center, + num_annular_bins=36, + radial_step=1.0, + device="cpu", + ) + + +def _reject_constant(name): + raise ValueError(f"{name} in the widget state is not valid JSON") + + +def _widget_state(html): + """The ShowPDF model entry of an exported file's widget-state JSON. + + The JSON is parsed strictly: NaN or Infinity, which the browser's JSON.parse rejects (the + page then never renders), fails the test. + """ + match = re.search( + r'<script type="application/vnd.jupyter.widget-state\+json">\s*(\{.*?\})\s*</script>', + html, + re.S, + ) + assert match, "no widget state in export" + models = json.loads(match.group(1), parse_constant=_reject_constant)["state"] + entries = [m for m in models.values() if m.get("state", {}).get("scan_rows") is not None] + assert len(entries) == 1 + return entries[0] + + +def _buffers(entry): + """The binary buffers of a model entry, by trait name.""" + return {b["path"][0]: base64.b64decode(b["data"]) for b in entry.get("buffers", [])} + + +def _export_entry(w, path, **kwargs): + """Export ``w`` to ``path`` and return the ShowPDF model entry of the written file.""" + return _widget_state(w.export_html(path, **kwargs).read_text(errors="ignore")) + + +# --------------------------------------------------------------------------- +# export_html: the standalone file +# --------------------------------------------------------------------------- + + +def test_showpdf_export_html_file(small_pdf, tmp_path): + w = ShowPDF(small_pdf, title="Amorphous PDF") + out = w.export_html(tmp_path / "pdf_view.html") + assert out.exists() + html = out.read_text() + # The anywidget bundle and the ipywidgets manager state are baked into the file. + assert "application/vnd.jupyter.widget-state+json" in html + assert "anywidget" in html or "AnyModel" in html + assert 'name="viewport"' in html # standalone shell (ensure_mobile_viewport) + entry = _widget_state(html) + state = entry["state"] + # The file carries the widget's state and data, with its own export button disabled: a + # kernel-less page cannot serve another export. + assert state["scan_rows"] == 4 + assert state["export_enabled"] is False + assert state["offline"] is True + assert state["export_status"] == "" + assert "_offline" not in state + buffers = _buffers(entry) + assert buffers["export_payload"] == b"" + # Every export carries the exact float64 k axis for the browser recompute. + np.testing.assert_array_equal(np.frombuffer(buffers["offline_qq"], np.float64), small_pdf.qq) + size_mb = out.stat().st_size / 1048576 + assert w.export_status == f"Exported pdf_view.html ({size_mb:.1f} MB, exact float32 curves)" + # The live widget is left as it was. + assert w.export_enabled is True + assert w.offline is False + assert w.export_payload == b"" + assert w.export_request == "" + assert w.offline_qq == b"" + + +def test_showpdf_export_keeps_what_a_saved_notebook_leaves_out(small_pdf, tmp_path): + # With save_state=False a saved notebook leaves out the scan image, mask and line-scan stack; + # the exported page needs them, and its saved-notebook preview is dropped. + w = ShowPDF(small_pdf) + mask = np.zeros((4, 4), dtype=bool) + mask[:2] = True + w.set_mask(mask) + w.set_line(0, 0, 3, 3, width=3) + w.line_mode = "linescan" + assert "nav_image_bytes" not in w.get_state() + entry = _export_entry(w, tmp_path / "scan.html") + buffers = _buffers(entry) + for name in ("nav_image_bytes", "mask_bytes", "linescan_bytes"): + assert buffers.get(name), name + assert entry["state"]["mask_b64"] == w.mask_b64 != "" + assert entry["state"]["_static_fallback_jpeg"] == "" + + +def test_showpdf_export_html_default_path(small_pdf, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + w = ShowPDF(small_pdf, title="My PDF") + out = w.export_html() + assert out.exists() + assert out.name == "my_pdf_4x4_exact.html" + assert out.parent == tmp_path + assert w.export_html(encoding="uint8").name == "my_pdf_4x4_quantized.html" + + +def test_showpdf_export_uint8_profiles_are_within_half_a_quantization_step(tmp_path): + # A "uint8" export holds every position's radial profile, each within half an 8-bit step of + # that k bin's range over the scan: it stores round((profile - lo) / (hi - lo) * 255). The live + # widget never holds the (possibly large) profiles, and a "full" export has none. + w = ShowPDF(_varied_pdf()) + entry = _export_entry(w, tmp_path / "varied.html", encoding="uint8") + state, buffers = entry["state"], _buffers(entry) + assert state["offline_recompute"] is True + assert w.export_status.endswith("exact float32 curves + quantized uint8 profiles)") + assert (w.offline_profiles, w.offline_recompute) == (b"", False) + rows, cols, n_k = state["offline_rows"], state["offline_cols"], state["offline_nk"] + levels = np.frombuffer(buffers["offline_profiles"], np.uint8).reshape(rows, cols, n_k) + lo = np.frombuffer(buffers["offline_profiles_lo"], np.float32).astype(np.float64) + hi = np.frombuffer(buffers["offline_profiles_hi"], np.float32).astype(np.float64) + profiles = lo + levels / 255.0 * (hi - lo) + assert (hi - lo).max() > 0.1 * np.abs(profiles).max() # the positions really differ + + half_step = (hi - lo) / 255.0 / 2.0 + + def radial_mean(mask): + return np.asarray(w._pdf.calculate_radial_mean(mask_realspace=mask, returnval=True), float) + + for row in range(rows): + for col in range(cols): + mask = np.zeros((rows, cols), dtype=bool) + mask[row, col] = True + exact = radial_mean(mask) + error = np.abs(profiles[row, col] - exact) + assert np.all(error <= half_step + 1e-5 * np.abs(exact).max()), (row, col, error.max()) + # The page averages the selected positions' profiles, which gives the library's radial mean. + mask = np.zeros((rows, cols), dtype=bool) + mask[: rows // 2] = True + exact = radial_mean(mask) + assert np.all(np.abs(profiles[mask].mean(axis=0) - exact) <= half_step + 1e-5 * np.abs(exact).max()) + + entry = _export_entry(w, tmp_path / "full.html", encoding="full") + assert entry["state"]["offline_recompute"] is False + assert _buffers(entry).get("offline_profiles", b"") == b"" + + +def test_showpdf_export_html_rejects_unsupported_options(small_pdf, tmp_path): + w = ShowPDF(small_pdf) + with pytest.raises(ValueError, match="mode='single'"): + w.export_html(tmp_path / "a.html", mode="folder") + with pytest.raises(ValueError, match="encoding"): + w.export_html(tmp_path / "b.html", encoding="float16") + with pytest.raises(NotImplementedError, match="downsample"): + w.export_html(tmp_path / "c.html", downsample=2) + with pytest.raises(TypeError): + w.export_html(tmp_path / "d.html", quantized=True) # use encoding="uint8" + + +# --------------------------------------------------------------------------- +# Export requests from the frontend's Export menu +# --------------------------------------------------------------------------- + + +def test_showpdf_export_request_download_roundtrip(small_pdf): + # A download request builds the file into export_payload; a clear request releases it. + w = ShowPDF(small_pdf, title="PDF") + w.export_request = json.dumps( + {"mode": "single", "download": True, "id": "abc123", "filename": "pdf.html", "encoding": "full"} + ) + assert w.export_payload_id == "abc123" + assert w.export_filename == "pdf.html" + assert len(w.export_payload) > 0 + assert b"application/vnd.jupyter.widget-state+json" in w.export_payload + assert w.export_status.startswith("Ready pdf.html (") + assert w.export_status.endswith(", exact float32 curves)") + # The live widget stays interactive after serving the export. + assert w.export_enabled is True + first_size = len(w.export_payload) + + w.export_request = json.dumps({"mode": "clear", "id": "abc123-clear"}) + assert w.export_payload == b"" + assert w.export_payload_id == "" + assert w.export_filename == "" + + # A second export does not embed the first one's payload, even while that is still held. + w.export_request = json.dumps( + {"mode": "single", "download": True, "id": "def456", "filename": "pdf.html"} + ) + w.export_request = json.dumps( + {"mode": "single", "download": True, "id": "ghi789", "filename": "pdf.html"} + ) + assert abs(len(w.export_payload) - first_size) < first_size * 0.1 + + # A uint8 request bakes the profiles into the file. + w.export_request = json.dumps( + {"mode": "single", "download": True, "id": "u8", "filename": "pdf.html", "encoding": "uint8"} + ) + assert w.export_status.endswith("exact float32 curves + quantized uint8 profiles)") + entry = _widget_state(w.export_payload.decode("utf-8", errors="ignore")) + assert entry["state"]["offline_recompute"] is True + + +def test_showpdf_export_request_without_download_writes_file(small_pdf, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + w = ShowPDF(small_pdf, title="PDF") + w.export_request = json.dumps({"mode": "single", "id": "file1"}) + assert (tmp_path / "pdf_4x4_exact.html").exists() + assert w.export_status.startswith("Exported pdf_4x4_exact.html") + assert w.export_payload == b"" + + +def test_showpdf_export_request_failure_is_reported(small_pdf): + w = ShowPDF(small_pdf) + w.export_request = json.dumps({"mode": "single", "download": True, "id": "x", "encoding": "png"}) + assert w.export_status.startswith("Export failed:") + + +# --------------------------------------------------------------------------- +# What the exported state holds after failed computes and non-finite values +# --------------------------------------------------------------------------- + + +def test_showpdf_export_html_has_no_curves_of_another_selection(small_pdf, tmp_path): + # A compute that fails for a new selection clears the curves, so a full export cannot + # recompute G(r) from the previous selection's I(k): the file has none. + w = ShowPDF(small_pdf) + left = np.zeros((4, 4), dtype=bool) + left[:, :2] = True + w.set_mask(left) + w.r_max = -1.0 # refused: the curves are kept, they belong to the shown mask + entry = _export_entry(w, tmp_path / "kept.html") + assert entry["state"]["status_message"].startswith("Error") + assert _buffers(entry)["ik_y_bytes"] == w.ik_y_bytes != b"" + w.set_mask(~left) # refused too, for other scan positions + entry = _export_entry(w, tmp_path / "cleared.html") + assert entry["state"]["status_message"].startswith("Error") + assert entry["state"]["mask_b64"] == w.mask_b64 + assert _buffers(entry)["ik_y_bytes"] == b"" + + +@pytest.mark.parametrize( + "pixels, expected", + [ + ({(1, 2): np.nan}, (0.0, 15.0)), + ({(1, 2): np.nan, (0, 0): np.inf, (3, 3): -np.inf}, (1.0, 14.0)), + (None, (0.0, 0.0)), + ], + ids=["nan", "nan and inf", "all nan"], +) +def test_showpdf_export_with_nonfinite_nav_pixels(small_pdf, tmp_path, pixels, expected): + # nav_data_min and nav_data_max are the range of the finite pixels (as findDataRange in the + # frontend computes it), so the state JSON stays valid; the pixels are exported unchanged. + nav = np.arange(16, dtype=np.float32).reshape(4, 4) + if pixels is None: + nav[:] = np.nan + else: + for (row, col), value in pixels.items(): + nav[row, col] = value + w = ShowPDF(small_pdf, nav_image=nav) + assert (w.nav_data_min, w.nav_data_max) == expected + for encoding in ("full", "uint8"): + entry = _export_entry(w, tmp_path / f"{encoding}.html", encoding=encoding) + assert (entry["state"]["nav_data_min"], entry["state"]["nav_data_max"]) == expected + exported = np.frombuffer(_buffers(entry)["nav_image_bytes"], np.float32) + np.testing.assert_array_equal(exported, nav.ravel()) + w.set_data(small_pdf, nav_image=nav[::-1]) + assert (w.nav_data_min, w.nav_data_max) == expected + + +def test_showpdf_export_refuses_nonfinite_float_traits(small_pdf, tmp_path): + # NaN or infinity would make the state JSON invalid. Parameters reject them when set; any other + # float trait holding one stops the export with an error naming it, before anything is written. + w = ShowPDF(small_pdf) + floats = sorted(name for name, trait in w.traits(sync=True).items() if isinstance(trait, traitlets.Float)) + refused = [] + for name in floats: + old = getattr(w, name) + for bad in (float("nan"), float("inf")): + try: + setattr(w, name, bad) + except traitlets.TraitError: + continue # a parameter, rejected when set + path = tmp_path / f"{name}-{bad}" / "sub" / "x.html" + message = rf"^Cannot export: {name} is {bad!r}, which is not valid JSON" + with pytest.raises(ValueError, match=message): + w.export_html(path) + assert not path.parent.parent.exists() + w.export_request = json.dumps( + {"mode": "single", "download": True, "id": f"{name}-{bad}", "filename": "x.html"} + ) + assert w.export_status.startswith(f"Export failed: Cannot export: {name} is {bad!r}") + assert w.export_payload == b"" + setattr(w, name, old) + refused.append(name) + assert sorted(set(refused)) == [ + "k_max_available", "k_min_available", "mask_fraction", "nav_data_max", "nav_data_min", + "nav_pixel_size", + ] + # With every trait finite again, the export's JSON parses strictly. + _export_entry(w, tmp_path / "finite.html") diff --git a/tests/test_html_export_protocol.py b/tests/test_html_export_protocol.py index 143b569b7..0c6fcd217 100644 --- a/tests/test_html_export_protocol.py +++ b/tests/test_html_export_protocol.py @@ -13,8 +13,16 @@ from quantem.widget import Show4DSTEM from quantem.widget.showdiffraction import ShowDiffraction +from quantem.widget.showpdf import ShowPDF, _pdf_class -EXPORT_WIDGET_CLASSES = (Show1D, Show2D, Show3D, Show3DSlices, Show4DSTEM, ShowDiffraction) +try: # ShowPDF needs quantem's PairDistributionFunction API (quantem pull request 248) + _pdf_class() +except ImportError: + ShowPDF = None + +EXPORT_WIDGET_CLASSES = (Show1D, Show2D, Show3D, Show3DSlices, Show4DSTEM, ShowDiffraction) + ( + (ShowPDF,) if ShowPDF is not None else () +) def _show1d() -> Show1D: @@ -73,6 +81,14 @@ def _showdiffraction() -> ShowDiffraction: return ShowDiffraction(data, title="Protocol ShowDiffraction", verbose=False) +def _showpdf(): + rr, cc = np.mgrid[0:16, 0:16] + rad = np.hypot(rr - 7.5, cc - 7.5) + pattern = (np.exp(-((rad - 4.0) ** 2) / 3.0) + 0.1).astype(np.float32) + data = np.broadcast_to(pattern, (2, 2, 16, 16)).copy() + return ShowPDF(data, title="Protocol ShowPDF", find_origin=False, origin_row=7.5, origin_col=7.5) + + EXPORT_WIDGET_CASES = ( pytest.param(_show1d, {"encoding": "full"}, {"mode": "single", "encoding": "full"}, "Protocol Show1D", id="show1d"), pytest.param(_show2d, {"encoding": "full"}, {"mode": "single", "encoding": "full"}, "Protocol Show2D", id="show2d"), @@ -80,6 +96,10 @@ def _showdiffraction() -> ShowDiffraction: pytest.param(_show3dslices, {"encoding": "full"}, {"mode": "single", "encoding": "full"}, "Protocol Show3DSlices", id="show3dslices"), pytest.param(_show4dstem, {"dtype": "uint8"}, {"mode": "single", "dtype": "uint8"}, "Protocol Show4DSTEM", id="show4dstem"), pytest.param(_showdiffraction, {}, {"mode": "single"}, "Protocol ShowDiffraction", id="showdiffraction"), +) + ( + (pytest.param(_showpdf, {"encoding": "uint8"}, {"mode": "single", "encoding": "uint8"}, "Protocol ShowPDF", id="showpdf"),) + if ShowPDF is not None + else () ) diff --git a/tests/test_save_state.py b/tests/test_save_state.py index 53ef942ae..dada89dd2 100644 --- a/tests/test_save_state.py +++ b/tests/test_save_state.py @@ -1,4 +1,4 @@ -"""Regression shield for the ``save_state`` contract (Plot2D / Show1D / Show2D / Show3D / Show4DSTEM). +"""Regression shield for the ``save_state`` contract (Plot2D / Show1D / Show2D / Show3D / Show4DSTEM / ShowPDF). Background: an anywidget syncs its pixel buffers as ``sync=True`` traits. On notebook save, ipywidgets serializes those buffers into ``metadata.widgets`` - @@ -39,6 +39,14 @@ from quantem.widget.show4dstem.export import export_clone +from quantem.widget.showpdf import ShowPDF, _pdf_class + +try: # ShowPDF needs quantem's PairDistributionFunction API (quantem pull request 248) + _pdf_class() +except ImportError: + ShowPDF = None + + IMAGE_MIME_KEYS = ("image/jpeg", "image/webp", "image/png") @@ -99,11 +107,19 @@ def _make(widget, *, save_state): if widget is Show3D: return Show3D(np.random.rand(4, 128, 128).astype("float32"), save_state=save_state), "_offline_float_stack" + if widget is ShowPDF: + rr, cc = np.mgrid[0:16, 0:16] + pattern = (np.exp(-((np.hypot(rr - 7.5, cc - 7.5) - 4.0) ** 2) / 3.0) + 0.1).astype("float32") + data = np.broadcast_to(pattern, (4, 4, 16, 16)).copy() + return ShowPDF(data, find_origin=False, origin_row=7.5, origin_col=7.5, + save_state=save_state), "nav_image_bytes" return Show4DSTEM(np.random.rand(8, 8, 16, 16).astype("float32"), save_state=save_state), "virtual_image_bytes" -WIDGETS = [Plot2D, Show1D, Show2D, Show3D, Show4DSTEM] +WIDGETS = [Plot2D, Show1D, Show2D, Show3D, Show4DSTEM] + ([ShowPDF] if ShowPDF is not None else []) +# Line plots keep a lossless PNG preview; the image viewers a JPEG. +PNG_PREVIEW_WIDGETS = (Plot2D, ShowPDF) @pytest.mark.parametrize("widget", WIDGETS) @@ -140,7 +156,7 @@ def test_static_fallback_present(widget): bundle = w._repr_mimebundle_() data = bundle[0] if isinstance(bundle, tuple) else bundle image_keys = [key for key in IMAGE_MIME_KEYS if key in (data or {})] - assert image_keys == ["image/png" if widget is Plot2D else "image/jpeg"], ( + assert image_keys == ["image/png" if widget in PNG_PREVIEW_WIDGETS else "image/jpeg"], ( f"{widget.__name__}: missing expected static fallback for a cold reopen") @@ -666,7 +682,7 @@ def fake_display(data, raw=False, metadata=None, display_id=None, **kw): assert png_calls, f"{widget.__name__}: deferred fill never rendered the PNG" assert "post_execute" not in hooks, "one-shot hook did not unregister" fill_data, fill_meta = updated[-1] - preview_mime = "image/png" if widget is Plot2D else "image/jpeg" + preview_mime = "image/png" if widget in PNG_PREVIEW_WIDGETS else "image/jpeg" assert isinstance(fill_data[preview_mime], bytes) assert len(fill_data[preview_mime]) > 1000 assert "quantem-static-fallback" in fill_data["text/html"] diff --git a/tests/test_tutorial_data.py b/tests/test_tutorial_data.py index 4d22fb138..b3cbfff6b 100644 --- a/tests/test_tutorial_data.py +++ b/tests/test_tutorial_data.py @@ -2,6 +2,7 @@ from pathlib import Path import numpy as np +import pytest from quantem.widget import datasets @@ -193,3 +194,53 @@ def fake_snapshot_download(**kwargs): "cache_dir": "/tmp/qw-cache", } ] + + +def test_showpdf_ta_loads_the_calibrated_scan_and_the_model_gr(tmp_path, monkeypatch): + root = tmp_path / "hf-cache" + data_dir = root / "4dstem" / "ta_amorphous_sim" + data_dir.mkdir(parents=True) + np.save(data_dir / "data.npy", np.arange(2 * 2 * 4 * 4, dtype=np.float32).reshape(2, 2, 4, 4)) + (data_dir / "meta.json").write_text( + json.dumps( + { + "name": "ta_amorphous_sim", + "sampling": [1.0, 1.0, 0.01488, 0.01488], + "units": ["pixels", "pixels", "A^-1", "A^-1"], + } + ) + ) + np.savez(data_dir / "ground_truth_gr.npz", r=np.array([0.0, 0.05]), gr=np.array([0.0, 1.0])) + calls = [] + + def fake_snapshot_download(**kwargs): + calls.append(kwargs) + return root + + monkeypatch.setattr(datasets, "snapshot_download", fake_snapshot_download, raising=False) + + scan = datasets.showpdf_ta(revision="abc123", verbose=False) + r, gr = datasets.showpdf_ta_ground_truth(revision="abc123") + + assert scan.array.shape == (2, 2, 4, 4) + assert [float(value) for value in scan.sampling] == [1.0, 1.0, 0.01488, 0.01488] + assert list(scan.units) == ["pixels", "pixels", "A^-1", "A^-1"] + assert r.tolist() == [0.0, 0.05] + assert gr.tolist() == [0.0, 1.0] + assert calls == 2 * [ + { + "repo_id": "bobleesj/quantem-data", + "repo_type": "dataset", + "allow_patterns": ["4dstem/ta_amorphous_sim/*"], + "force_download": False, + "revision": "abc123", + } + ] + + +def test_showpdf_ta_names_the_hugging_face_path_of_a_missing_folder(tmp_path, monkeypatch): + # Hugging Face returns the snapshot without the folder when no file matches allow_patterns. + monkeypatch.setattr(datasets, "snapshot_download", lambda **kwargs: tmp_path, raising=False) + + with pytest.raises(FileNotFoundError, match="bobleesj/quantem-data/4dstem/ta_amorphous_sim$"): + datasets.showpdf_ta(verbose=False)