diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 99e1263..116c61b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,6 +45,20 @@ jobs:
- name: Run pytest
run: pytest -v
+ - name: Try install optional AdStat extra
+ id: adstat_extra
+ run: |
+ if python -m pip install -e ".[adstat]"; then
+ echo "available=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "available=false" >> "$GITHUB_OUTPUT"
+ echo "AdStat extra is not available in this environment; adapter tests already skip without it."
+ fi
+
+ - name: Run AdStat adapter tests
+ if: steps.adstat_extra.outputs.available == 'true'
+ run: pytest -v tests/test_adstat_adapter.py tests/test_adstat_results_dialog.py
+
lint:
name: ruff
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index 5192f6b..652ba27 100644
--- a/README.md
+++ b/README.md
@@ -183,6 +183,8 @@ dzdv = numeric_derivative(spec.x_array, z_smooth)
- [GUI guide](docs/gui.md)
- [Command-line guide](docs/cli.md)
+- [Particle Statistics guide](docs/adstat_user_guide.md)
+- [AdStat integration](docs/adstat_integration.md)
- [Createc `.dat` reader notes](docs/createc_dat_reader.md)
- [ROI manual workflow checklist](docs/roi_manual_test_checklist.md)
- [Review and cleanup status](docs/review_status.md)
diff --git a/docs/adstat_integration.md b/docs/adstat_integration.md
new file mode 100644
index 0000000..a37de95
--- /dev/null
+++ b/docs/adstat_integration.md
@@ -0,0 +1,119 @@
+# AdStat Integration
+
+ProbeFlow can hand curated STM point collections to AdStat without exporting an
+intermediate file. ProbeFlow remains responsible for image loading, processing,
+point detection, ROI and mask editing, and Qt presentation. AdStat receives
+normalised point-pattern objects, runs the statistics and matched null models,
+and returns GUI-free result/view specifications for ProbeFlow to render.
+
+The adapter lives in `probeflow.analysis.adstat_adapter` and imports AdStat
+lazily. Install the optional AdStat extra, or put an AdStat checkout on
+`PYTHONPATH`, before using this path:
+
+```bash
+pip install "probeflow[adstat]"
+```
+
+The viewer's **Particle Statistics...** command opens a ProbeFlow-native Qt
+shell powered by the AdStat engine. Its **Analyze scan points** mode calls
+`compare_point_source_view_spec(...)` for live point-source records already
+collected by the image viewer. The saved/session feature-set workflow builds
+`point_set_record(...)` objects and runs `compare_point_set_record_view_spec(...)`
+for one set or `compare_point_set_records_view_spec(...)` for pooled replicate
+sets.
+
+Points reach that shared feature-set pool from several sources:
+
+- **Feature Finder** and **Feature Counting** both have a *Send to Particle
+ Statistics* action. Feature Counting particles/detections are converted with
+ `feature_counting_to_particle_table(...)` (via
+ `point_table_io.feature_items_to_feature_set`) into a `FeatureSet`.
+- **Load points from disk…** uses `point_table_io.sniff_point_table` /
+ `load_point_table` to import CSV position tables and ProbeFlow JSON (Feature
+ Counting exports and saved `FeatureSetStore` files).
+
+`compare_particle_collection_view_spec(...)` remains the broadest single-call
+entry point and also accepts independent feature layers.
+
+## Single Image
+
+1. Generate one point collection from any canonical ProbeFlow source:
+ Feature Finder maxima/minima, Feature maxima, point ROIs, Feature Counting
+ segmented particles, or template-match detections.
+2. Choose that collection as the tested population. Each run analyses one
+ population; mixed species should be analysed separately unless the scientific
+ intent is an unlabelled merged population.
+3. Choose the analysis region from an active area ROI, mask, or the full image.
+ The same region must be used for observed statistics and every null
+ simulation.
+4. Convert through AdStat `ImageCalibration`, preserving anisotropic pixel
+ sizes from `Scan.scan_range_m` and `Scan.dims`.
+5. Open **Measurements -> Features -> Particle Statistics...** and use
+ **Analyze scan points** to pass
+ `ParticleTable + AnalysisRegion + optional independent feature layers` to
+ AdStat and render the returned `ResultViewSpec` with ProbeFlow's native Qt
+ result-view widgets.
+
+Measured feature layers, such as step traces or defect landmarks, must be
+independent measurements. A layer derived from the same particle centroids being
+tested is not a valid measured-inhomogeneity null.
+
+The older **Pair correlation...** dialog remains available during migration. The
+AdStat path uses matched simulation envelopes and verdict rows, so its plots are
+not intended to be numerically identical to the older square-window
+pair-correlation readout.
+
+## Synthetic Demo Run
+
+The repository includes a reproducible teaching script that generates a
+clustered random point collection and runs it through the same direct adapter
+path used by the viewer:
+
+```bash
+python scripts/adstat_demo.py --output-dir /tmp/probeflow_adstat_demo
+```
+
+If AdStat is checked out locally rather than installed as a package, put that
+source tree on `PYTHONPATH` first, for example:
+
+```bash
+PYTHONPATH=/path/to/AdStat/src python scripts/adstat_demo.py
+```
+
+The script writes:
+
+- `synthetic_points.csv` - the ProbeFlow-shaped point collection in nm and px.
+- `adstat_result_view_spec.json` - the AdStat `ResultViewSpec` that ProbeFlow's
+ Qt-native renderer consumes.
+- `synthetic_points_preview.png` - a quick plot of the generated point pattern.
+
+This demo teaches the data path and expected result panels. It does not replace
+a GUI workflow: in the viewer, users still generate or curate a point
+collection, choose an active ROI/mask if needed, and open
+**Measurements -> Features -> Particle Statistics...**.
+
+## Generated Teaching Modes
+
+Two Particle Statistics modes use AdStat's synthetic sandbox backend rather than
+the current scan's ROIs or detected features:
+
+- **Learn with tutorial** — a guided walkthrough that stages generated patterns,
+ models, and statistics step by step.
+- **Model simulations** — free-play exploration of generated patterns, where the
+ user picks the pattern, null model, particle count, seed, and simulation count
+ directly and reruns at will.
+
+Both pages are persistently labelled `TEST MODE - GENERATED DATA` and use
+different point markers and colours from real-data analysis. Sandbox points are
+deliberately isolated from real ProbeFlow data in v1: they do not become point
+ROIs, measurements, processing provenance, or point-source records. Use
+**Analyze scan points** for real scan data and either generated mode for
+examples.
+
+## Series
+
+A multi-image collection is a list of scan records. Each record stores the scan
+id, one normalised point set, calibration, the analysis ROI/mask, source
+metadata, and an optional user-supplied series coordinate such as coverage or
+temperature. ProbeFlow can run AdStat directly from those records and may also
+export AdStat project/coverage-series JSON as provenance.
diff --git a/docs/adstat_user_guide.md b/docs/adstat_user_guide.md
new file mode 100644
index 0000000..361fb57
--- /dev/null
+++ b/docs/adstat_user_guide.md
@@ -0,0 +1,192 @@
+# Particle Statistics User Guide
+
+ProbeFlow is the normal entry point for particle/point-pattern statistics. Open
+**Measurements -> Features -> Particle Statistics...** to choose between real
+scan analysis, a guided tutorial, and free-play model simulations. The
+calculations are powered by the AdStat engine.
+
+> **Maturity note — please read.** Particle Statistics is the newest and
+> **least user-tested** part of ProbeFlow. It has unit tests and a worked
+> tutorial, but it has had far less real-world use than the imaging, ROI, and
+> Feature Finder tools, and it **may contain mistakes** — in the statistics, the
+> automatic scale choices, or the interpretation language. Treat its output as
+> *exploratory*: sanity-check verdicts against your own judgement and, for
+> anything you intend to publish, against an independent point-pattern method you
+> trust. If a result looks wrong, it may well be — please report it.
+
+## What Particle Statistics Does
+
+Particle Statistics compares a collection of points against spatial null models.
+In plain language, it asks whether the points look consistent with random
+placement, or whether they show signs of clustering, separation, or association
+with another measured feature.
+
+It does not prove a physical mechanism. The result depends on which points you
+analyse, which image region you allow, and which null model you choose.
+
+## How It Works
+
+The core idea is a **null model** plus a **simulation envelope**:
+
+1. You give it a set of point positions and an analysis region.
+2. It measures one or more spatial statistics on your points.
+3. It generates many random point patterns from the chosen null model (same
+ region, same number of points) and measures the same statistic on each. The
+ spread of those simulations is the **envelope** — the range expected by
+ chance.
+4. If your observed curve falls outside the envelope, the pattern is reported as
+ *inconsistent with* the null model; if it stays inside, it is *consistent
+ with* it.
+
+**Null models** available for real data:
+
+- **Homogeneous Poisson** — completely random placement (the usual baseline).
+- **Hard-core random** — random but with a minimum spacing (points cannot sit
+ closer than a set distance), a baseline for excluded-volume effects.
+- **Measured-feature Poisson** — random but biased toward an *independently
+ measured* feature layer (e.g. step edges), to test association. The feature
+ layer must be a different measurement from the particles being tested —
+ reusing the particles as their own feature is circular.
+
+**Core statistics** (different lenses on the same points, always shown):
+
+- **Pair correlation g(r)** — relative density of neighbours at distance *r*. A
+ short-range bump means clustering; a dip near zero means avoidance/spacing.
+- **Nearest-neighbour distribution** — how far each point is from its closest
+ neighbour; the clearest first look at spacing vs clumping.
+- **Ripley's L** — cumulative neighbour counts vs distance; sensitive to
+ clustering or regularity across a range of scales.
+- **Cluster sizes** — counts of connected groups within a linking distance.
+
+**Local-order checks (opt-in).** Bond-orientational order **ψ4 / ψ6** and the
+angular pair map **g(r, θ)** answer a *different* question — is there square or
+triangular *lattice* order? — so they are **off by default** and not shown in a
+plain randomness analysis. Tick **"Include local-order checks"** (or run the
+ordered tutorial examples) to compute them. They are validated to behave
+correctly — a triangular lattice rejects on ψ6, a square lattice on ψ4, and
+random points stay consistent (see `tests/test_adstat_validation.py`) — but they
+depend on a neighbour-distance cutoff and have no edge correction, so for sparse
+patterns or particles near the region boundary treat them as suggestive of local
+order, not proof of a crystal.
+
+ProbeFlow chooses the distance scales (bin widths, maximum radii, hard-core and
+cluster radii) automatically from the region size and point density when you do
+not set them. These automatic choices are *teaching-quality defaults*, not tuned
+parameters — see the maturity note above.
+
+**Reading a verdict.** "Consistent with random" means the null model was *not
+rejected* — it is not positive proof that nothing is going on (a small or noisy
+sample simply may not have the power to detect an effect). "Inconsistent with
+random" means the statistic departed from the envelope — evidence of structure,
+but not proof of any particular physical mechanism. Pooling several independent
+images of the same condition is the practical way to strengthen a conclusion.
+
+## Analyze Scan Points
+
+Use **Analyze scan points** for real ProbeFlow data.
+
+1. Generate or curate a point collection:
+ Feature Finder maxima/minima, feature maxima, or point ROIs are available in
+ the current viewer workflow.
+2. Open **Particle Statistics...** and stay on **Analyze scan points**.
+3. Choose one point source as the tested population.
+4. Choose the analysis region: active area ROI, active mask, or full image.
+5. Choose a model and run the comparison.
+
+For session feature-set workflows, click **Send to Particle Statistics** from
+Feature Finder. The set is saved with its image calibration. Tick one saved set
+to analyse that image, or tick multiple saved sets from independent scans of the
+same condition to pool them.
+
+The real-data UI exposes homogeneous Poisson, hard-core random, and
+measured-feature Poisson models, plus simulation count and random seed.
+Measured-feature Poisson uses one tested session set and a different,
+independently measured Feature layer set.
+
+## Getting Points In
+
+Particle Statistics shares one feature-set pool across the whole session, so
+points from any of these sources can be ticked together and pooled:
+
+1. **Feature Finder → Send to Particle Statistics** — local maxima/minima from
+ the open image.
+2. **Feature Counting → Send to Particle Statistics** — segmented particle
+ centroids (Particles mode) or template detections (Template mode) from the
+ Feature Counting window.
+3. **Point sources in the open image** — detected feature maxima and point ROIs
+ appear directly in the *Analyze scan points* dropdown (point ROIs include any
+ loaded from a `.rois.json` sidecar).
+4. **Load points from disk…** — import an external position table. Accepted
+ formats: CSV position tables (with or without a leading particle-number
+ column; units inferred from `x_px` / `x_nm` / `x_m` / `x_phys` headers or
+ chosen on import), ProbeFlow's own Feature Finder / measurements CSV, and
+ ProbeFlow JSON (Feature Counting exports and saved feature-set files). For a
+ file with no embedded calibration, a small dialog (prefilled from the file)
+ asks for the position units and physical field size before the points become a
+ feature set.
+
+Use **Save feature sets…** to write the current pool to a JSON file; it can be
+re-imported later with **Load points from disk…**.
+
+## Exporting Results
+
+After running a comparison, the top **Export** menu writes the results in simple
+formats so you can reproduce the plots in another program:
+
+- **Export curves + verdicts (CSV folder)…** — one CSV per statistic. Each curve
+ file has a distance column plus the `observed` line and the model envelope
+ (`model_low` / `model_central` / `model_high`), so g(r), the nearest-neighbour
+ distribution, Ripley's L, cluster sizes, etc. can be re-plotted directly. A
+ `…_verdicts.csv` holds the per-model/per-statistic verdict table. (Heatmap and
+ real-space panels are not written as CSV; they are kept in the JSON export.)
+- **Export full result (JSON)…** — the entire result (all panels, curves, and
+ verdicts) in one JSON file, for archiving or scripted post-processing.
+
+The input points themselves are exported separately via **Save feature sets…**
+(above) or the source tools' own CSV/JSON exports.
+
+## Learn With Tutorial
+
+Use **Learn with tutorial** (the **Tutorial** card on the workflow start page)
+for a guided walkthrough that builds up particle-pattern analysis one idea at a
+time, using generated example data rather than the current image. It steps
+through random placement, sample size, pooling, clustering, hard-core spacing,
+and feature association, then hands you off to the real scan-points workflow.
+
+## Model Simulations
+
+Use **Model simulations** to experiment freely with generated patterns without
+the tutorial's guidance. Choose the synthetic pattern, null model, particle
+count, seed, and simulation count, then run the comparison or draw a fresh
+pattern. This is the place to build intuition by changing one knob at a time.
+
+Both generated modes are labelled `TEST MODE - GENERATED DATA` and use different
+point markers and colours so they cannot be mistaken for real data. Generated
+points stay isolated: they do not become point ROIs, measurements, processing
+provenance, or active ProbeFlow point sources.
+
+## Appropriate Data
+
+Good tested populations are point-like features whose coordinates have a clear
+meaning: atoms, adsorbates, defects, particle centroids, template detections, or
+manually curated point ROIs.
+
+Do not mix unrelated species unless the scientific question is explicitly about
+the merged set. Independent feature layers, such as step traces or external
+landmarks, must be measured separately from the particles being tested.
+
+## Current Limitations
+
+- **This is the least battle-tested area of ProbeFlow** (see the maturity note at
+ the top): the statistics and especially the automatic scale defaults may still
+ have rough edges. Verify important results independently.
+- Feature sets live in one shared session pool; **Save feature sets…** /
+ **Load points from disk…** persist and restore them as JSON, but they are not
+ yet tied into a durable per-project record.
+- Imported files with no embedded calibration require you to supply the field
+ size; the image (pixel) dimensions are synthetic and only affect the
+ pixel-resolution note, not the statistics.
+- Series/project export workflows are still pending.
+
+See [AdStat integration](adstat_integration.md) for the developer-facing API
+contract between ProbeFlow and AdStat.
diff --git a/docs/gui.md b/docs/gui.md
index 22eb421..d616cf1 100644
--- a/docs/gui.md
+++ b/docs/gui.md
@@ -108,7 +108,9 @@ atoms, molecules, defects, moiré sites — on the current image.
From the **Export** section you can write the coordinates to CSV, render
a synthetic *feature image* (a disk at every detection, useful for pair
correlation and lattice statistics), or send that feature image straight
-to the FFT viewer.
+to the FFT viewer. When Particle Statistics is available, **Send to Particle
+Statistics** saves the current detections as a calibrated session feature set
+for single-image or pooled spatial statistics.
For segmentation-based workflows — particle size statistics, template
matching, classification, lattice extraction — use the **Feature
@@ -119,6 +121,16 @@ optional `features` extra:
pip install "probeflow[features]"
```
+For particle spatial-statistics workflows, these detected features and point
+ROIs can be analysed from **Measurements → Features → Particle Statistics...**.
+Use **Analyze scan points** for real ProbeFlow data, **Learn with tutorial** for
+a guided walkthrough, or **Model simulations** to explore synthetic patterns and
+null-model behaviour freely before applying the tool to a scan. Particle
+Statistics is the newest and least user-tested part of ProbeFlow — treat its
+verdicts as exploratory and verify important results independently. See the
+[Particle Statistics guide](adstat_user_guide.md)
+and the developer-facing [AdStat integration](adstat_integration.md) contract.
+
## Beyond the basics
* **ROIs** — draw rectangles, ellipses, and lines from the ROI tab; ROIs
diff --git a/docs/particle_statistics_tutorial_flow.md b/docs/particle_statistics_tutorial_flow.md
new file mode 100644
index 0000000..6c25116
--- /dev/null
+++ b/docs/particle_statistics_tutorial_flow.md
@@ -0,0 +1,474 @@
+# Particle Statistics Tutorial Structure and Flow
+
+This document describes the tutorial mode for the ProbeFlow Particle Statistics
+tool. It is written as a detailed reference for teaching, review, and future
+maintenance. The implementation lives primarily in
+`probeflow/gui/dialogs/particle_statistics.py`.
+
+The tutorial is inspired by the SEMITIP/Poisson Solver teaching pattern: each
+step should tell the student what problem is being addressed, what control or
+result area matters, what is expected to change, and where to check the result.
+The goal is not only to demonstrate the interface, but to make particle-pattern
+analysis intelligible to students who are still learning what the statistics
+mean.
+
+## Teaching Goal
+
+Particle Statistics asks a spatial question:
+
+> Given a set of particle positions, are those positions consistent with a
+> simple null model, or do they show clustering, spacing, or association with an
+> independently measured feature?
+
+The tutorial mode is designed to teach four ideas in order:
+
+1. A point field can be turned into measurable spatial statistics.
+2. A null model gives the baseline for deciding whether an observed pattern is
+ unusual.
+3. Different statistics answer different geometric questions.
+4. Real analysis requires detected feature sets, calibrated image scale,
+ independent images for pooling, and careful model choice.
+
+The tutorial deliberately starts with generated data. That avoids requiring a
+specific scan file and lets students see known patterns before applying the
+same workflow to their own detections.
+
+## Main UI Surfaces
+
+The dialog opens on a **workflow start page** (the landing page) with three
+cards: **Analyze scan points** (real data), **Model simulations** (free-play
+generated data), and **Tutorial** (this guided mode). A menu bar mirrors the
+same actions. **Model simulations** is a separate, non-guided sandbox workflow;
+it shares the generated-data backend but is not part of the tutorial sequence.
+
+Once a workflow is chosen, the Particle Statistics dialog is divided into a few
+teaching regions:
+
+| Region | Role in the tutorial |
+| --- | --- |
+| Menu bar | Workflow / Data / Model / Statistic / View / Definitions menus mirroring the controls. |
+| Top toolbar | Contains `Workflows`, `Start tutorial`, the mode selector (Analyze scan points / Learn with tutorial / Model simulations), and `Run comparison`. |
+| Landing page | The workflow start page shown before a mode is chosen. |
+| Tutorial drawer | The guided lesson selector, navigation buttons, action hints, and detail text. |
+| Point field | Shows observed points, generated points, model simulations, feature layers, and regions. |
+| Focus plot | Shows the currently selected statistic or verdict summary. |
+| Field/info panel | Summarizes mode, point count, model, source, region, and layer visibility. |
+| Data tab | Holds generated-data controls in generated mode and real-data controls in real mode. |
+| Model tab | Holds generated model controls or real model/simulation/seed controls. |
+| Statistics tab | Holds the statistic selector cards and the "Include local-order checks" toggle. |
+| Results tab | Holds verdict cards and technical details. |
+| Learn tab | Holds compact reference notes and definitions. |
+
+The local-order statistics (ψ4, ψ6, angular `g(r, θ)`) are **opt-in**: off by
+default and hidden from the verdict so a plain randomness analysis is not
+cluttered with lattice checks. The ordered tutorial examples tick the
+"Include local-order checks" box automatically (driven by each step's
+`focus_statistic`); leaving those steps turns it back off.
+
+The tutorial drawer remains the central teaching surface. It has:
+
+- A guided-example selector.
+- `Load example` and `Run this example`.
+- `Previous` and `Next`.
+- `More detail`.
+- `Restart tutorial`.
+- `Exit tutorial`.
+- An always-visible green "Why it matters" row.
+- A collapsible detail section with "Change", "Expected", "Check", "What to
+ look for", and "Careful" notes.
+
+## Tutorial State Model
+
+The tutorial distinguishes between two concepts that used to be coupled:
+
+| Concept | Meaning |
+| --- | --- |
+| Tutorial active | The guided drawer is visible and `current_mode` reports `learn`. |
+| Active data mode | The visible analysis controls are either generated examples or real scan points. |
+
+This is important because the tutorial starts in generated mode but ends by
+showing real-data controls. The final lessons need the tutorial drawer to stay
+open while the Data and Model tabs show real point sources, saved feature sets,
+real models, simulation count, seed, and pooling controls.
+
+Internally:
+
+- `self._tutorial_active` records whether the guided tutorial is active.
+- `self._active_mode` records whether the current data source is `generated` or
+ `real`.
+- `current_mode` returns `learn` when tutorial mode is active, even if the
+ current step is displaying real-data controls.
+- `Exit tutorial` switches back to normal real analysis, clears tutorial
+ highlights, invalidates stale generated workers, and clears the real view.
+
+## Step Data Model
+
+Each tutorial step is represented by `ParticleTutorialStep`. It combines
+student-facing text with machine-readable UI guidance.
+
+| Field | Purpose |
+| --- | --- |
+| `title` | Short step title shown in the drawer. |
+| `body` | Main student instruction. |
+| `mode` | `generated` or `real`; controls which workflow controls are visible. |
+| `target_tab` | The tab the tutorial should select for this step. |
+| `controls` | Stable tutorial control keys to highlight. |
+| `focus_statistic` | Statistic plotted in the focus panel. |
+| `focus_curve_mode` | Plot emphasis, such as observed-only or model comparison. |
+| `action_button` | Which action is the intended next action: next, next example, run, load, restart, or none. |
+| `action_text` | Custom call-to-action text for the green button. |
+| `advance_after_run` | Whether a run completion should advance to the next step. |
+| `pattern` | Generated point pattern to stage. |
+| `model` | Generated or real model to select. |
+| `n` | Generated particle count. |
+| `seed` | Random seed. |
+| `simulations` | Number of model simulations. |
+| `show_observed` | Whether observed particles are visible in the field. |
+| `show_simulated` | Whether model simulations are visible in the field. |
+| `show_features` | Whether independent feature layers are visible. |
+| `show_region` | Whether region or mask overlays are visible. |
+| `pool_images` | Number of generated images to pool for the pooling demo. |
+| `intro_card` | Marks read-only orientation cards that do not run a comparison. |
+| `intro_region` | Which central panel an intro card is introducing. |
+| `intro_panel_text` | One-line text placed in the introduced panel. |
+| `what_changes` | What input or view changes in this step. |
+| `expected_effect` | What the student should expect to happen. |
+| `where_to_check` | Where in the UI the student should verify it. |
+| `why` | The conceptual reason this step matters. |
+| `statistic_hint` | What to look for in the plot or control. |
+| `limitation` | A caution or interpretation limit. |
+
+The tutorial uses `_complete_tutorial_metadata()` to fill default
+`what_changes`, `expected_effect`, and `where_to_check` text for steps that
+have controls, actions, or result tabs. This keeps the SEMITIP-style metadata
+complete without forcing every simple step to repeat boilerplate.
+
+## Control Highlighting
+
+The tutorial uses a registry of stable control keys. Each key maps to one or
+more widgets. Tutorial steps name keys, not widget objects.
+
+Current control keys include:
+
+| Key | Highlighted control |
+| --- | --- |
+| `mode` | Mode combo box. |
+| `pattern` | Generated pattern combo. |
+| `n` | Generated particle-count spinbox. |
+| `generated_seed` | Generated seed spinbox. |
+| `generated_model` | Generated model combo. |
+| `generated_simulations` | Generated simulation-count spinbox. |
+| `source` | Real point-source combo. |
+| `region` | Real region combo. |
+| `real_model` | Real model combo. |
+| `real_simulations` | Real simulation-count spinbox. |
+| `real_seed` | Real seed spinbox. |
+| `feature_sets` | Saved feature-set list. |
+| `feature_layer` | Feature-layer picker for measured-feature models. |
+| `run_comparison` | Main `Run comparison` button. |
+| `run_tutorial` | Tutorial `Run this example` button. |
+| `run_selected_sets` | Saved feature-set run button. |
+| `load_tutorial` | Tutorial `Load example` button. |
+| `next` | Tutorial `Next` button. |
+| `restart` | Tutorial `Restart tutorial` button. |
+| `layer_observed` | Observed-points layer checkbox. |
+| `layer_simulated` | Model-simulation layer checkbox. |
+| `layer_features` | Feature-layer checkbox. |
+| `layer_region` | Region/mask layer checkbox. |
+| `stat_pair` | Pair correlation statistic card. |
+| `stat_nearest` | Nearest-neighbor statistic card. |
+| `stat_ripley` | Ripley L statistic card. |
+| `stat_clusters` | Cluster-size statistic card. |
+
+Highlight colors:
+
+- Green means "this is the current control or action".
+- Yellow means "this control has already been covered in the current lesson".
+- Normal styling is restored when leaving tutorial mode or changing steps.
+
+The tutorial also highlights the active action button. For example, a step with
+`action_button="next_example"` highlights `Next`, while a final step with
+`action_button="restart"` highlights `Restart tutorial`.
+
+## Lesson Sequence
+
+The tutorial is a sequence of guided examples. Each example has a stable key,
+a title, a summary, and a tuple of steps.
+
+### 1. `intro`: Start Here
+
+Purpose:
+
+- Introduce the three main visual regions without running a comparison.
+- Explain that the tool looks for spatial rules beyond random chance.
+- Prepare students for the idea that one image is noisy and pooling matters.
+
+Flow:
+
+1. The point field: where particles are drawn.
+2. The statistic plot: where point positions become a curve or verdict.
+3. The model summary: where model assumptions and results are summarized.
+4. Getting good statistics: why multiple independent images matter.
+
+These are soft intro cards. They keep the central panels mostly blank so
+students are not overloaded before the first generated example.
+
+### 2. `tour`: Workspace Tour
+
+Purpose:
+
+- Walk the student through every tab and statistic card.
+- Show how generated data, model choice, statistic selection, and result
+ interpretation are connected.
+
+Flow:
+
+1. Data tab: generated random points, particle count, seed, and layer checkboxes.
+2. Model tab: homogeneous Poisson as the baseline null model.
+3. Statistics tab: pair correlation `g(r)`.
+4. Statistics tab: nearest-neighbor distance.
+5. Statistics tab: Ripley L.
+6. Statistics tab: cluster sizes.
+7. Results tab: verdict cards and consistency language.
+8. Learn tab: compact definitions and reference notes.
+
+Teaching emphasis:
+
+- The four statistic cards are different lenses on the same points.
+- Switching statistics changes the focus plot, not the underlying point data.
+- A verdict is statistical consistency or inconsistency with a model, not proof
+ of a physical mechanism.
+
+### 3. `random`: Random Placement
+
+Purpose:
+
+- Establish homogeneous Poisson placement as the baseline null model.
+
+Flow:
+
+1. Show a random generated pattern and its observed-only pair correlation.
+2. Add the simulation envelope from many random layouts.
+3. Read the verdict: random placement is not rejected.
+
+Teaching emphasis:
+
+- A single random layout proves nothing because it is itself random.
+- The simulation envelope is the fair comparison.
+- Failure to reject the null does not prove there are no interactions.
+
+### 4. `more_particles`: More Particles
+
+Purpose:
+
+- Show how sample size affects statistical sensitivity.
+
+Flow:
+
+1. Increase the generated random field from the earlier example to many more
+ points.
+2. Compare against the same null model.
+3. Read the verdict, which should still be consistent with random placement.
+
+Teaching emphasis:
+
+- More points make curves smoother.
+- More points narrow the envelope and make smaller effects detectable.
+- More points can also cost more computation.
+
+### 5. `pooling`: Pooling Multiple Images
+
+Purpose:
+
+- Teach why independent images are the practical route to stronger statistics.
+
+Flow:
+
+1. Show one noisy single-image comparison.
+2. Pool five independent generated images and show the smoother combined result.
+
+Teaching emphasis:
+
+- One image is often too noisy.
+- Pooling independent images adds evidence without forcing one image to carry
+ the whole conclusion.
+- Images should only be pooled when they belong to the same condition.
+
+### 6. `clustered`: Clustered Points
+
+Purpose:
+
+- Show how visual clustering becomes a measurable short-range excess.
+
+Flow:
+
+1. Show a clustered generated pattern and observed-only `g(r)`.
+2. Compare the pattern against homogeneous Poisson simulations.
+3. Read the verdict: inconsistent with random placement.
+
+Teaching emphasis:
+
+- Clustering creates extra close pairs.
+- Extra close pairs appear as a small-distance bump in pair correlation.
+- Rejecting random placement is evidence of structure, not proof of a specific
+ attractive mechanism.
+
+### 7. `hard_core`: No-Overlap / Hard-Core
+
+Purpose:
+
+- Show the opposite of clustering: points avoid close neighbors.
+
+Flow:
+
+1. Show a no-overlap generated pattern and focus nearest-neighbor distance.
+2. Compare against the hard-core model and simulation envelope.
+3. Read the verdict and compare it with pure random placement.
+
+Teaching emphasis:
+
+- Exclusion removes very short neighbor distances.
+- Nearest-neighbor distance is the clearest first statistic for this case.
+- Apparent spacing can have several causes: particle size, detection merging,
+ substrate registry, or other effects.
+
+### 8. `feature_biased`: Feature-Biased Points
+
+Purpose:
+
+- Teach how to test association with an independently measured feature layer.
+
+Flow:
+
+1. Show the independent feature layer alone.
+2. Add the particles and ask whether they sit near the features.
+3. Compare model verdicts.
+4. Explain why the feature layer must be independent.
+5. Switch into real mode and show the real saved-set workflow for this model.
+
+Teaching emphasis:
+
+- The feature layer is the proposed influence, not the particles being tested.
+- Reusing the particles as their own feature layer is circular.
+- Measured-feature Poisson requires one tested set and a different feature-layer
+ set from the same real image context.
+
+### 9. `real_handoff`: Move to Real Scan Points
+
+Purpose:
+
+- Walk students from generated examples into actual ProbeFlow analysis.
+
+Flow:
+
+1. Detect features in Feature Finder.
+2. Send the detected features to Particle Statistics.
+3. Repeat across independent images of the same condition.
+4. Tick saved sets and run a pooled comparison.
+
+Teaching emphasis:
+
+- Feature Finder creates the point set Particle Statistics needs.
+- Sending to Particle Statistics preserves points and calibration in a
+ viewer-session feature set.
+- Multiple saved sets represent independent images and can be pooled.
+- The final run uses real model, simulation count, seed, and saved-set controls.
+- Particle Statistics can suggest clustering, spacing, randomness, or feature
+ association. It does not prove a physical mechanism by itself.
+
+## Generated Data Flow
+
+Generated tutorial steps use the AdStat sandbox:
+
+1. The step selects `pattern`, `n`, `seed`, `model`, and `simulations`.
+2. The sandbox state is staged only when the step is in generated mode.
+3. The point field refreshes with observed generated points, optional simulated
+ points, and optional feature points.
+4. The focus statistic is selected.
+5. The target tab is selected.
+6. If the dialog is visible and the step is not an intro card, the tutorial can
+ compute a comparison so the plot is live.
+
+Layer checkboxes are display-only. They affect what is drawn, not what is
+computed. The tutorial uses this to teach observed points, model simulations,
+feature layers, and region overlays one at a time.
+
+## Real Data Flow
+
+Real tutorial steps do not require the tutorial to own Feature Finder. Instead,
+they explain the handoff and highlight the Particle Statistics controls that
+students should inspect next.
+
+Real steps show:
+
+1. Point source and region controls.
+2. Saved feature sets from Feature Finder.
+3. Real model selection.
+4. Simulation count and seed.
+5. Measured-feature feature-layer picker.
+6. `Run selected sets` for single-set or pooled saved-set analysis.
+
+The tutorial stays active while these controls are visible. This is the key
+structural change from the older version, where tutorial visibility was tied to
+generated mode only.
+
+## Interpretation Language
+
+The tutorial uses careful language throughout:
+
+- "Consistent with random" means the null model was not rejected.
+- "Inconsistent with random" means the observed statistic departed from the
+ null-model envelope.
+- A model verdict is not a proof of mechanism.
+- Feature association requires independently measured feature layers.
+- Pooling requires independent images from the same condition.
+
+This language is intentionally conservative for students. It separates what the
+statistical comparison can support from what a physical interpretation would
+require.
+
+## Tests and Guardrails
+
+The tutorial has tests in `tests/test_adstat_workbench_dialog.py`.
+
+Important checks include:
+
+- The tutorial example order is stable.
+- The intro example contains read-only intro cards.
+- Feature-biased model-summary steps do not strand the student on the
+ Statistics tab.
+- Every used tutorial control key resolves to an actual widget.
+- Steps that point at controls, actions, or results carry metadata for
+ `what_changes`, `expected_effect`, `where_to_check`, and `why`.
+- Tutorial highlights apply to current controls.
+- Previously covered controls become yellow.
+- Highlights clear on tutorial exit.
+- Real workflow steps keep the tutorial drawer visible while real controls are
+ shown.
+- Feature-biased handoff steps switch to real model controls and select
+ measured-feature Poisson.
+- Generated tutorial behavior is preserved: focus plots, example loading,
+ layer toggles, next-example navigation, pooling demo, exit/restart behavior,
+ and late generated worker suppression.
+
+These tests are intentionally similar in spirit to the SEMITIP tutorial tests:
+they protect both mechanics and teaching quality.
+
+## Future Extension Points
+
+This pass deliberately does not add a full primer, glossary dialog, artwork
+package, or PDF export. Good future additions would be:
+
+1. A Particle Statistics primer with pages for null models, pair correlation,
+ nearest-neighbor distance, Ripley L, cluster sizes, pooling, and
+ measured-feature controls.
+2. Contextual primer links from tutorial steps, similar to SEMITIP
+ `primer_key`.
+3. A short glossary for terms such as null model, envelope, hard-core,
+ measured feature, pooling, independent replicate, and verdict.
+4. Optional handout/export support once the tutorial text stabilizes.
+5. Light Feature Finder cues if the real-data workflow needs cross-dialog
+ guidance later.
+
+The current structure leaves room for those additions by keeping lesson content
+separate from control highlighting and by making tutorial metadata explicit.
diff --git a/probeflow/analysis/adstat_adapter.py b/probeflow/analysis/adstat_adapter.py
new file mode 100644
index 0000000..f10e333
--- /dev/null
+++ b/probeflow/analysis/adstat_adapter.py
@@ -0,0 +1,1273 @@
+"""ProbeFlow-to-AdStat integration adapter.
+
+The functions in this module are deliberately small conversion boundaries:
+ProbeFlow owns scans, point detection, ROI/mask editing, and curation; AdStat
+owns point-pattern statistics and result view specifications. Imports from
+AdStat are lazy so ProbeFlow remains importable without the optional analysis
+engine installed.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Iterable, Mapping
+from dataclasses import dataclass, replace
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+
+
+KEEP_STATUSES = frozenset({"accepted", "manual"})
+
+# Local-order / lattice statistics. These answer a different question from the
+# core randomness null (is there square/triangular local order?), are sensitive
+# to the neighbour cutoff and edge effects, and AdStat itself excludes them from
+# its core verdict rollup. ProbeFlow treats them as opt-in (off by default).
+ORDERING_STATISTICS = frozenset(
+ {"pair_correlation_g_r_theta", "bond_order_psi6", "bond_order_psi4"}
+)
+
+__all__ = [
+ "AdStatPointSetRecord",
+ "ORDERING_STATISTICS",
+ "adstat_sandbox_context",
+ "adstat_sandbox_preview",
+ "adstat_sandbox_state",
+ "adstat_sandbox_view_spec",
+ "compare_particle_collection_view_spec",
+ "compare_point_set_record_view_spec",
+ "compare_point_set_records_view_spec",
+ "compare_point_source_view_spec",
+ "feature_counting_to_particle_table",
+ "feature_layers_to_adstat",
+ "point_set_record",
+ "point_source_to_particle_table",
+ "roi_to_region",
+ "scan_calibration_to_adstat",
+ "workbench_view_spec",
+]
+
+
+@dataclass(frozen=True)
+class AdStatPointSetRecord:
+ """One scan's point set and analysis region for a multi-image collection."""
+
+ dataset_id: str | None
+ table: Any
+ region: Any
+ calibration: Any
+ series_value: float | None = None
+ series_unit: str | None = None
+ series_label: str | None = None
+ source_metadata: dict[str, object] | None = None
+
+
+def scan_calibration_to_adstat(scan: Any) -> Any:
+ """Convert a ProbeFlow ``Scan`` into an AdStat ``ImageCalibration``."""
+
+ adstat = _adstat()
+ width_m, height_m = _scan_range_m(scan)
+ width_px, height_px = _scan_dims(scan)
+ if width_px <= 0 or height_px <= 0:
+ raise ValueError("scan dimensions must be positive")
+ return adstat.ImageCalibration(
+ pixel_size_x_nm=float(width_m) * 1e9 / float(width_px),
+ pixel_size_y_nm=float(height_m) * 1e9 / float(height_px),
+ width_px=int(width_px),
+ height_px=int(height_px),
+ origin="upper_left",
+ rotation_angle_deg=0.0,
+ )
+
+
+def point_source_to_particle_table(
+ source: Any,
+ *,
+ scan_id: str | None = None,
+ accepted_statuses: Iterable[str] = KEEP_STATUSES,
+) -> Any:
+ """Convert a ProbeFlow ``PointSource`` into an AdStat ``ParticleTable``.
+
+ ``PointSource`` is already the common GUI-free bridge for Feature Finder
+ maxima/minima, detected feature maxima, and point ROIs. Coordinates in
+ ``points_m`` are converted to AdStat's canonical nanometres while original
+ pixel coordinates are preserved on each particle.
+ """
+
+ adstat = _adstat()
+ points_px = _xy_array(_field(source, "points_px"), name="points_px")
+ points_m = _xy_array(_field(source, "points_m"), name="points_m")
+ if len(points_px) != len(points_m):
+ raise ValueError("points_px and points_m must have the same length")
+ if len(points_m) == 0:
+ raise ValueError("point source must contain at least one point")
+
+ label = _field(source, "label", None)
+ source_type = _field(source, "source_type", None)
+ metadata = dict(_field(source, "metadata", {}) or {})
+ particles = tuple(
+ adstat.Particle(
+ id=_point_id(metadata, index),
+ x_nm=float(points_m[index, 0]) * 1e9,
+ y_nm=float(points_m[index, 1]) * 1e9,
+ x_px=float(points_px[index, 0]),
+ y_px=float(points_px[index, 1]),
+ metadata={
+ "probeflow_source": label,
+ "probeflow_source_type": source_type,
+ "probeflow_status": "accepted",
+ },
+ )
+ for index in range(len(points_m))
+ )
+ return adstat.ParticleTable(
+ particles=particles,
+ metadata={
+ "scan_id": scan_id,
+ "probeflow_point_source": label,
+ "probeflow_point_source_type": source_type,
+ "accepted_statuses": tuple(sorted(str(s) for s in accepted_statuses)),
+ **metadata,
+ },
+ )
+
+
+def feature_counting_to_particle_table(
+ items: Iterable[Any],
+ *,
+ scan_id: str | None = None,
+ calibration: Any | None = None,
+ accepted_statuses: Iterable[str] = KEEP_STATUSES,
+) -> Any:
+ """Convert Feature Counting particles or template detections to AdStat.
+
+ Supported records are native ``probeflow.analysis.features.Particle`` and
+ ``Detection`` instances, dictionaries from ProbeFlow JSON exports, or
+ similarly shaped objects.
+ """
+
+ adstat = _adstat()
+ keep = frozenset(str(status) for status in accepted_statuses)
+ particles = []
+ source_kinds: set[str] = set()
+ for index, item in enumerate(items):
+ status = _field(item, "status", "accepted")
+ if status is not None and str(status) not in keep:
+ continue
+ source_kind = _feature_counting_kind(item)
+ source_kinds.add(source_kind)
+ x_nm, y_nm = _feature_counting_xy_nm(item)
+ x_px, y_px = _feature_counting_xy_px(item, calibration=calibration)
+ particles.append(
+ adstat.Particle(
+ id=_feature_counting_id(item, index),
+ x_nm=x_nm,
+ y_nm=y_nm,
+ x_px=x_px,
+ y_px=y_px,
+ orientation_deg=_optional_float(_field(item, "orientation_deg", None)),
+ area_nm2=_optional_float(_field(item, "area_nm2", None)),
+ confidence=_feature_counting_confidence(item),
+ label=_optional_str(_field(item, "class_name", None)),
+ metadata=_feature_counting_metadata(item, source_kind, status),
+ )
+ )
+ if not particles:
+ raise ValueError("feature counting conversion produced no accepted points")
+ return adstat.ParticleTable(
+ particles=tuple(particles),
+ metadata={
+ "scan_id": scan_id,
+ "probeflow_point_source": "Feature Counting",
+ "probeflow_point_source_type": ",".join(sorted(source_kinds)),
+ "accepted_statuses": tuple(sorted(keep)),
+ },
+ )
+
+
+def roi_to_region(
+ roi_or_mask: Any,
+ *,
+ scan: Any,
+ image_shape: tuple[int, int] | None = None,
+) -> Any:
+ """Convert a ProbeFlow area ROI or mask into an AdStat analysis region.
+
+ ``None`` and non-area ROIs fall back to the full image rectangle. Boolean
+ arrays and area ROIs become ``MaskRegion`` objects aligned to the scan
+ calibration.
+ """
+
+ adstat = _adstat()
+ calibration = scan_calibration_to_adstat(scan)
+ if image_shape is None:
+ width_px, height_px = _scan_dims(scan)
+ image_shape = (height_px, width_px)
+
+ mask = _mask_from_roi_or_mask(roi_or_mask, image_shape)
+ if mask is None:
+ return adstat.RectangularRegion(
+ width_nm=calibration.width_nm,
+ height_nm=calibration.height_nm,
+ )
+ return adstat.MaskRegion(mask, calibration=calibration, mask_path=_roi_label(roi_or_mask))
+
+
+def feature_layers_to_adstat(
+ layers: Iterable[Any],
+ *,
+ calibration: Any,
+ require_independent: bool = True,
+) -> tuple[Any, ...]:
+ """Convert ProbeFlow-shaped independent feature layers to AdStat layers."""
+
+ adstat = _adstat()
+ converted = []
+ for layer in layers:
+ provenance = dict(_field(layer, "provenance", {}) or {})
+ if require_independent and (
+ provenance.get("measured_independently") is not True
+ or provenance.get("derived_from_particles") is True
+ ):
+ raise ValueError(
+ "feature layers used for AdStat comparison must be independent "
+ "measurements, not maps derived from the tested particles"
+ )
+ kind = str(_field(layer, "kind", _field(layer, "type", "")))
+ name = str(_field(layer, "name", _field(layer, "label", kind or "feature")))
+ feature_type = str(_field(layer, "feature_type", kind or "feature"))
+ source = provenance.get("source") or _field(layer, "source", None)
+ metadata = {"provenance": provenance}
+ if kind == "points":
+ xy_nm = _points_layer_xy_nm(layer, calibration)
+ converted.append(
+ adstat.PointFeatureLayer(
+ name=name,
+ xy_nm=xy_nm,
+ feature_type=feature_type,
+ source=None if source is None else str(source),
+ metadata=metadata,
+ )
+ )
+ elif kind == "lines":
+ segments_nm = _line_layer_segments_nm(layer, calibration)
+ converted.append(
+ adstat.LineFeatureLayer(
+ name=name,
+ segments_nm=segments_nm,
+ feature_type=feature_type,
+ source=None if source is None else str(source),
+ metadata=metadata,
+ )
+ )
+ else:
+ raise ValueError(f"unsupported feature layer kind: {kind!r}")
+ return tuple(converted)
+
+
+def point_set_record(
+ *,
+ dataset_id: str | None,
+ scan: Any,
+ point_source: Any | None = None,
+ feature_counting_items: Iterable[Any] | None = None,
+ roi_or_mask: Any = None,
+ image_shape: tuple[int, int] | None = None,
+ series_value: float | None = None,
+ series_unit: str | None = None,
+ series_label: str | None = None,
+) -> AdStatPointSetRecord:
+ """Build one manifest-like in-memory record for a ProbeFlow scan."""
+
+ calibration = scan_calibration_to_adstat(scan)
+ if point_source is not None:
+ table = point_source_to_particle_table(point_source, scan_id=dataset_id)
+ source_metadata = dict(_field(point_source, "metadata", {}) or {})
+ elif feature_counting_items is not None:
+ table = feature_counting_to_particle_table(
+ feature_counting_items,
+ scan_id=dataset_id,
+ calibration=calibration,
+ )
+ source_metadata = dict(table.metadata)
+ else:
+ raise ValueError("point_set_record requires a point_source or feature_counting_items")
+ region = roi_to_region(roi_or_mask, scan=scan, image_shape=image_shape)
+ return AdStatPointSetRecord(
+ dataset_id=dataset_id,
+ table=table,
+ region=region,
+ calibration=calibration,
+ series_value=series_value,
+ series_unit=series_unit,
+ series_label=series_label,
+ source_metadata=source_metadata,
+ )
+
+
+def workbench_view_spec(
+ *,
+ table: Any,
+ region: Any,
+ summary: Any,
+ comparisons: Iterable[Any] = (),
+ active_model: str | None = None,
+ **kwargs: Any,
+) -> Any:
+ """Return AdStat's GUI-free workbench view spec for ProbeFlow Qt rendering."""
+
+ viz_spec = _adstat_viz_spec()
+ return viz_spec.view_spec_for(
+ viz_spec.WorkbenchViewInput(
+ table=table,
+ region=region,
+ summary=summary,
+ comparisons=tuple(comparisons),
+ active_model=active_model,
+ **kwargs,
+ )
+ )
+
+
+def _pixel_resolution_floor_nm(calibration: Any | None) -> float | None:
+ if calibration is None:
+ return None
+ values = [
+ _optional_float(_field(calibration, "pixel_size_x_nm", None)),
+ _optional_float(_field(calibration, "pixel_size_y_nm", None)),
+ ]
+ finite = [float(value) for value in values if value is not None and value > 0.0]
+ return max(finite) if finite else None
+
+
+def _region_area_nm2(region: Any) -> float:
+ """Best-effort analysis-region area in nm^2 (mask area or rectangle area)."""
+
+ area = float(getattr(region, "area_nm2", 0.0) or 0.0)
+ if area > 0.0:
+ return area
+ width = float(getattr(region, "width_nm", 0.0) or 0.0)
+ height = float(getattr(region, "height_nm", 0.0) or 0.0)
+ return width * height
+
+
+def _default_statistic_scales(
+ region: Any,
+ n_points: int = 0,
+ *,
+ resolution_floor_nm: float | None = None,
+) -> SimpleNamespace:
+ """Derive pair / nearest-neighbor / cluster scales from the analysis region.
+
+ AdStat requires at least one comparison statistic to be configured; passing
+ ``None`` for every scale raises ``"at least one comparison statistic must be
+ configured"``. Real-data callers (the viewer) do not ask the user for these
+ nanometre scales, so we derive teaching-quality defaults from the region size,
+ matching the generated sandbox (a 100 nm field gives a 20 nm radius, a
+ 1 nm bin, a 2 nm cluster radius, and a 1.5 nm hard-core radius).
+ """
+
+ area = float(getattr(region, "area_nm2", 0.0) or 0.0)
+ char = math.sqrt(area) if area > 0.0 else 0.0
+ width = float(getattr(region, "width_nm", 0.0) or 0.0)
+ height = float(getattr(region, "height_nm", 0.0) or 0.0)
+ if char <= 0.0:
+ char = max(min(width, height), max(width, height))
+ if char <= 0.0:
+ char = 100.0 # last-resort fallback for degenerate regions
+ max_radius = 0.2 * char
+ # Distances can only be measured out to roughly half the field; bound the
+ # nearest-neighbor cap below to this so it never runs past the analysis area.
+ distance_cap = 0.5 * char
+ if width > 0.0 and height > 0.0:
+ # Keep radii inside thin regions so edge effects stay bounded.
+ max_radius = min(max_radius, 0.45 * min(width, height))
+ distance_cap = min(distance_cap, 0.45 * min(width, height))
+ floor = (
+ float(resolution_floor_nm)
+ if resolution_floor_nm is not None and resolution_floor_nm > 0.0
+ else None
+ )
+ raw_bin_width = max_radius / 20.0
+ bin_width = max(raw_bin_width, floor) if floor is not None else raw_bin_width
+
+ mean_spacing = (
+ math.sqrt(area / float(n_points)) if n_points > 0 and area > 0.0 else 0.0
+ )
+ # The nearest-neighbor distribution is set by point density, not by the pair
+ # radius: in sparse fields the typical NN distance exceeds 0.2*char, so a
+ # pair-radius cap would clip the histogram exactly where the signal lives.
+ # Extend the NN range to ~1.5x the mean spacing (covering the bulk of the
+ # Poisson NN tail), never below the pair radius and never past the field.
+ nn_max_distance = max_radius
+ if mean_spacing > 0.0:
+ nn_max_distance = min(max(max_radius, 1.5 * mean_spacing), distance_cap)
+ raw_nn_bin_width = nn_max_distance / 20.0
+ nn_bin_width = max(raw_nn_bin_width, floor) if floor is not None else raw_nn_bin_width
+
+ # Hard-core radius mirrors the sandbox ratio (1.5 nm in a 100 nm field) but is
+ # capped below the mean point spacing so the null model can always place the
+ # same number of points without exhausting its attempt limit.
+ hard_core_radius = 0.015 * char
+ if mean_spacing > 0.0 and n_points > 1:
+ hard_core_radius = min(hard_core_radius, 0.5 * mean_spacing)
+ return SimpleNamespace(
+ pair_bin_width_nm=bin_width,
+ pair_max_radius_nm=max_radius,
+ nn_bin_width_nm=nn_bin_width,
+ nn_max_distance_nm=nn_max_distance,
+ cluster_radius_nm=max_radius * 0.1,
+ hard_core_radius_nm=hard_core_radius,
+ # Feature-conditioned Poisson smooths the measured feature layer with this
+ # kernel; mirror the sandbox ratio (4 nm in a 100 nm field).
+ feature_kernel_sigma_nm=0.04 * char,
+ resolution_floor_nm=floor,
+ raw_pair_bin_width_nm=raw_bin_width,
+ bin_width_resolution_limited=(
+ bool(floor is not None and raw_bin_width < floor)
+ ),
+ )
+
+
+def _bond_order_neighbor_radius_nm(
+ table: Any,
+ *,
+ resolution_floor_nm: float | None = None,
+) -> float | None:
+ try:
+ xy_nm = np.asarray(table.xy_nm, dtype=float)
+ except Exception: # noqa: BLE001 - optional stat should not block analysis
+ return None
+ if xy_nm.ndim != 2 or xy_nm.shape[1] != 2 or len(xy_nm) < 2:
+ return None
+ deltas = xy_nm[:, None, :] - xy_nm[None, :, :]
+ distances = np.linalg.norm(deltas, axis=2)
+ np.fill_diagonal(distances, np.inf)
+ nearest = distances.min(axis=1)
+ nearest = nearest[np.isfinite(nearest) & (nearest > 0.0)]
+ if len(nearest) == 0:
+ return None
+ radius = 1.35 * float(np.median(nearest))
+ if resolution_floor_nm is not None and resolution_floor_nm > 0.0:
+ radius = max(radius, float(resolution_floor_nm))
+ return radius
+
+
+def _resolution_status_lines(
+ scales: SimpleNamespace,
+ *,
+ explicit_pair_bin_width_nm: float | None = None,
+ explicit_nn_bin_width_nm: float | None = None,
+) -> tuple[str, ...]:
+ floor = getattr(scales, "resolution_floor_nm", None)
+ if floor is None or float(floor) <= 0.0:
+ return ()
+ lines: list[str] = []
+ if _automatic_bins_resolution_limited(
+ scales,
+ explicit_pair_bin_width_nm=explicit_pair_bin_width_nm,
+ explicit_nn_bin_width_nm=explicit_nn_bin_width_nm,
+ ):
+ lines.append(
+ f"Pixel size is {float(floor):g} nm; automatic distance bins were "
+ "clamped to this resolution floor."
+ )
+ for label, value in (
+ ("pair", explicit_pair_bin_width_nm),
+ ("nearest-neighbor", explicit_nn_bin_width_nm),
+ ):
+ if value is not None and float(value) < float(floor):
+ lines.append(
+ f"Warning: explicit {label} bin width {float(value):g} nm is "
+ f"below the pixel-size floor ({float(floor):g} nm)."
+ )
+ return tuple(lines)
+
+
+def _automatic_bins_resolution_limited(
+ scales: SimpleNamespace,
+ *,
+ explicit_pair_bin_width_nm: float | None = None,
+ explicit_nn_bin_width_nm: float | None = None,
+) -> bool:
+ if not getattr(scales, "bin_width_resolution_limited", False):
+ return False
+ return explicit_pair_bin_width_nm is None or explicit_nn_bin_width_nm is None
+
+
+def _with_resolution_metadata(
+ spec: Any,
+ *,
+ scales: SimpleNamespace,
+ status_lines: tuple[str, ...],
+ resolution_limited: bool | None = None,
+) -> Any:
+ if not status_lines and getattr(scales, "resolution_floor_nm", None) is None:
+ return spec
+ metadata = dict(getattr(spec, "metadata", {}) or {})
+ floor = getattr(scales, "resolution_floor_nm", None)
+ if floor is not None:
+ metadata["pixel_resolution_floor_nm"] = float(floor)
+ if resolution_limited is None:
+ resolution_limited = bool(
+ getattr(scales, "bin_width_resolution_limited", False)
+ )
+ metadata["bin_width_resolution_limited"] = bool(
+ resolution_limited
+ )
+ return replace(
+ spec,
+ status_lines=tuple(getattr(spec, "status_lines", ()) or ()) + status_lines,
+ metadata=metadata,
+ )
+
+
+def _filter_ordering_statistics(spec: Any) -> Any:
+ """Drop local-order panels and verdict rows from a view spec.
+
+ Used when ordering statistics are not requested, so the display never shows
+ ψ4/ψ6/g(r,θ) as co-equal verdicts in a plain randomness analysis. Verdict
+ rows carry the statistic id at index 1.
+ """
+
+ panels = tuple(getattr(spec, "panels", ()) or ())
+ verdict_rows = tuple(getattr(spec, "verdict_rows", ()) or ())
+ kept_panels = tuple(
+ p for p in panels if str(getattr(p, "statistic", "")) not in ORDERING_STATISTICS
+ )
+ kept_rows = tuple(
+ row for row in verdict_rows if not (len(row) > 1 and str(row[1]) in ORDERING_STATISTICS)
+ )
+ if len(kept_panels) == len(panels) and len(kept_rows) == len(verdict_rows):
+ return spec
+ try:
+ return replace(spec, panels=kept_panels, verdict_rows=kept_rows)
+ except TypeError: # pragma: no cover - non-dataclass spec
+ return spec
+
+
+def compare_particle_collection_view_spec(
+ *,
+ scan: Any,
+ point_source: Any | None = None,
+ feature_counting_items: Iterable[Any] | None = None,
+ roi_or_mask: Any = None,
+ image_shape: tuple[int, int] | None = None,
+ scan_id: str | None = None,
+ feature_layers: Iterable[Any] = (),
+ models: Iterable[str] = ("poisson",),
+ pair_bin_width_nm: float | None = None,
+ pair_max_radius_nm: float | None = None,
+ nn_bin_width_nm: float | None = None,
+ nn_max_distance_nm: float | None = None,
+ cluster_radius_nm: float | None = None,
+ n_simulations: int = 19,
+ random_seed: int | None = 0,
+ include_ordering: bool = False,
+) -> Any:
+ """Run AdStat for one ProbeFlow point collection.
+
+ This is the ProbeFlow-side contract wrapper for any v1 point population:
+ Feature Finder/ROI ``PointSource`` records or raw Feature Counting/template
+ records. The current viewer UI supplies ``point_source``; Feature Counting
+ and series callers can use the same API once they can hand their records to
+ the viewer layer.
+
+ ``include_ordering`` controls the opt-in local-order statistics (ψ4/ψ6 and
+ the angular pair map). When ``False`` (the default) they are neither computed
+ nor shown, so a plain randomness analysis is not cluttered with lattice
+ verdicts that answer a different question.
+ """
+
+ adstat_analysis = _adstat_analysis()
+ record = point_set_record(
+ dataset_id=scan_id,
+ scan=scan,
+ point_source=point_source,
+ feature_counting_items=feature_counting_items,
+ roi_or_mask=roi_or_mask,
+ image_shape=image_shape,
+ )
+ table = record.table
+ region = record.region
+ try:
+ n_points = len(table)
+ except TypeError:
+ n_points = 0
+ resolution_floor_nm = _pixel_resolution_floor_nm(record.calibration)
+ explicit_pair_bin_width_nm = pair_bin_width_nm
+ explicit_nn_bin_width_nm = nn_bin_width_nm
+ scales = _default_statistic_scales(
+ region,
+ n_points=n_points,
+ resolution_floor_nm=resolution_floor_nm,
+ )
+ if pair_bin_width_nm is None:
+ pair_bin_width_nm = scales.pair_bin_width_nm
+ if pair_max_radius_nm is None:
+ pair_max_radius_nm = scales.pair_max_radius_nm
+ if nn_bin_width_nm is None:
+ nn_bin_width_nm = scales.nn_bin_width_nm
+ if nn_max_distance_nm is None:
+ nn_max_distance_nm = scales.nn_max_distance_nm
+ if cluster_radius_nm is None:
+ cluster_radius_nm = scales.cluster_radius_nm
+ converted_layers = feature_layers_to_adstat(
+ feature_layers,
+ calibration=record.calibration,
+ )
+ comparison_feature_layer = converted_layers[0] if converted_layers else None
+ pair_angle_bin_width_deg = 15.0 if include_ordering else None
+ bond_order_neighbor_radius_nm = (
+ _bond_order_neighbor_radius_nm(table, resolution_floor_nm=resolution_floor_nm)
+ if include_ordering
+ else None
+ )
+ summary = adstat_analysis.summarize_particle_table(
+ table,
+ region=region,
+ feature_layers=converted_layers,
+ cluster_radius_nm=cluster_radius_nm,
+ pair_bin_width_nm=pair_bin_width_nm,
+ pair_max_radius_nm=pair_max_radius_nm,
+ pair_angle_bin_width_deg=pair_angle_bin_width_deg,
+ pair_reference_angle_deg=0.0,
+ )
+ comparisons = adstat_analysis.compare_particle_table(
+ table,
+ region=region,
+ models=tuple(models),
+ pair_bin_width_nm=pair_bin_width_nm,
+ pair_max_radius_nm=pair_max_radius_nm,
+ pair_angle_bin_width_deg=pair_angle_bin_width_deg,
+ pair_reference_angle_deg=0.0,
+ nn_bin_width_nm=nn_bin_width_nm,
+ nn_max_distance_nm=nn_max_distance_nm,
+ cluster_radius_nm=cluster_radius_nm,
+ bond_order_neighbor_radius_nm=bond_order_neighbor_radius_nm,
+ hard_core_radius_nm=scales.hard_core_radius_nm,
+ poisson_seed=random_seed,
+ hard_core_seed=random_seed,
+ feature_layer=comparison_feature_layer,
+ feature_kernel_sigma_nm=scales.feature_kernel_sigma_nm,
+ feature_seed=random_seed,
+ n_simulations=int(n_simulations),
+ )
+ active_model = comparisons[0].model_name if comparisons else next(iter(models), None)
+ spec = workbench_view_spec(
+ table=table,
+ region=region,
+ summary=summary,
+ comparisons=comparisons,
+ active_model=active_model,
+ feature_xy_nm=_first_point_feature_xy_nm(converted_layers),
+ )
+ spec = _with_resolution_metadata(
+ spec,
+ scales=scales,
+ status_lines=_resolution_status_lines(
+ scales,
+ explicit_pair_bin_width_nm=explicit_pair_bin_width_nm,
+ explicit_nn_bin_width_nm=explicit_nn_bin_width_nm,
+ ),
+ resolution_limited=_automatic_bins_resolution_limited(
+ scales,
+ explicit_pair_bin_width_nm=explicit_pair_bin_width_nm,
+ explicit_nn_bin_width_nm=explicit_nn_bin_width_nm,
+ ),
+ )
+ return spec if include_ordering else _filter_ordering_statistics(spec)
+
+
+def compare_point_source_view_spec(
+ point_source: Any,
+ *,
+ scan: Any,
+ roi_or_mask: Any = None,
+ image_shape: tuple[int, int] | None = None,
+ scan_id: str | None = None,
+ feature_layers: Iterable[Any] = (),
+ models: Iterable[str] = ("poisson",),
+ pair_bin_width_nm: float | None = None,
+ pair_max_radius_nm: float | None = None,
+ nn_bin_width_nm: float | None = None,
+ nn_max_distance_nm: float | None = None,
+ cluster_radius_nm: float | None = None,
+ n_simulations: int = 19,
+ random_seed: int | None = 0,
+ include_ordering: bool = False,
+) -> Any:
+ """Run the direct AdStat PointSource path and return a Qt-renderable view spec."""
+
+ return compare_particle_collection_view_spec(
+ scan=scan,
+ point_source=point_source,
+ roi_or_mask=roi_or_mask,
+ image_shape=image_shape,
+ scan_id=scan_id,
+ feature_layers=feature_layers,
+ models=models,
+ pair_bin_width_nm=pair_bin_width_nm,
+ pair_max_radius_nm=pair_max_radius_nm,
+ nn_bin_width_nm=nn_bin_width_nm,
+ nn_max_distance_nm=nn_max_distance_nm,
+ cluster_radius_nm=cluster_radius_nm,
+ n_simulations=n_simulations,
+ random_seed=random_seed,
+ include_ordering=include_ordering,
+ )
+
+
+def compare_point_set_record_view_spec(
+ record: Any,
+ *,
+ models: Iterable[str] = ("poisson",),
+ feature_layers: Iterable[Any] = (),
+ n_simulations: int = 60,
+ random_seed: int | None = 0,
+ include_ordering: bool = False,
+) -> Any:
+ """Single-image workbench view spec from a prebuilt point-set record.
+
+ Uses the record's own table/region/calibration (so a saved set is analysed
+ with the image it came from, not the current viewer image), deriving statistic
+ scales from its region like the live single-image path. ``feature_layers`` are
+ independently-measured layers used by the measured-feature Poisson model.
+ ``include_ordering`` adds the opt-in ψ4/ψ6/g(r,θ) local-order statistics.
+ """
+
+ adstat_analysis = _adstat_analysis()
+ table = record.table
+ region = record.region
+ try:
+ n_points = len(table)
+ except TypeError:
+ n_points = 0
+ resolution_floor_nm = _pixel_resolution_floor_nm(record.calibration)
+ scales = _default_statistic_scales(
+ region,
+ n_points=n_points,
+ resolution_floor_nm=resolution_floor_nm,
+ )
+ converted_layers = feature_layers_to_adstat(
+ feature_layers, calibration=record.calibration
+ )
+ comparison_feature_layer = converted_layers[0] if converted_layers else None
+ pair_angle_bin_width_deg = 15.0 if include_ordering else None
+ bond_order_neighbor_radius_nm = (
+ _bond_order_neighbor_radius_nm(table, resolution_floor_nm=resolution_floor_nm)
+ if include_ordering
+ else None
+ )
+ summary = adstat_analysis.summarize_particle_table(
+ table,
+ region=region,
+ feature_layers=converted_layers,
+ cluster_radius_nm=scales.cluster_radius_nm,
+ pair_bin_width_nm=scales.pair_bin_width_nm,
+ pair_max_radius_nm=scales.pair_max_radius_nm,
+ pair_angle_bin_width_deg=pair_angle_bin_width_deg,
+ pair_reference_angle_deg=0.0,
+ )
+ comparisons = adstat_analysis.compare_particle_table(
+ table,
+ region=region,
+ models=tuple(models),
+ pair_bin_width_nm=scales.pair_bin_width_nm,
+ pair_max_radius_nm=scales.pair_max_radius_nm,
+ pair_angle_bin_width_deg=pair_angle_bin_width_deg,
+ pair_reference_angle_deg=0.0,
+ nn_bin_width_nm=scales.nn_bin_width_nm,
+ nn_max_distance_nm=scales.nn_max_distance_nm,
+ cluster_radius_nm=scales.cluster_radius_nm,
+ bond_order_neighbor_radius_nm=bond_order_neighbor_radius_nm,
+ hard_core_radius_nm=scales.hard_core_radius_nm,
+ hard_core_seed=random_seed,
+ feature_layer=comparison_feature_layer,
+ feature_kernel_sigma_nm=scales.feature_kernel_sigma_nm,
+ poisson_seed=random_seed,
+ feature_seed=random_seed,
+ n_simulations=int(n_simulations),
+ )
+ active_model = comparisons[0].model_name if comparisons else next(iter(models), None)
+ spec = workbench_view_spec(
+ table=table,
+ region=region,
+ summary=summary,
+ comparisons=comparisons,
+ active_model=active_model,
+ feature_xy_nm=_first_point_feature_xy_nm(converted_layers),
+ )
+ spec = _with_resolution_metadata(
+ spec,
+ scales=scales,
+ status_lines=_resolution_status_lines(scales),
+ )
+ return spec if include_ordering else _filter_ordering_statistics(spec)
+
+
+def compare_point_set_records_view_spec(
+ records: Iterable[Any],
+ *,
+ models: Iterable[str] = ("poisson",),
+ n_simulations: int = 60,
+ random_seed: int | None = 0,
+) -> Any:
+ """Pool several saved point-set records as replicates → one combined view spec.
+
+ Each ``record`` is an :class:`AdStatPointSetRecord` (e.g. from
+ ``FeatureSet.to_point_set_record``). Statistic scales are derived from the
+ first record's region (shared across all images so AdStat can pool them), and
+ the hard-core radius from the smallest point count. Renders through AdStat's
+ coverage-series view spec, which exposes per-statistic pooled panels plus the
+ combined verdict rows.
+ """
+
+ record_list = list(records)
+ if not record_list:
+ raise ValueError("compare_point_set_records_view_spec requires at least one record")
+ adstat_analysis = _adstat_analysis()
+ viz_spec = _adstat_viz_spec()
+
+ counts = [len(rec.table) for rec in record_list]
+ floors = [
+ _pixel_resolution_floor_nm(rec.calibration)
+ for rec in record_list
+ ]
+ finite_floors = [floor for floor in floors if floor is not None and floor > 0.0]
+ resolution_floor_nm = max(finite_floors) if finite_floors else None
+ scales = _default_statistic_scales(
+ record_list[0].region,
+ n_points=min(counts),
+ resolution_floor_nm=resolution_floor_nm,
+ )
+ # Statistic scales come from the first record's region; warn if the pooled
+ # images are heterogeneous enough that the shared scales may not suit them.
+ pool_status_lines: list[str] = []
+ areas = [_region_area_nm2(rec.region) for rec in record_list]
+ finite_areas = [area for area in areas if area > 0.0]
+ if len(finite_areas) >= 2 and max(finite_areas) > 1.5 * min(finite_areas):
+ pool_status_lines.append(
+ "Pooled images span different analysis-region areas "
+ f"({min(finite_areas):.0f}-{max(finite_areas):.0f} nm^2); statistic "
+ "scales are taken from the first image and may not suit all of them."
+ )
+ if len(finite_floors) >= 2 and max(finite_floors) > 1.5 * min(finite_floors):
+ pool_status_lines.append(
+ "Pooled images have different pixel sizes; distance bins use the "
+ f"coarsest resolution floor ({max(finite_floors):g} nm)."
+ )
+ overrides = adstat_analysis.SeriesAnalysisOverrides(
+ pair_bin_width_nm=scales.pair_bin_width_nm,
+ pair_max_radius_nm=scales.pair_max_radius_nm,
+ nn_bin_width_nm=scales.nn_bin_width_nm,
+ nn_max_distance_nm=scales.nn_max_distance_nm,
+ cluster_radius_nm=scales.cluster_radius_nm,
+ hard_core_radius_nm=scales.hard_core_radius_nm,
+ )
+ items = [
+ (rec.table, rec.region, rec.dataset_id or f"image_{index + 1}")
+ for index, rec in enumerate(record_list)
+ ]
+ result = adstat_analysis.pool_particle_tables(
+ items,
+ models=tuple(models),
+ n_simulations=int(n_simulations),
+ random_seed=random_seed,
+ overrides=overrides,
+ )
+ spec = viz_spec.view_spec_for(result)
+ return _with_resolution_metadata(
+ spec,
+ scales=scales,
+ status_lines=_resolution_status_lines(scales) + tuple(pool_status_lines),
+ )
+
+
+def adstat_sandbox_context() -> Any:
+ """Return AdStat's GUI-free sandbox symbols for ProbeFlow Qt shells."""
+
+ sandbox = _adstat_sandbox()
+ return SimpleNamespace(
+ SandboxConfig=sandbox.SandboxConfig,
+ SandboxState=sandbox.SandboxState,
+ SANDBOX_PATTERNS=tuple(sandbox.SANDBOX_PATTERNS),
+ SANDBOX_MODELS=tuple(sandbox.SANDBOX_MODELS),
+ ORDERED_ISLAND_LATTICES=tuple(
+ getattr(sandbox, "ORDERED_ISLAND_LATTICES", ("triangular", "square"))
+ ),
+ ORDERED_ISLAND_BACKGROUNDS=tuple(
+ getattr(
+ sandbox,
+ "ORDERED_ISLAND_BACKGROUNDS",
+ ("none", "random", "clustered"),
+ )
+ ),
+ )
+
+
+def adstat_sandbox_preview(config: Any, *, active_model: str | None = None) -> Any:
+ """Return lightweight generated-data preview points without full comparison."""
+
+ sandbox = _adstat_sandbox()
+ region = sandbox.RectangularRegion(
+ width_nm=float(config.width_nm),
+ height_nm=float(config.height_nm),
+ )
+ feature_layer = sandbox.synthetic_feature_layer(config)
+ table = sandbox.generate_demo_pattern(
+ config,
+ region=region,
+ feature_layer=feature_layer,
+ rng=np.random.default_rng(int(config.seed)),
+ )
+ simulated = None
+ if active_model and hasattr(sandbox, "_simulate_overlay"):
+ try:
+ simulated = sandbox._simulate_overlay( # noqa: SLF001 - adapter shields GUI from AdStat internals
+ config,
+ region,
+ feature_layer,
+ active_model,
+ len(table),
+ )
+ except Exception: # noqa: BLE001 - preview overlay is best-effort, never block the preview
+ simulated = None
+ return SimpleNamespace(
+ xy_nm=np.asarray(table.xy_nm, dtype=float),
+ width_nm=float(config.width_nm),
+ height_nm=float(config.height_nm),
+ feature_xy_nm=np.asarray(feature_layer.xy_nm, dtype=float),
+ simulated_xy_nm=(
+ None if simulated is None else np.asarray(simulated.xy_nm, dtype=float)
+ ),
+ )
+
+
+def adstat_sandbox_state(config: Any | None = None) -> Any:
+ """Create a default AdStat sandbox state lazily."""
+
+ context = adstat_sandbox_context()
+ return context.SandboxState(config)
+
+
+def adstat_sandbox_view_spec(state: Any, *, include_ordering: bool = False) -> Any:
+ """Return a ProbeFlow-renderable sandbox view spec.
+
+ AdStat's native sandbox view spec carries the status, verdict rows, and
+ active-model panels. ProbeFlow prepends a real-space panel so the teaching
+ dialog always has a visible point pattern once a sandbox run exists.
+
+ The sandbox backend always computes every statistic, so when
+ ``include_ordering`` is ``False`` (the default) the ψ4/ψ6/g(r,θ) panels and
+ verdict rows are filtered out for display, matching the opt-in behaviour of
+ the real-data path.
+ """
+
+ viz_spec = _adstat_viz_spec()
+ spec = viz_spec.view_spec_for(state)
+ if not include_ordering:
+ spec = _filter_ordering_statistics(spec)
+ result = getattr(state, "result", None)
+ if result is None:
+ return spec
+
+ feature_layer = getattr(result, "feature_layer", None)
+ feature_xy_nm = getattr(feature_layer, "xy_nm", None)
+ table = getattr(result, "table", None)
+ region = getattr(result, "region", None)
+ observed = getattr(table, "xy_nm", None)
+ panel = viz_spec.PanelSpec(
+ statistic="sandbox_realspace",
+ title="generated point pattern",
+ kind="realspace",
+ x_label="x (nm)",
+ y_label="y (nm)",
+ reference_line=None,
+ glossary_term="roi_mask",
+ verdict_label="",
+ verdict_color=viz_spec.verdict_color(""),
+ global_p=float("nan"),
+ observed=observed,
+ caption_lines=(
+ "TEST MODE - GENERATED DATA",
+ f"pattern: {getattr(getattr(state, 'config', None), 'pattern', '')}",
+ f"seed: {getattr(getattr(state, 'config', None), 'seed', '')}",
+ f"simulations: {getattr(getattr(state, 'config', None), 'n_simulations', '')}",
+ ),
+ metadata={
+ "data_mode": "sandbox",
+ "table": table,
+ "region": region,
+ "simulated": getattr(result, "simulated", None),
+ "feature_xy_nm": feature_xy_nm,
+ "overlays": ("simulated", "features"),
+ "particle_count": len(table) if table is not None else 0,
+ },
+ )
+ return replace(spec, panels=(panel, *tuple(spec.panels)))
+
+
+def _first_point_feature_xy_nm(layers: Iterable[Any]) -> np.ndarray | None:
+ for layer in layers:
+ xy_nm = _field(layer, "xy_nm", None)
+ if xy_nm is not None:
+ return _xy_array(xy_nm, name="feature_xy_nm")
+ return None
+
+
+def _adstat() -> Any:
+ try:
+ import adstat
+ except ImportError as exc: # pragma: no cover - depends on optional package
+ raise ImportError(
+ "ProbeFlow's AdStat adapter requires the optional 'adstat' package. "
+ "Install AdStat or run ProbeFlow with the AdStat source tree on PYTHONPATH."
+ ) from exc
+ return adstat
+
+
+def _adstat_analysis() -> Any:
+ try:
+ from adstat import analysis
+ except ImportError as exc: # pragma: no cover - depends on optional package
+ raise ImportError(
+ "ProbeFlow's AdStat comparison path requires adstat.analysis."
+ ) from exc
+ return analysis
+
+
+def _adstat_viz_spec() -> Any:
+ try:
+ from adstat import viz_spec
+ except ImportError as exc: # pragma: no cover - depends on optional package
+ raise ImportError(
+ "ProbeFlow's AdStat view-spec path requires adstat.viz_spec."
+ ) from exc
+ return viz_spec
+
+
+def _adstat_sandbox() -> Any:
+ try:
+ from adstat.analysis import sandbox
+ except ImportError as exc: # pragma: no cover - depends on optional package
+ raise ImportError(
+ "ProbeFlow's AdStat sandbox requires adstat.analysis.sandbox."
+ ) from exc
+ return sandbox
+
+
+def _scan_dims(scan: Any) -> tuple[int, int]:
+ dims = _field(scan, "dims", None)
+ if callable(dims):
+ dims = dims()
+ if dims is not None:
+ return int(dims[0]), int(dims[1])
+ planes = _field(scan, "planes", None)
+ if planes:
+ height_px, width_px = np.asarray(planes[0]).shape[:2]
+ return int(width_px), int(height_px)
+ raise ValueError("scan must provide dims or at least one plane")
+
+
+def _scan_range_m(scan: Any) -> tuple[float, float]:
+ scan_range = _field(scan, "scan_range_m", None)
+ if scan_range is None:
+ raise ValueError("scan must provide scan_range_m")
+ return float(scan_range[0]), float(scan_range[1])
+
+
+def _field(obj: Any, name: str, default: Any = None) -> Any:
+ if isinstance(obj, Mapping):
+ return obj.get(name, default)
+ return getattr(obj, name, default)
+
+
+def _xy_array(values: Any, *, name: str) -> np.ndarray:
+ arr = np.asarray(values, dtype=float)
+ if arr.ndim != 2 or arr.shape[1] != 2:
+ raise ValueError(f"{name} must have shape (N, 2)")
+ return arr
+
+
+def _point_id(metadata: Mapping[str, object], index: int) -> str | None:
+ ids = metadata.get("point_ids")
+ if ids is None:
+ return None
+ try:
+ return str(list(ids)[index])
+ except Exception:
+ return None
+
+
+def _feature_counting_kind(item: Any) -> str:
+ if _field(item, "centroid_x_m", None) is not None:
+ return "particles"
+ if _field(item, "x_m", None) is not None:
+ return "detections"
+ return str(_field(item, "kind", "feature_counting"))
+
+
+def _feature_counting_xy_nm(item: Any) -> tuple[float, float]:
+ if _field(item, "centroid_x_m", None) is not None:
+ return (
+ float(_field(item, "centroid_x_m")) * 1e9,
+ float(_field(item, "centroid_y_m")) * 1e9,
+ )
+ if _field(item, "x_m", None) is not None:
+ return float(_field(item, "x_m")) * 1e9, float(_field(item, "y_m")) * 1e9
+ if _field(item, "x_nm", None) is not None:
+ return float(_field(item, "x_nm")), float(_field(item, "y_nm"))
+ raise ValueError("feature counting item needs metre or nanometre coordinates")
+
+
+def _feature_counting_xy_px(
+ item: Any,
+ *,
+ calibration: Any | None,
+) -> tuple[float | None, float | None]:
+ x_px = _optional_float(_field(item, "x_px", None))
+ y_px = _optional_float(_field(item, "y_px", None))
+ if x_px is not None and y_px is not None:
+ return x_px, y_px
+ if calibration is None:
+ return None, None
+ x_nm, y_nm = _feature_counting_xy_nm(item)
+ xy_px = calibration.nm_to_pixel(np.asarray([[x_nm, y_nm]], dtype=float))[0]
+ return float(xy_px[0]), float(xy_px[1])
+
+
+def _feature_counting_id(item: Any, fallback_index: int) -> str:
+ for field in ("id", "point_id", "particle_id"):
+ value = _field(item, field, None)
+ if value not in (None, ""):
+ return str(value)
+ value = _field(item, "index", fallback_index)
+ return str(value)
+
+
+def _feature_counting_confidence(item: Any) -> float | None:
+ for field in ("confidence", "correlation", "similarity"):
+ value = _optional_float(_field(item, field, None))
+ if value is not None:
+ return value
+ return None
+
+
+def _feature_counting_metadata(
+ item: Any,
+ source_kind: str,
+ status: object,
+) -> dict[str, object]:
+ metadata = {
+ "probeflow_source": "feature_counting",
+ "probeflow_source_type": source_kind,
+ "probeflow_status": status,
+ }
+ for field in (
+ "index",
+ "bbox_m",
+ "bbox_px",
+ "mean_height",
+ "max_height",
+ "min_height",
+ "n_pixels",
+ "sharpness",
+ "correlation",
+ "local_height",
+ ):
+ value = _field(item, field, None)
+ if value is not None:
+ metadata[field] = value
+ return metadata
+
+
+def _mask_from_roi_or_mask(
+ roi_or_mask: Any,
+ image_shape: tuple[int, int],
+) -> np.ndarray | None:
+ if roi_or_mask is None:
+ return None
+ if isinstance(roi_or_mask, np.ndarray) or _looks_like_mask(roi_or_mask):
+ mask = np.asarray(roi_or_mask, dtype=bool)
+ if mask.shape != tuple(image_shape):
+ raise ValueError(
+ f"mask shape must match image_shape {tuple(image_shape)}, got {mask.shape}"
+ )
+ return mask
+ if _field(roi_or_mask, "kind", None) not in {
+ "rectangle",
+ "ellipse",
+ "polygon",
+ "freehand",
+ "multipolygon",
+ }:
+ return None
+ try:
+ mask = np.asarray(roi_or_mask.to_mask(tuple(image_shape)), dtype=bool)
+ except Exception as exc:
+ raise ValueError("could not rasterize ProbeFlow ROI for AdStat") from exc
+ if not mask.any():
+ raise ValueError("analysis ROI mask must contain at least one allowed pixel")
+ return mask
+
+
+def _looks_like_mask(value: Any) -> bool:
+ return isinstance(value, (list, tuple)) and np.asarray(value).ndim == 2
+
+
+def _roi_label(roi_or_mask: Any) -> str | None:
+ if roi_or_mask is None or isinstance(roi_or_mask, np.ndarray) or _looks_like_mask(roi_or_mask):
+ return None
+ return _optional_str(_field(roi_or_mask, "name", _field(roi_or_mask, "id", None)))
+
+
+def _points_layer_xy_nm(layer: Any, calibration: Any) -> np.ndarray:
+ if _field(layer, "xy_nm", None) is not None:
+ return _xy_array(_field(layer, "xy_nm"), name="xy_nm")
+ if _field(layer, "points_nm", None) is not None:
+ return _xy_array(_field(layer, "points_nm"), name="points_nm")
+ points = _field(layer, "points_px", None)
+ if points is None:
+ points = _field(layer, "points", None)
+ if points is None:
+ raise ValueError("point feature layer requires points_px or xy_nm")
+ xy_px = np.asarray(
+ [[_field(point, "x_px"), _field(point, "y_px")] for point in points],
+ dtype=float,
+ )
+ return calibration.pixel_to_nm(xy_px)
+
+
+def _line_layer_segments_nm(layer: Any, calibration: Any) -> np.ndarray:
+ if _field(layer, "segments_nm", None) is not None:
+ return np.asarray(_field(layer, "segments_nm"), dtype=float)
+ segments = _field(layer, "segments_px", None)
+ if segments is None:
+ segments = _field(layer, "segments", None)
+ if segments is None:
+ raise ValueError("line feature layer requires segments_px or segments_nm")
+ converted = []
+ for segment in segments:
+ endpoints_px = np.asarray(
+ [
+ [_field(segment, "x1_px"), _field(segment, "y1_px")],
+ [_field(segment, "x2_px"), _field(segment, "y2_px")],
+ ],
+ dtype=float,
+ )
+ converted.append(calibration.pixel_to_nm(endpoints_px))
+ return np.asarray(converted, dtype=float)
+
+
+def _optional_float(value: Any) -> float | None:
+ if value in (None, ""):
+ return None
+ return float(value)
+
+
+def _optional_str(value: Any) -> str | None:
+ if value in (None, ""):
+ return None
+ return str(value)
diff --git a/probeflow/gui/app.py b/probeflow/gui/app.py
index 4941b5e..12ee71e 100644
--- a/probeflow/gui/app.py
+++ b/probeflow/gui/app.py
@@ -388,6 +388,12 @@ def _build_ui(self):
# Floating Feature Counting window (lazy-created on first open)
self._fc_window = None
+ # One application-level feature-set store shared across image viewers,
+ # Feature Counting, and file imports, so points from any source can be
+ # pooled together in Particle Statistics.
+ from probeflow.measurements.feature_sets import FeatureSetStore
+ self._feature_set_store = FeatureSetStore()
+
# TV-denoise tab plumbing
self._tv_pool = QThreadPool.globalInstance()
self._tv_signals = _TVWorkerSignals()
@@ -434,7 +440,10 @@ def _build_ui(self):
status_cb=lambda msg: self._status_bar.showMessage(msg),
preview_pool=self._features_preview_pool,
parent_widget=self,
+ feature_set_store=self._feature_set_store,
)
+ self._features_ctrl.open_particle_statistics_requested.connect(
+ self._on_open_particle_statistics_from_features)
# Status bar
self._status_bar = QStatusBar()
@@ -1156,9 +1165,13 @@ def _open_fc_window(self) -> None:
if self._fc_window is None:
from probeflow.gui.features.window import FeatureCountingWindow
theme = THEMES[self._theme_name]
- self._fc_window = FeatureCountingWindow(parent=None, theme=theme)
+ self._fc_window = FeatureCountingWindow(
+ parent=None, theme=theme, feature_set_store=self._feature_set_store
+ )
self._fc_window.load_from_browse_needed.connect(
self._on_fc_load_from_browse)
+ self._fc_window.open_particle_statistics_needed.connect(
+ self._on_open_particle_statistics_from_features)
self._fc_window.show()
self._fc_window.raise_()
self._fc_window.activateWindow()
@@ -1273,6 +1286,43 @@ def _load_scan_plane_for_analysis(
return arr, px_m, px_x_m, px_y_m, plane_idx, analysis_scan
+ def _on_open_particle_statistics_from_features(self, scan_context, set_id: str) -> None:
+ """Open (or raise) Particle Statistics for a set sent from Feature Counting."""
+ from probeflow.gui.dialogs.particle_statistics import ParticleStatisticsDialog
+
+ theme = THEMES[self._theme_name]
+ dlg = getattr(self, "_features_particle_statistics_dialog", None)
+ try:
+ if dlg is not None:
+ dlg.isVisible()
+ except RuntimeError:
+ dlg = None
+ if dlg is None:
+ dlg = ParticleStatisticsDialog(
+ scan=scan_context,
+ feature_set_store=self._feature_set_store,
+ theme=theme,
+ initial_mode="real",
+ parent=None,
+ )
+ self._features_particle_statistics_dialog = dlg
+ try:
+ dlg.destroyed.connect(
+ lambda _obj=None: setattr(self, "_features_particle_statistics_dialog", None)
+ )
+ except Exception:
+ pass
+ else:
+ dlg.refresh_probe_context(
+ scan=scan_context, feature_set_store=self._feature_set_store
+ )
+ dlg.set_current_mode("real")
+ dlg.show()
+ dlg.raise_()
+ dlg.activateWindow()
+ if set_id:
+ dlg.select_feature_set(set_id)
+
def _on_fc_load_from_browse(self) -> None:
"""Bridge: read Browse selection → load into the floating FC window (off-thread)."""
from probeflow.gui.models import VertFile
@@ -1430,6 +1480,8 @@ def _open_viewer(self, entry):
processing=proc,
spec_image_map=self._spec_image_map,
initial_plane_idx=initial_plane_idx)
+ # Share the application-level feature-set store with this viewer.
+ dlg._feature_set_store_obj = self._feature_set_store
# Use show() instead of exec() so the dialog is non-modal: the browse
# window stays interactive, and all child windows (FFT viewer, Reciprocal
# Grid panel, etc.) get normal macOS window controls (minimize, resize).
diff --git a/probeflow/gui/dialogs/__init__.py b/probeflow/gui/dialogs/__init__.py
index 417780d..1aae257 100644
--- a/probeflow/gui/dialogs/__init__.py
+++ b/probeflow/gui/dialogs/__init__.py
@@ -3,6 +3,8 @@
from __future__ import annotations
from probeflow.gui.dialogs.about import AboutDialog
+from probeflow.gui.dialogs.adstat_results import AdStatPlotWidget, AdStatResultsDialog, AdStatResultView
+from probeflow.gui.dialogs.adstat_workbench import AdStatWorkbenchDialog
from probeflow.gui.dialogs.definitions import (
_DEFINITIONS_HTML,
_ROI_REFERENCE_HTML,
@@ -14,8 +16,10 @@
from probeflow.gui.dialogs.feature_finder import FeatureFinderDialog
from probeflow.gui.dialogs.feature_lattice_dialog import FeatureLatticeDialog
from probeflow.gui.dialogs.fft_viewer import FFTViewerDialog
+from probeflow.gui.dialogs.import_points import ImportPointsDialog
from probeflow.gui.dialogs.pair_correlation import PairCorrelationDialog
from probeflow.gui.dialogs.periodic_filter import PeriodicFilterDialog
+from probeflow.gui.dialogs.particle_statistics import ParticleFieldView, ParticleStatisticsDialog
from probeflow.gui.dialogs.point_fft import PointMaskFFTDialog
from probeflow.gui.dialogs.spec_mapping import SpecMappingDialog, ViewerSpecMappingDialog
from probeflow.gui.dialogs.spec_viewer import SpecOverlayDialog, SpecViewerDialog
@@ -24,6 +28,12 @@
__all__ = [
"AboutDialog",
+ "AdStatPlotWidget",
+ "AdStatResultsDialog",
+ "AdStatResultView",
+ "AdStatWorkbenchDialog",
+ "ParticleFieldView",
+ "ParticleStatisticsDialog",
"EdgeDetectionDialog",
"_DEFINITIONS_HTML",
"_ROI_REFERENCE_HTML",
@@ -33,6 +43,7 @@
"FeatureFinderDialog",
"FeatureLatticeDialog",
"FFTViewerDialog",
+ "ImportPointsDialog",
"PairCorrelationDialog",
"ImageViewerDialog",
"PeriodicFilterDialog",
diff --git a/probeflow/gui/dialogs/adstat_results.py b/probeflow/gui/dialogs/adstat_results.py
new file mode 100644
index 0000000..c0fa0fe
--- /dev/null
+++ b/probeflow/gui/dialogs/adstat_results.py
@@ -0,0 +1,1707 @@
+"""Qt renderer for AdStat result view specifications."""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+import numpy as np
+
+from PySide6.QtCore import QPointF, QRectF, Qt
+from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPainterPath, QPen, QPolygonF
+from PySide6.QtWidgets import (
+ QDialog,
+ QFrame,
+ QLabel,
+ QScrollArea,
+ QTabWidget,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+
+_CURVE_OBSERVED_COLOR = "#ff9f1c"
+_CURVE_MODEL_COLOR = "#7cc7ff"
+_CURVE_BAND_COLOR = "#5ea3ff"
+
+
+class AdStatResultView(QWidget):
+ """Reusable renderer for an AdStat ``ResultViewSpec``."""
+
+ def __init__(
+ self,
+ view_spec: Any,
+ *,
+ source_label: str = "",
+ theme: dict | None = None,
+ data_mode: str = "real",
+ show_banner: bool = True,
+ show_panels: bool = True,
+ parent=None,
+ ):
+ super().__init__(parent)
+ self._spec = view_spec
+ self._source_label = str(source_label or "")
+ self._theme = theme or {}
+ self._data_mode = _normalise_data_mode(data_mode)
+ self._show_banner = bool(show_banner)
+ self._show_panels = bool(show_panels)
+ self._show_technical_details = True
+ self._title: QLabel | None = None
+ self._banner: QLabel | None = None
+ self._tabs = QTabWidget(self)
+ self._build()
+
+ @property
+ def data_mode(self) -> str:
+ """Either ``real`` or ``sandbox``."""
+
+ return self._data_mode
+
+ @property
+ def tab_count(self) -> int:
+ """Number of tabs currently rendered, exposed for GUI tests."""
+
+ return self._tabs.count()
+
+ @property
+ def tab_titles(self) -> tuple[str, ...]:
+ """Rendered tab titles, exposed for contract-style GUI tests."""
+
+ return tuple(self._tabs.tabText(index) for index in range(self._tabs.count()))
+
+ @property
+ def banner_text(self) -> str:
+ """Visible generated-data banner text, exposed for GUI tests."""
+
+ if self._banner is None or not self._show_banner or self._data_mode != "sandbox":
+ return ""
+ return self._banner.text()
+
+ @property
+ def technical_details_visible(self) -> bool:
+ """Whether the diagnostics table tab is currently allowed to render."""
+
+ return bool(self._show_technical_details)
+
+ def set_technical_details_visible(self, visible: bool) -> None:
+ """Show or hide the diagnostics tab without changing the result spec."""
+
+ visible = bool(visible)
+ if self._show_technical_details == visible:
+ return
+ self._show_technical_details = visible
+ self._refresh()
+
+ def set_view_spec(
+ self,
+ view_spec: Any,
+ *,
+ source_label: str | None = None,
+ data_mode: str | None = None,
+ ) -> None:
+ """Replace the rendered spec without rebuilding the owning dialog."""
+
+ self._spec = view_spec
+ if source_label is not None:
+ self._source_label = str(source_label or "")
+ if data_mode is not None:
+ self._data_mode = _normalise_data_mode(data_mode)
+ self._refresh()
+
+ def _build(self) -> None:
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(6)
+
+ self._banner = QLabel("TEST MODE - GENERATED DATA")
+ self._banner.setObjectName("adstatSandboxBanner")
+ self._banner.setAlignment(Qt.AlignCenter)
+ self._banner.setStyleSheet(
+ "background: #f59f00; color: #1f1300; font-weight: 800; "
+ "padding: 6px; border: 1px solid #b36b00;"
+ )
+ layout.addWidget(self._banner)
+
+ self._title = QLabel()
+ self._title.setObjectName("dialogTitle")
+ self._title.setStyleSheet("font-weight: 700;")
+ layout.addWidget(self._title)
+
+ self._tabs.setDocumentMode(True)
+ layout.addWidget(self._tabs, 1)
+ self._refresh()
+
+ def _refresh(self) -> None:
+ if self._banner is not None:
+ self._banner.setVisible(self._show_banner and self._data_mode == "sandbox")
+ if self._title is not None:
+ self._title.setText(self._title_text())
+
+ self._clear_tabs()
+ self._tabs.addTab(self._summary_tab(), "Summary")
+ if self._show_technical_details and tuple(_field(self._spec, "verdict_rows", ()) or ()):
+ self._tabs.addTab(self._technical_details_tab(), "Technical details")
+ # Embedded in the Particle Statistics dialog the per-statistic plots and the
+ # point-pattern panel duplicate the always-visible top panel and left field, so
+ # they are suppressed there (show_panels=False); the standalone results dialog
+ # still renders them.
+ if self._show_panels:
+ for index, panel in enumerate(tuple(_field(self._spec, "panels", ()) or ())):
+ tab_title = _short_tab_title(_field(panel, "title", "") or f"Panel {index + 1}")
+ self._tabs.addTab(self._panel_tab(panel), tab_title)
+
+ def _clear_tabs(self) -> None:
+ while self._tabs.count():
+ widget = self._tabs.widget(0)
+ self._tabs.removeTab(0)
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+
+ def _title_text(self) -> str:
+ if self._data_mode == "sandbox":
+ if self._source_label:
+ return f"Generated examples - Particle Statistics results - {self._source_label}"
+ return "Generated examples - Particle Statistics results"
+ if self._source_label:
+ return f"Particle Statistics results - {self._source_label}"
+ return "Particle Statistics results"
+
+ def _summary_tab(self) -> QWidget:
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(8)
+
+ rows = tuple(_field(self._spec, "verdict_rows", ()) or ())
+ if rows:
+ layout.addWidget(_section_label("Model summary"))
+ layout.addWidget(_model_summary_widget(rows))
+ note = QLabel(
+ "The same model can appear several times because Particle Statistics "
+ "tests one null model with several statistics. Read the group as one "
+ "model assumption checked from multiple angles."
+ )
+ note.setWordWrap(True)
+ note.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ layout.addWidget(note)
+
+ status_lines = tuple(_field(self._spec, "status_lines", ()) or ())
+ if status_lines:
+ layout.addWidget(_section_label("Diagnostics"))
+ status = QLabel("\n".join(str(line) for line in status_lines))
+ status.setWordWrap(True)
+ status.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ layout.addWidget(status)
+
+ explainer = _field(self._spec, "explainer", None)
+ if explainer is not None:
+ layout.addWidget(_section_label("Model"))
+ explanation = QLabel(_explainer_text(explainer))
+ explanation.setWordWrap(True)
+ explanation.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ layout.addWidget(explanation)
+
+ if not rows and not status_lines and explainer is None:
+ layout.addWidget(QLabel("No summary rows available."))
+ layout.addStretch(1)
+ return _scrollable(page)
+
+ def _technical_details_tab(self) -> QWidget:
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(8)
+ rows = tuple(_field(self._spec, "verdict_rows", ()) or ())
+ layout.addWidget(_section_label("Raw AdStat verdict rows"))
+ detail = QLabel("These are the engine-facing model and statistic ids used for reproducibility.")
+ detail.setWordWrap(True)
+ layout.addWidget(detail)
+ if rows:
+ layout.addWidget(_table_widget(_verdict_columns(rows), rows))
+ else:
+ layout.addWidget(QLabel("No raw verdict rows available."))
+ layout.addStretch(1)
+ return _scrollable(page)
+
+ def _panel_tab(self, panel: Any) -> QWidget:
+ kind = str(_field(panel, "kind", ""))
+ if kind == "table" or _field(panel, "table_rows", None):
+ return self._table_panel_tab(panel)
+ return self._plot_panel_tab(panel)
+
+ def _table_panel_tab(self, panel: Any) -> QWidget:
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(8)
+ layout.addWidget(_caption_label(panel))
+ rows = tuple(_field(panel, "table_rows", ()) or ())
+ columns = tuple(_field(panel, "table_columns", ()) or ())
+ if rows:
+ layout.addWidget(_table_widget(columns, rows))
+ else:
+ message = _field(_field(panel, "metadata", {}) or {}, "empty_message", None)
+ layout.addWidget(QLabel(str(message or "No rows available.")))
+ layout.addStretch(1)
+ return _scrollable(page)
+
+ def _plot_panel_tab(self, panel: Any) -> QWidget:
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(6)
+
+ plot = AdStatPlotWidget(
+ panel,
+ theme=self._theme,
+ data_mode=self._data_mode,
+ parent=page,
+ )
+ layout.addWidget(plot, 1)
+ layout.addWidget(_caption_label(panel))
+ return page
+
+
+class AdStatResultsDialog(QDialog):
+ """Render a real-data AdStat ``ResultViewSpec`` in ProbeFlow's Qt shell."""
+
+ def __init__(
+ self,
+ view_spec: Any,
+ *,
+ source_label: str = "",
+ theme: dict | None = None,
+ parent=None,
+ ):
+ super().__init__(parent)
+ self.setWindowTitle("Particle Statistics results")
+ self.resize(980, 680)
+ self.setAttribute(Qt.WA_DeleteOnClose, False)
+ self._view = AdStatResultView(
+ view_spec,
+ source_label=source_label,
+ theme=theme,
+ data_mode="real",
+ parent=self,
+ )
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._view)
+
+ @property
+ def tab_count(self) -> int:
+ """Number of tabs currently rendered, exposed for GUI tests."""
+
+ return self._view.tab_count
+
+ @property
+ def tab_titles(self) -> tuple[str, ...]:
+ """Rendered tab titles, exposed for contract-style GUI tests."""
+
+ return self._view.tab_titles
+
+
+class AdStatPlotWidget(QWidget):
+ """ProbeFlow-native Qt renderer for one AdStat plot panel."""
+
+ def __init__(
+ self,
+ panel: Any,
+ *,
+ theme: dict | None = None,
+ data_mode: str = "real",
+ curve_mode: str = "comparison",
+ show_observed_curve: bool = True,
+ show_model_curves: bool = True,
+ parent=None,
+ ):
+ super().__init__(parent)
+ self._panel = panel
+ self._theme = theme or {}
+ self._data_mode = _normalise_data_mode(data_mode)
+ self._curve_mode = _normalise_curve_mode(curve_mode)
+ self._show_observed_curve = bool(show_observed_curve)
+ self._show_model_curves = bool(show_model_curves)
+ self.setObjectName("adstatResultPlot")
+ self.setMinimumHeight(320)
+ self._cursor_pos: QPointF | None = None
+ self.setMouseTracking(True)
+
+ def mouseMoveEvent(self, event) -> None: # noqa: N802 - Qt override
+ try:
+ self._cursor_pos = event.position()
+ except AttributeError: # pragma: no cover - older Qt
+ self._cursor_pos = QPointF(event.pos())
+ self.update()
+ super().mouseMoveEvent(event)
+
+ def leaveEvent(self, event) -> None: # noqa: N802 - Qt override
+ self._cursor_pos = None
+ self.update()
+ super().leaveEvent(event)
+
+ @property
+ def panel_kind(self) -> str:
+ return str(_field(self._panel, "kind", ""))
+
+ @property
+ def curve_mode(self) -> str:
+ return self._curve_mode
+
+ @property
+ def show_observed_curve(self) -> bool:
+ return self._show_observed_curve
+
+ @property
+ def show_model_curves(self) -> bool:
+ return self._show_model_curves
+
+ def paintEvent(self, event) -> None: # noqa: N802 - Qt override
+ super().paintEvent(event)
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+
+ bg = _theme_qcolor(self._theme, ("figure.facecolor", "card_bg", "surface", "bg"), "#ffffff")
+ fg = _theme_qcolor(self._theme, ("text.color", "fg", "card_fg"), "#111111")
+ border = _theme_qcolor(self._theme, ("border", "sep"), "#d8dbe1")
+ painter.fillRect(self.rect(), bg)
+
+ kind = self.panel_kind
+ plot_rect = self._plot_rect(with_colorbar=kind == "heatmap")
+ if plot_rect.width() < 40 or plot_rect.height() < 40:
+ return
+
+ if kind == "realspace":
+ ok = self._paint_realspace(painter, plot_rect, fg, border)
+ elif kind == "heatmap":
+ ok = self._paint_heatmap(painter, plot_rect, fg, border)
+ elif kind in {"series_metric", "series_curve"}:
+ ok = self._paint_series(painter, plot_rect, fg, border)
+ elif kind == "motif_counts":
+ ok = self._paint_motif_counts(painter, plot_rect, fg, border)
+ else:
+ ok = self._paint_curve(painter, plot_rect, fg, border)
+ if not ok:
+ no_visible_curves = (
+ kind not in {"realspace", "heatmap", "series_metric", "series_curve", "motif_counts"}
+ and not (self._show_observed_curve or self._show_model_curves)
+ )
+ self._paint_empty(
+ painter,
+ plot_rect,
+ fg,
+ border,
+ _empty_panel_message(kind, no_visible_curves=no_visible_curves),
+ )
+
+ def _plot_rect(self, *, with_colorbar: bool = False) -> QRectF:
+ left = 56.0
+ right = 58.0 if with_colorbar else 18.0
+ top = 48.0 # room for the title plus a one-line legend above the data box
+ bottom = 48.0
+ return QRectF(
+ left,
+ top,
+ max(1.0, float(self.width()) - left - right),
+ max(1.0, float(self.height()) - top - bottom),
+ )
+
+ def _paint_curve(self, painter: QPainter, plot_rect: QRectF, fg: QColor, border: QColor) -> bool:
+ observed = _float_array_or_none(_field(self._panel, "observed", None))
+ if observed is None:
+ return False
+ x = _float_array_or_none(_panel_x(self._panel, len(observed)))
+ if x is None or len(x) != len(observed):
+ return False
+ band_low = _float_array_or_none(_field(self._panel, "band_low", None))
+ band_high = _float_array_or_none(_field(self._panel, "band_high", None))
+ central = _float_array_or_none(_field(self._panel, "central", None))
+ ref = _finite_float(_field(self._panel, "reference_line", None))
+ comparison_mode = self._curve_mode == "comparison" and self._show_model_curves
+ has_observed = bool(self._show_observed_curve)
+
+ x_range = _range_for_arrays(x)
+ if str(_field(self._panel, "statistic", "")) == "cluster_size_counts":
+ # Random/most real patterns put all counts at small cluster sizes, leaving a
+ # long empty tail out to N. Zoom x to the populated support so the curve reads.
+ x_range = _populated_x_range(x, (observed, band_high, central), x_range)
+ y_arrays = [observed] if has_observed else []
+ if comparison_mode and band_low is not None and len(band_low) == len(observed):
+ y_arrays.append(band_low)
+ if comparison_mode and band_high is not None and len(band_high) == len(observed):
+ y_arrays.append(band_high)
+ if comparison_mode and central is not None and len(central) == len(observed):
+ y_arrays.append(central)
+ if not y_arrays:
+ return False
+ if ref is not None:
+ y_arrays.append(np.asarray([ref], dtype=float))
+ y_range = _range_for_arrays(*y_arrays)
+ transform = _PlotTransform(plot_rect, x_range, y_range)
+
+ painter.save()
+ painter.setClipRect(plot_rect.adjusted(1, 1, -1, -1))
+ has_band = (
+ comparison_mode
+ and band_low is not None
+ and band_high is not None
+ and len(band_low) == len(x)
+ )
+ has_central = comparison_mode and central is not None and len(central) == len(x)
+ if has_band:
+ _draw_band(
+ painter,
+ transform,
+ x,
+ band_low,
+ band_high,
+ _CURVE_BAND_COLOR,
+ alpha=115,
+ )
+ if has_central:
+ _draw_polyline(painter, transform, x, central, _CURVE_MODEL_COLOR, width=1.7)
+ _draw_reference_line(painter, transform, ref, plot_rect, fg)
+ if has_observed:
+ _draw_polyline(painter, transform, x, observed, _CURVE_OBSERVED_COLOR, width=2.5)
+ legend = _curve_legend_entries(
+ has_band=has_band,
+ has_central=has_central,
+ has_observed=has_observed,
+ )
+ painter.restore()
+
+ self._draw_chrome(painter, plot_rect, x_range, y_range, fg, border, y_anchor=ref)
+ _draw_legend(painter, plot_rect, legend, fg, self._theme)
+ if has_observed:
+ self._draw_cursor_readout(painter, plot_rect, transform, x, observed, fg, border)
+ return True
+
+ def _draw_cursor_readout(
+ self,
+ painter: QPainter,
+ plot_rect: QRectF,
+ transform: "_PlotTransform",
+ x: np.ndarray,
+ observed: np.ndarray,
+ fg: QColor,
+ border: QColor,
+ ) -> None:
+ cursor = self._cursor_pos
+ if cursor is None or not plot_rect.contains(cursor):
+ return
+ if x is None or len(x) == 0:
+ return
+ # Map the cursor x back to data space and snap to the nearest sample.
+ x_min, x_max = transform.x_min, transform.x_max
+ denom = max(x_max - x_min, 1e-12)
+ x_data = x_min + ((cursor.x() - plot_rect.left()) / max(plot_rect.width(), 1.0)) * denom
+ idx = int(np.argmin(np.abs(np.asarray(x, dtype=float) - x_data)))
+ x_val = float(x[idx])
+ y_val = float(observed[idx])
+ point = transform.point(x_val, y_val)
+ if point is None:
+ return
+ painter.save()
+ guide = QColor(fg.red(), fg.green(), fg.blue(), 90)
+ painter.setPen(QPen(guide, 1.0, Qt.DashLine))
+ painter.drawLine(QPointF(point.x(), plot_rect.top()), QPointF(point.x(), plot_rect.bottom()))
+ painter.setPen(QPen(QColor(_CURVE_OBSERVED_COLOR), 1.0))
+ painter.setBrush(QColor(_CURVE_OBSERVED_COLOR))
+ painter.drawEllipse(point, 3.0, 3.0)
+
+ x_label = str(_field(self._panel, "x_label", "x"))
+ y_label = str(_field(self._panel, "y_label", "y"))
+ text = f"{_axis_symbol(x_label)}={_format_tick(x_val)} {_axis_symbol(y_label)}={_format_tick(y_val)}"
+ font = QFont(painter.font())
+ font.setPointSizeF(8.5)
+ painter.setFont(font)
+ metrics = painter.fontMetrics()
+ pad = 5.0
+ box_w = metrics.horizontalAdvance(text) + 2 * pad
+ box_h = metrics.height() + 2 * pad
+ box_x = min(point.x() + 8.0, plot_rect.right() - box_w)
+ box_x = max(box_x, plot_rect.left())
+ box_y = plot_rect.top() + 4.0
+ box = QRectF(box_x, box_y, box_w, box_h)
+ bg = _theme_qcolor(self._theme, ("card_bg", "surface", "bg"), "#ffffff")
+ bg.setAlpha(230)
+ painter.setPen(QPen(QColor(fg.red(), fg.green(), fg.blue(), 90), 1.0))
+ painter.setBrush(bg)
+ painter.drawRect(box)
+ painter.setPen(QPen(fg))
+ painter.drawText(box, Qt.AlignCenter, text)
+ painter.restore()
+
+ def _paint_realspace(self, painter: QPainter, plot_rect: QRectF, fg: QColor, border: QColor) -> bool:
+ points = _xy_array_or_none(_field(self._panel, "observed", None))
+ if points is None or points.size == 0:
+ return False
+ metadata = _field(self._panel, "metadata", {}) or {}
+ simulated = None
+ feature_xy = None
+ arrays = [points]
+ if self._data_mode == "sandbox":
+ simulated = _particle_xy_or_none(_field(metadata, "simulated", None))
+ feature_xy = _xy_array_or_none(_field(metadata, "feature_xy_nm", None))
+ if simulated is not None and simulated.size:
+ arrays.append(simulated)
+ if feature_xy is not None and feature_xy.size:
+ arrays.append(feature_xy)
+
+ x_range, y_range = _xy_ranges(arrays)
+ x_range, y_range = _equal_aspect_ranges(plot_rect, x_range, y_range)
+ transform = _PlotTransform(plot_rect, x_range, y_range, invert_y=True)
+
+ legend: list[tuple[str, str, str, bool]] = []
+ painter.save()
+ painter.setClipRect(plot_rect.adjusted(1, 1, -1, -1))
+ if self._data_mode == "sandbox" and simulated is not None and simulated.size:
+ _draw_marker_series(
+ painter,
+ transform,
+ simulated,
+ marker="o",
+ color="#6b7280",
+ edgecolor="#6b7280",
+ radius=4.0,
+ hollow=True,
+ )
+ legend.append(("model sample", "#6b7280", "o", True))
+ if self._data_mode == "sandbox" and feature_xy is not None and feature_xy.size:
+ _draw_marker_series(
+ painter,
+ transform,
+ feature_xy,
+ marker="x",
+ color="#7b2cbf",
+ edgecolor="#7b2cbf",
+ radius=5.0,
+ )
+ legend.append(("synthetic feature layer", "#7b2cbf", "x", False))
+
+ style = _realspace_marker_style(self._data_mode)
+ _draw_marker_series(
+ painter,
+ transform,
+ points,
+ marker=str(style["marker"]),
+ color=str(style["color"]),
+ edgecolor=str(style["edgecolor"]),
+ radius=max(3.5, float(style["size"]) ** 0.5 * 0.72),
+ )
+ legend.append((str(style["label"]), str(style["color"]), str(style["marker"]), False))
+ painter.restore()
+
+ self._draw_chrome(painter, plot_rect, x_range, y_range, fg, border, invert_y=True)
+ _draw_legend(painter, plot_rect, legend, fg, self._theme)
+ return True
+
+ def _paint_heatmap(self, painter: QPainter, plot_rect: QRectF, fg: QColor, border: QColor) -> bool:
+ observed = _float_array_or_none(_field(self._panel, "observed", None))
+ if observed is None or observed.ndim != 2:
+ return False
+ heatmap = _heatmap_image(observed)
+ if heatmap is None:
+ return False
+ painter.drawImage(plot_rect, heatmap)
+ x_range = (0.0, float(observed.shape[1]))
+ y_range = (0.0, float(observed.shape[0]))
+ self._draw_chrome(painter, plot_rect, x_range, y_range, fg, border)
+ self._draw_colorbar(painter, plot_rect, observed, fg, border)
+ return True
+
+ def _paint_series(self, painter: QPainter, plot_rect: QRectF, fg: QColor, border: QColor) -> bool:
+ curves = _series_curves(self._panel)
+ reference_curves = _series_reference_curves(self._panel)
+ if not curves:
+ return False
+ x_range = _range_for_arrays(
+ *(curve["x"] for curve in curves),
+ *(curve["x"] for curve in reference_curves),
+ )
+ y_values: list[np.ndarray] = []
+ for curve in curves:
+ y_values.append(curve["mean"])
+ if curve["low"] is not None:
+ y_values.append(curve["low"])
+ if curve["high"] is not None:
+ y_values.append(curve["high"])
+ for curve in reference_curves:
+ y_values.append(curve["y"])
+ y_range = _range_for_arrays(*y_values)
+ transform = _PlotTransform(plot_rect, x_range, y_range)
+ palette = ("#2f7ed8", "#7b2cbf", "#178a5a", "#c75d00", "#5e7fb7")
+ legend: list[tuple[str, str, str, bool]] = []
+
+ painter.save()
+ painter.setClipRect(plot_rect.adjusted(1, 1, -1, -1))
+ has_band = False
+ for index, curve in enumerate(curves):
+ color = palette[index % len(palette)]
+ if curve["low"] is not None and curve["high"] is not None:
+ _draw_band(painter, transform, curve["x"], curve["low"], curve["high"], color, alpha=45)
+ has_band = True
+ _draw_polyline(painter, transform, curve["x"], curve["mean"], color, width=1.5)
+ _draw_line_markers(painter, transform, curve["x"], curve["mean"], color)
+ legend.append((_series_curve_label(curve["label"]), color, "line", False))
+ # A single pooled group (one curve) reads better with the band named.
+ if has_band and len(curves) == 1:
+ legend.append(("image-to-image spread", palette[0], "bar", False))
+ for curve in reference_curves:
+ color = str(curve["color"])
+ _draw_polyline(painter, transform, curve["x"], curve["y"], color, width=1.8)
+ legend.append((str(curve["label"]), color, "line", False))
+ painter.restore()
+
+ self._draw_chrome(painter, plot_rect, x_range, y_range, fg, border)
+ _draw_legend(painter, plot_rect, legend, fg, self._theme)
+ return True
+
+ def _paint_motif_counts(self, painter: QPainter, plot_rect: QRectF, fg: QColor, border: QColor) -> bool:
+ observed = _float_array_or_none(_field(self._panel, "observed", None))
+ if observed is None:
+ return False
+ x = np.arange(len(observed), dtype=float)
+ low = _float_array_or_none(_field(self._panel, "band_low", None))
+ high = _float_array_or_none(_field(self._panel, "band_high", None))
+ y_values = [observed, np.asarray([0.0], dtype=float)]
+ if low is not None and len(low) == len(observed):
+ y_values.append(low)
+ if high is not None and len(high) == len(observed):
+ y_values.append(high)
+ x_range = (-0.5, float(len(observed)) - 0.5)
+ y_range = _range_for_arrays(*y_values)
+ transform = _PlotTransform(plot_rect, x_range, y_range)
+
+ painter.save()
+ painter.setClipRect(plot_rect.adjusted(1, 1, -1, -1))
+ _draw_bars(painter, transform, x, observed, "#2f7ed8")
+ if low is not None and high is not None and len(low) == len(observed):
+ _draw_error_bars(painter, transform, x, low, high, "#333333")
+ painter.restore()
+
+ labels = _motif_labels(self._panel, len(observed))
+ self._draw_chrome(painter, plot_rect, x_range, y_range, fg, border, x_tick_labels=labels)
+ _draw_legend(painter, plot_rect, [("observed", "#2f7ed8", "bar", False)], fg, self._theme)
+ return True
+
+ def _paint_empty(
+ self,
+ painter: QPainter,
+ plot_rect: QRectF,
+ fg: QColor,
+ border: QColor,
+ message: str,
+ ) -> None:
+ self._draw_chrome(painter, plot_rect, (0.0, 1.0), (0.0, 1.0), fg, border)
+ font = QFont(painter.font())
+ font.setPointSizeF(10.0)
+ painter.setFont(font)
+ painter.setPen(QPen(fg))
+ painter.drawText(plot_rect, Qt.AlignCenter, message)
+
+ def _draw_chrome(
+ self,
+ painter: QPainter,
+ plot_rect: QRectF,
+ x_range: tuple[float, float],
+ y_range: tuple[float, float],
+ fg: QColor,
+ border: QColor,
+ *,
+ invert_y: bool = False,
+ x_tick_labels: tuple[str, ...] | None = None,
+ y_anchor: float | None = None,
+ ) -> None:
+ title = _plot_title(self._panel)
+ x_label = str(_field(self._panel, "x_label", ""))
+ y_label = str(_field(self._panel, "y_label", ""))
+ grid = QColor(border)
+ grid.setAlpha(95)
+
+ x_ticks = _ticks(*x_range)
+ y_ticks = _ticks(*y_range, anchor=y_anchor)
+
+ painter.setPen(QPen(border, 1.0))
+ painter.drawRect(plot_rect)
+ painter.setPen(QPen(grid, 1.0))
+ for tick in x_ticks:
+ x = _axis_x(plot_rect, tick, x_range)
+ painter.drawLine(QPointF(x, plot_rect.top()), QPointF(x, plot_rect.bottom()))
+ for tick in y_ticks:
+ y = _axis_y(plot_rect, tick, y_range, invert_y=invert_y)
+ painter.drawLine(QPointF(plot_rect.left(), y), QPointF(plot_rect.right(), y))
+
+ painter.setPen(QPen(fg, 1.0))
+ painter.drawRect(plot_rect)
+ tick_font = QFont(painter.font())
+ tick_font.setPointSizeF(8.5)
+ painter.setFont(tick_font)
+ for tick in x_ticks:
+ x = _axis_x(plot_rect, tick, x_range)
+ painter.drawLine(QPointF(x, plot_rect.bottom()), QPointF(x, plot_rect.bottom() + 4.0))
+ for tick in y_ticks:
+ y = _axis_y(plot_rect, tick, y_range, invert_y=invert_y)
+ painter.drawLine(QPointF(plot_rect.left() - 4.0, y), QPointF(plot_rect.left(), y))
+ painter.drawText(QRectF(2.0, y - 8.0, plot_rect.left() - 8.0, 16.0), Qt.AlignRight | Qt.AlignVCenter, _format_tick(tick))
+
+ if x_tick_labels:
+ for index, label in enumerate(x_tick_labels):
+ if index >= 10:
+ break
+ x = _axis_x(plot_rect, float(index), x_range)
+ painter.drawText(QRectF(x - 30.0, plot_rect.bottom() + 6.0, 60.0, 16.0), Qt.AlignCenter, label)
+ else:
+ for tick in x_ticks:
+ x = _axis_x(plot_rect, tick, x_range)
+ painter.drawText(QRectF(x - 24.0, plot_rect.bottom() + 6.0, 48.0, 16.0), Qt.AlignCenter, _format_tick(tick))
+
+ title_font = QFont(painter.font())
+ title_font.setPointSizeF(11.0)
+ title_font.setBold(True)
+ painter.setFont(title_font)
+ painter.drawText(QRectF(0.0, 4.0, float(self.width()), 20.0), Qt.AlignCenter, title)
+
+ label_font = QFont(painter.font())
+ label_font.setPointSizeF(10.0)
+ label_font.setBold(False)
+ painter.setFont(label_font)
+ painter.drawText(
+ QRectF(plot_rect.left(), float(self.height()) - 25.0, plot_rect.width(), 18.0),
+ Qt.AlignCenter,
+ x_label,
+ )
+ if y_label:
+ painter.save()
+ painter.translate(13.0, plot_rect.center().y())
+ painter.rotate(-90.0)
+ painter.drawText(
+ QRectF(-plot_rect.height() / 2.0, -9.0, plot_rect.height(), 18.0),
+ Qt.AlignCenter,
+ y_label,
+ )
+ painter.restore()
+
+ def _draw_colorbar(self, painter: QPainter, plot_rect: QRectF, values: np.ndarray, fg: QColor, border: QColor) -> None:
+ finite = values[np.isfinite(values)]
+ if finite.size == 0:
+ return
+ bar = QRectF(plot_rect.right() + 14.0, plot_rect.top(), 12.0, plot_rect.height())
+ steps = max(1, int(bar.height()))
+ for row in range(steps):
+ t = 1.0 - (row / max(1, steps - 1))
+ painter.setPen(QPen(_heatmap_color(t)))
+ y = bar.top() + row
+ painter.drawLine(QPointF(bar.left(), y), QPointF(bar.right(), y))
+ painter.setPen(QPen(border, 1.0))
+ painter.drawRect(bar)
+ painter.setPen(QPen(fg))
+ font = QFont(painter.font())
+ font.setPointSizeF(8.5)
+ painter.setFont(font)
+ painter.drawText(QRectF(bar.right() + 4.0, bar.top() - 2.0, 34.0, 14.0), Qt.AlignLeft | Qt.AlignVCenter, _format_tick(float(np.max(finite))))
+ painter.drawText(QRectF(bar.right() + 4.0, bar.bottom() - 12.0, 34.0, 14.0), Qt.AlignLeft | Qt.AlignVCenter, _format_tick(float(np.min(finite))))
+
+
+class _PlotTransform:
+ def __init__(
+ self,
+ rect: QRectF,
+ x_range: tuple[float, float],
+ y_range: tuple[float, float],
+ *,
+ invert_y: bool = False,
+ ):
+ self.rect = rect
+ self.x_min, self.x_max = x_range
+ self.y_min, self.y_max = y_range
+ self.invert_y = invert_y
+
+ def point(self, x_value: float, y_value: float) -> QPointF | None:
+ if not np.isfinite(x_value) or not np.isfinite(y_value):
+ return None
+ x = _axis_x(self.rect, float(x_value), (self.x_min, self.x_max))
+ y = _axis_y(self.rect, float(y_value), (self.y_min, self.y_max), invert_y=self.invert_y)
+ return QPointF(x, y)
+
+
+def _theme_qcolor(theme: dict, keys: tuple[str, ...], fallback: str) -> QColor:
+ for key in keys:
+ value = theme.get(key)
+ if value:
+ color = QColor(str(value))
+ if color.isValid():
+ return color
+ return QColor(fallback)
+
+
+def _float_array_or_none(value: Any) -> np.ndarray | None:
+ arr = _array_or_none(value)
+ if arr is None:
+ return None
+ try:
+ return np.asarray(arr, dtype=float)
+ except (TypeError, ValueError):
+ return None
+
+
+def _range_for_arrays(*arrays: np.ndarray) -> tuple[float, float]:
+ finite: list[np.ndarray] = []
+ for array in arrays:
+ arr = np.asarray(array, dtype=float)
+ values = arr[np.isfinite(arr)]
+ if values.size:
+ finite.append(values)
+ if not finite:
+ return (0.0, 1.0)
+ values = np.concatenate(finite)
+ return _padded_range(float(np.min(values)), float(np.max(values)))
+
+
+def _populated_x_range(
+ x: np.ndarray,
+ y_arrays: tuple[np.ndarray | None, ...],
+ fallback: tuple[float, float],
+) -> tuple[float, float]:
+ """Trim an x-range to where the data is actually non-zero.
+
+ Count distributions (e.g. cluster sizes) are non-zero only at small x and then
+ flat-zero out to N, which wastes the axis. Return ``[min(x), last_nonzero_x]`` with
+ a one-sample margin; fall back to the full range if nothing is populated.
+ """
+ xs = np.asarray(x, dtype=float)
+ if xs.size == 0:
+ return fallback
+ mask = np.zeros(xs.shape, dtype=bool)
+ for arr in y_arrays:
+ if arr is None:
+ continue
+ values = np.asarray(arr, dtype=float)
+ if values.shape != xs.shape:
+ continue
+ mask |= np.isfinite(values) & (np.abs(values) > 1e-9)
+ if not mask.any():
+ return fallback
+ last = int(np.flatnonzero(mask)[-1])
+ upper_index = min(last + 1, xs.size - 1)
+ x_min = float(np.min(xs))
+ x_max = float(xs[upper_index])
+ if x_max <= x_min:
+ return fallback
+ return _padded_range(x_min, x_max)
+
+
+def _padded_range(vmin: float, vmax: float, fraction: float = 0.06) -> tuple[float, float]:
+ if not np.isfinite(vmin) or not np.isfinite(vmax):
+ return (0.0, 1.0)
+ if vmin == vmax:
+ pad = max(abs(vmin) * 0.08, 0.5)
+ else:
+ pad = abs(vmax - vmin) * fraction
+ return (vmin - pad, vmax + pad)
+
+
+def _xy_ranges(arrays: list[np.ndarray]) -> tuple[tuple[float, float], tuple[float, float]]:
+ xs: list[np.ndarray] = []
+ ys: list[np.ndarray] = []
+ for array in arrays:
+ arr = _xy_array_or_none(array)
+ if arr is None:
+ continue
+ xs.append(arr[:, 0])
+ ys.append(arr[:, 1])
+ return _range_for_arrays(*xs), _range_for_arrays(*ys)
+
+
+def _equal_aspect_ranges(
+ rect: QRectF,
+ x_range: tuple[float, float],
+ y_range: tuple[float, float],
+) -> tuple[tuple[float, float], tuple[float, float]]:
+ x_min, x_max = x_range
+ y_min, y_max = y_range
+ x_span = max(x_max - x_min, 1e-12)
+ y_span = max(y_max - y_min, 1e-12)
+ rect_ratio = max(rect.width(), 1.0) / max(rect.height(), 1.0)
+ data_ratio = x_span / y_span
+ if data_ratio > rect_ratio:
+ target_y = x_span / rect_ratio
+ centre = (y_min + y_max) / 2.0
+ y_min = centre - target_y / 2.0
+ y_max = centre + target_y / 2.0
+ else:
+ target_x = y_span * rect_ratio
+ centre = (x_min + x_max) / 2.0
+ x_min = centre - target_x / 2.0
+ x_max = centre + target_x / 2.0
+ return (x_min, x_max), (y_min, y_max)
+
+
+def _axis_x(rect: QRectF, value: float, x_range: tuple[float, float]) -> float:
+ x_min, x_max = x_range
+ denom = max(x_max - x_min, 1e-12)
+ return rect.left() + ((value - x_min) / denom) * rect.width()
+
+
+def _axis_y(
+ rect: QRectF,
+ value: float,
+ y_range: tuple[float, float],
+ *,
+ invert_y: bool = False,
+) -> float:
+ y_min, y_max = y_range
+ denom = max(y_max - y_min, 1e-12)
+ frac = (value - y_min) / denom
+ if invert_y:
+ return rect.top() + frac * rect.height()
+ return rect.bottom() - frac * rect.height()
+
+
+def _nice_step(raw: float) -> float:
+ """Round a raw step up to the nearest 1 / 2 / 2.5 / 5 x 10**k."""
+ if not np.isfinite(raw) or raw <= 0.0:
+ return 1.0
+ magnitude = 10.0 ** math.floor(math.log10(raw))
+ for multiple in (1.0, 2.0, 2.5, 5.0):
+ if raw <= multiple * magnitude:
+ return multiple * magnitude
+ return 10.0 * magnitude
+
+
+def _ticks(
+ vmin: float,
+ vmax: float,
+ *,
+ anchor: float | None = None,
+ target: int = 5,
+) -> tuple[float, ...]:
+ """Evenly spaced "nice" axis ticks.
+
+ Returns ticks at regular 1/2/2.5/5 x 10**k intervals spanning ``[vmin, vmax]``
+ so labels read cleanly. When ``anchor`` is given (e.g. the g=1 reference line),
+ the tick lattice is shifted to land exactly on it, so the reference is a gridline.
+ """
+ if not np.isfinite(vmin) or not np.isfinite(vmax):
+ return (0.0, 0.5, 1.0)
+ if vmin == vmax:
+ return (vmin - 0.5, vmin, vmin + 0.5)
+ step = _nice_step((vmax - vmin) / max(1, target))
+ if anchor is not None and np.isfinite(anchor):
+ first = anchor + math.ceil((vmin - anchor) / step - 1e-9) * step
+ else:
+ first = math.ceil(vmin / step - 1e-9) * step
+ ticks: list[float] = []
+ tick = first
+ while tick <= vmax + step * 1e-6 and len(ticks) < 40:
+ # Snap away tiny floating-point noise around zero.
+ ticks.append(0.0 if abs(tick) < step * 1e-9 else tick)
+ tick += step
+ if not ticks:
+ return (vmin, (vmin + vmax) / 2.0, vmax)
+ return tuple(ticks)
+
+
+def _axis_symbol(label: str) -> str:
+ """Compact axis symbol for the cursor read-out (e.g. 'distance r (nm)' -> 'r')."""
+ s = (label or "").strip()
+ if not s:
+ return ""
+ if s.endswith(")") and "(" in s:
+ head = s[: s.rfind("(")].strip()
+ unit = s[s.rfind("(") :].lower()
+ if head and any(token in unit for token in ("nm", "µm", "um", "deg", "%", "px")):
+ s = head
+ parts = s.split()
+ return parts[-1] if parts else s
+
+
+def _format_tick(value: float) -> str:
+ if not np.isfinite(value):
+ return ""
+ if abs(value) >= 1000.0 or (0.0 < abs(value) < 0.01):
+ return f"{value:.2g}"
+ return f"{value:.3g}"
+
+
+def _draw_polyline(
+ painter: QPainter,
+ transform: _PlotTransform,
+ x_values: np.ndarray,
+ y_values: np.ndarray,
+ color: str,
+ *,
+ width: float = 1.4,
+) -> None:
+ points = _mapped_points(transform, x_values, y_values)
+ if len(points) < 2:
+ return
+ painter.setPen(QPen(QColor(color), width))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawPolyline(QPolygonF(points))
+
+
+def _mapped_points(transform: _PlotTransform, x_values: np.ndarray, y_values: np.ndarray) -> list[QPointF]:
+ points: list[QPointF] = []
+ for x_value, y_value in zip(x_values, y_values, strict=False):
+ point = transform.point(float(x_value), float(y_value))
+ if point is not None:
+ points.append(point)
+ return points
+
+
+def _draw_band(
+ painter: QPainter,
+ transform: _PlotTransform,
+ x_values: np.ndarray,
+ low: np.ndarray,
+ high: np.ndarray,
+ color: str,
+ *,
+ alpha: int = 95,
+) -> None:
+ valid = np.isfinite(x_values) & np.isfinite(low) & np.isfinite(high)
+ if int(np.count_nonzero(valid)) < 2:
+ return
+ x = x_values[valid]
+ lo = low[valid]
+ hi = high[valid]
+ upper = _mapped_points(transform, x, hi)
+ lower = _mapped_points(transform, x[::-1], lo[::-1])
+ if len(upper) < 2 or len(lower) < 2:
+ return
+ path = QPainterPath(upper[0])
+ for point in upper[1:]:
+ path.lineTo(point)
+ for point in lower:
+ path.lineTo(point)
+ path.closeSubpath()
+ fill = QColor(color)
+ fill.setAlpha(alpha)
+ painter.setPen(Qt.NoPen)
+ painter.setBrush(fill)
+ painter.drawPath(path)
+ painter.setBrush(Qt.NoBrush)
+
+
+def _draw_reference_line(
+ painter: QPainter,
+ transform: _PlotTransform,
+ value: float | None,
+ plot_rect: QRectF,
+ color: QColor,
+) -> None:
+ if value is None:
+ return
+ y = transform.point(transform.x_min, value)
+ if y is None:
+ return
+ pen = QPen(color, 1.0)
+ pen.setStyle(Qt.DashLine)
+ pen.setColor(QColor(color.red(), color.green(), color.blue(), 130))
+ painter.setPen(pen)
+ painter.drawLine(QPointF(plot_rect.left(), y.y()), QPointF(plot_rect.right(), y.y()))
+
+
+def _draw_marker_series(
+ painter: QPainter,
+ transform: _PlotTransform,
+ xy: np.ndarray,
+ *,
+ marker: str,
+ color: str,
+ edgecolor: str,
+ radius: float,
+ hollow: bool = False,
+) -> None:
+ for point in np.asarray(xy, dtype=float):
+ mapped = transform.point(float(point[0]), float(point[1]))
+ if mapped is not None:
+ _draw_marker(
+ painter,
+ mapped,
+ marker=marker,
+ color=color,
+ edgecolor=edgecolor,
+ radius=radius,
+ hollow=hollow,
+ )
+
+
+def _draw_marker(
+ painter: QPainter,
+ point: QPointF,
+ *,
+ marker: str,
+ color: str,
+ edgecolor: str,
+ radius: float,
+ hollow: bool = False,
+) -> None:
+ fill = QColor(color)
+ edge = QColor(edgecolor)
+ painter.setPen(QPen(edge, 1.1))
+ painter.setBrush(Qt.NoBrush if hollow or marker == "x" else fill)
+ if marker == "^":
+ poly = QPolygonF(
+ [
+ QPointF(point.x(), point.y() - radius),
+ QPointF(point.x() - radius, point.y() + radius),
+ QPointF(point.x() + radius, point.y() + radius),
+ ]
+ )
+ painter.drawPolygon(poly)
+ elif marker == "x":
+ painter.setPen(QPen(fill, 1.5))
+ painter.drawLine(
+ QPointF(point.x() - radius, point.y() - radius),
+ QPointF(point.x() + radius, point.y() + radius),
+ )
+ painter.drawLine(
+ QPointF(point.x() - radius, point.y() + radius),
+ QPointF(point.x() + radius, point.y() - radius),
+ )
+ else:
+ painter.drawEllipse(point, radius, radius)
+
+
+def _draw_line_markers(
+ painter: QPainter,
+ transform: _PlotTransform,
+ x_values: np.ndarray,
+ y_values: np.ndarray,
+ color: str,
+) -> None:
+ for point in _mapped_points(transform, x_values, y_values):
+ _draw_marker(
+ painter,
+ point,
+ marker="o",
+ color=color,
+ edgecolor=color,
+ radius=2.4,
+ )
+
+
+def _draw_bars(
+ painter: QPainter,
+ transform: _PlotTransform,
+ x_values: np.ndarray,
+ values: np.ndarray,
+ color: str,
+) -> None:
+ painter.setPen(QPen(QColor(color).darker(115), 1.0))
+ painter.setBrush(QColor(color))
+ for x_value, value in zip(x_values, values, strict=False):
+ left = transform.point(float(x_value) - 0.36, 0.0)
+ right = transform.point(float(x_value) + 0.36, float(value))
+ baseline = transform.point(float(x_value), 0.0)
+ if left is None or right is None or baseline is None:
+ continue
+ rect = QRectF(left.x(), right.y(), right.x() - left.x(), baseline.y() - right.y()).normalized()
+ painter.drawRect(rect)
+ painter.setBrush(Qt.NoBrush)
+
+
+def _draw_error_bars(
+ painter: QPainter,
+ transform: _PlotTransform,
+ x_values: np.ndarray,
+ low: np.ndarray,
+ high: np.ndarray,
+ color: str,
+) -> None:
+ painter.setPen(QPen(QColor(color), 1.0))
+ for x_value, lo, hi in zip(x_values, low, high, strict=False):
+ low_pt = transform.point(float(x_value), float(lo))
+ high_pt = transform.point(float(x_value), float(hi))
+ if low_pt is None or high_pt is None:
+ continue
+ painter.drawLine(low_pt, high_pt)
+ painter.drawLine(QPointF(low_pt.x() - 3.0, low_pt.y()), QPointF(low_pt.x() + 3.0, low_pt.y()))
+ painter.drawLine(QPointF(high_pt.x() - 3.0, high_pt.y()), QPointF(high_pt.x() + 3.0, high_pt.y()))
+
+
+def _draw_legend(
+ painter: QPainter,
+ plot_rect: QRectF,
+ entries: list[tuple[str, str, str, bool]],
+ fg: QColor,
+ theme: dict,
+) -> None:
+ if not entries:
+ return
+ font = QFont(painter.font())
+ font.setPointSizeF(8.5)
+ painter.setFont(font)
+ metrics = painter.fontMetrics()
+ # Lay the legend out as a single horizontal strip in the top margin, above the
+ # data box, so it never overlaps the plotted curves.
+ gap = 16.0
+ sample_w = 18.0
+ items = [(label, color, marker, hollow, metrics.horizontalAdvance(label)) for label, color, marker, hollow in entries]
+ total = sum(sample_w + tw for *_unused, tw in items) + gap * (len(items) - 1)
+ y = plot_rect.top() - 12.0
+ x = max(plot_rect.left(), plot_rect.right() - total)
+ for label, color, marker, hollow, text_w in items:
+ sample = QPointF(x + 7.0, y)
+ if marker == "line":
+ painter.setPen(QPen(QColor(color), 1.5))
+ painter.drawLine(QPointF(sample.x() - 6.0, sample.y()), QPointF(sample.x() + 6.0, sample.y()))
+ elif marker == "bar":
+ painter.setPen(QPen(QColor(color).darker(115), 1.0))
+ painter.setBrush(QColor(color))
+ painter.drawRect(QRectF(sample.x() - 5.0, sample.y() - 5.0, 10.0, 10.0))
+ painter.setBrush(Qt.NoBrush)
+ else:
+ _draw_marker(
+ painter,
+ sample,
+ marker=marker,
+ color=color,
+ edgecolor=color,
+ radius=4.0,
+ hollow=hollow,
+ )
+ painter.setPen(QPen(fg))
+ painter.drawText(QRectF(x + sample_w, y - 8.0, text_w + 4.0, 16.0), Qt.AlignLeft | Qt.AlignVCenter, label)
+ x += sample_w + text_w + gap
+
+
+def _series_curve_label(raw: Any) -> str:
+ """Human label for a pooled series curve.
+
+ A single pooled group carries a coverage label like "0 " (value 0, unitless);
+ show "pooled mean" instead so the legend is meaningful.
+ """
+ text = str(raw or "").strip()
+ if text in ("", "0") or text.rstrip("0").rstrip(".") in ("", "0"):
+ return "pooled mean"
+ return text
+
+
+def _series_curves(panel: Any) -> list[dict[str, Any]]:
+ curves: list[dict[str, Any]] = []
+ for curve in tuple(_field(panel, "series_curves", ()) or ()):
+ x = _float_array_or_none(_field(curve, "x", None))
+ mean = _float_array_or_none(_field(curve, "mean", None))
+ if x is None or mean is None or len(x) != len(mean):
+ continue
+ low = _float_array_or_none(_field(curve, "band_low", None))
+ high = _float_array_or_none(_field(curve, "band_high", None))
+ if low is not None and len(low) != len(x):
+ low = None
+ if high is not None and len(high) != len(x):
+ high = None
+ curves.append(
+ {
+ "x": x,
+ "mean": mean,
+ "low": low,
+ "high": high,
+ "label": str(_field(curve, "label", "series")),
+ }
+ )
+ return curves
+
+
+def _series_reference_curves(panel: Any) -> list[dict[str, Any]]:
+ metadata = _field(panel, "metadata", {}) or {}
+ raw_curves = tuple(_field(metadata, "reference_curves", ()) or ())
+ curves: list[dict[str, Any]] = []
+ for curve in raw_curves:
+ x = _float_array_or_none(_field(curve, "x", None))
+ y = _float_array_or_none(_field(curve, "y", None))
+ if x is None or y is None or len(x) != len(y):
+ continue
+ curves.append(
+ {
+ "x": x,
+ "y": y,
+ "label": str(_field(curve, "label", "single image reference")),
+ "color": str(_field(curve, "color", _CURVE_OBSERVED_COLOR)),
+ }
+ )
+ return curves
+
+
+def _motif_labels(panel: Any, count: int) -> tuple[str, ...] | None:
+ motifs = _field(_field(panel, "coordinate_values", {}) or {}, "motif", None)
+ if motifs is None:
+ return None
+ labels = tuple(str(item) for item in np.asarray(motifs, dtype=object).tolist())
+ return labels[:count]
+
+
+def _heatmap_image(values: np.ndarray) -> QImage | None:
+ arr = np.asarray(values, dtype=float)
+ if arr.ndim != 2 or arr.size == 0:
+ return None
+ finite = arr[np.isfinite(arr)]
+ if finite.size == 0:
+ return None
+ vmin = float(np.min(finite))
+ vmax = float(np.max(finite))
+ denom = max(vmax - vmin, 1e-12)
+ rows, cols = arr.shape
+ image = QImage(cols, rows, QImage.Format_RGB32)
+ for row in range(rows):
+ source_row = rows - row - 1
+ for col in range(cols):
+ value = arr[source_row, col]
+ t = 0.0 if not np.isfinite(value) else (float(value) - vmin) / denom
+ image.setPixelColor(col, row, _heatmap_color(float(np.clip(t, 0.0, 1.0))))
+ return image
+
+
+def _heatmap_color(t: float) -> QColor:
+ stops = (
+ (0.0, QColor("#30295f")),
+ (0.35, QColor("#20639b")),
+ (0.70, QColor("#29a36a")),
+ (1.0, QColor("#f7d13d")),
+ )
+ t = float(np.clip(t, 0.0, 1.0))
+ for (left_t, left), (right_t, right) in zip(stops, stops[1:], strict=False):
+ if left_t <= t <= right_t:
+ frac = (t - left_t) / max(right_t - left_t, 1e-12)
+ return QColor(
+ round(left.red() + (right.red() - left.red()) * frac),
+ round(left.green() + (right.green() - left.green()) * frac),
+ round(left.blue() + (right.blue() - left.blue()) * frac),
+ )
+ return QColor(stops[-1][1])
+
+
+def _empty_panel_message(kind: str, *, no_visible_curves: bool = False) -> str:
+ if no_visible_curves:
+ return "No selected plot layers visible"
+ if kind == "realspace":
+ return "No points"
+ if kind == "heatmap":
+ return "No heatmap data"
+ if kind in {"series_metric", "series_curve"}:
+ return "No series data"
+ if kind == "motif_counts":
+ return "No motif data"
+ return "No curve data"
+
+
+def _panel_x(panel: Any, length: int) -> np.ndarray:
+ x = _array_or_none(_field(panel, "x", None))
+ if x is not None and len(x) == length:
+ return x
+ for value in (_field(panel, "coordinate_values", {}) or {}).values():
+ arr = _array_or_none(value)
+ if arr is not None and len(arr) == length and np.issubdtype(arr.dtype, np.number):
+ return arr
+ return np.arange(length, dtype=float)
+
+
+def _caption_label(panel: Any) -> QLabel:
+ lines = tuple(_field(panel, "caption_lines", ()) or ())
+ if not lines:
+ verdict = _field(panel, "verdict_label", "")
+ p = _field(panel, "global_p", None)
+ lines = tuple(item for item in (verdict, f"global p: {p}" if p is not None else "") if item)
+ label = QLabel("\n".join(str(line) for line in lines) or "No caption.")
+ label.setWordWrap(True)
+ label.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ return label
+
+
+def _model_summary_widget(rows: tuple[tuple[Any, ...], ...]) -> QWidget:
+ container = QWidget()
+ layout = QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(8)
+ grouped: dict[str, list[dict[str, str]]] = {}
+ for row in rows:
+ parsed = _parse_verdict_row(row)
+ if parsed is None:
+ continue
+ grouped.setdefault(parsed["model"], []).append(parsed)
+ if not grouped:
+ layout.addWidget(QLabel("No model verdicts available."))
+ return container
+ green = "#2fb344"
+ red = "#e0564b"
+ for model_id, entries in grouped.items():
+ # A model is ruled out if any one statistic rejects it; plausible only when
+ # every statistic stays consistent. Colour the card so it reads at a glance.
+ ruled_out = any(_verdict_is_inconsistent(entry["verdict"]) for entry in entries)
+ bar = red if ruled_out else green
+ card = QFrame(container)
+ card.setFrameShape(QFrame.StyledPanel)
+ card.setStyleSheet(
+ f"QFrame {{ border: 1px solid #384250; border-left: 5px solid {bar}; }}"
+ )
+ card_layout = QVBoxLayout(card)
+ card_layout.setContentsMargins(10, 8, 8, 8)
+ card_layout.setSpacing(5)
+ badge = "ruled out" if ruled_out else "plausible"
+ title = QLabel(
+ f"{_model_label(model_id)} "
+ f"— {badge}"
+ )
+ title.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ card_layout.addWidget(title)
+ desc = QLabel(_model_description(model_id))
+ desc.setWordWrap(True)
+ desc.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ card_layout.addWidget(desc)
+ for entry in entries:
+ inconsistent = _verdict_is_inconsistent(entry["verdict"])
+ colour = red if inconsistent else green
+ line = (
+ f"{_statistic_label(entry['statistic'])}: "
+ f"{_verdict_label(entry['verdict'])}"
+ )
+ details = []
+ if entry.get("p"):
+ details.append(f"p {entry['p']}")
+ if entry.get("outside"):
+ details.append(f"outside {entry['outside']}")
+ if details:
+ line += " (" + ", ".join(details) + ")"
+ stat = QLabel(line)
+ stat.setWordWrap(True)
+ stat.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ card_layout.addWidget(stat)
+ layout.addWidget(card)
+ return container
+
+
+def _verdict_is_inconsistent(verdict_id: str) -> bool:
+ return "inconsistent" in str(verdict_id).lower()
+
+
+def _parse_verdict_row(row: tuple[Any, ...]) -> dict[str, str] | None:
+ values = tuple(str(item) for item in row)
+ if len(values) >= 7:
+ return {
+ "model": values[0],
+ "statistic": values[1],
+ "verdict": values[2],
+ "p": values[3],
+ "outside": values[5],
+ }
+ if len(values) >= 5:
+ return {
+ "model": values[1],
+ "statistic": values[2],
+ "verdict": values[3],
+ "p": values[4],
+ "outside": "",
+ }
+ return None
+
+
+def _model_label(model_id: str) -> str:
+ labels = {
+ "homogeneous_poisson": "Random placement",
+ "poisson": "Random placement",
+ "hard_core_random": "No-overlap random placement",
+ "measured_feature_poisson": "Feature-biased placement",
+ }
+ return labels.get(str(model_id), str(model_id).replace("_", " ").title())
+
+
+def _model_description(model_id: str) -> str:
+ descriptions = {
+ "homogeneous_poisson": "Assumes points are placed independently with one average density across the allowed region.",
+ "poisson": "Assumes points are placed independently with one average density across the allowed region.",
+ "hard_core_random": "Assumes random placement but prevents points from being closer than a minimum distance.",
+ "measured_feature_poisson": "Assumes point density can be biased by independently measured feature locations.",
+ }
+ return descriptions.get(str(model_id), "A spatial null model used as a comparison baseline.")
+
+
+def _statistic_label(statistic_id: str) -> str:
+ labels = {
+ "pair_correlation_g_r": "Pair correlation",
+ "pair_correlation_g_r_theta": "Pair distance-angle map",
+ "bond_order_psi6": "ψ6 triangular order",
+ "bond_order_psi4": "ψ4 square order",
+ "nearest_neighbor_distribution": "Nearest neighbors",
+ "ripley_l_function": "Ripley L",
+ "cluster_size_counts": "Cluster sizes",
+ }
+ return labels.get(str(statistic_id), str(statistic_id).replace("_", " ").title())
+
+
+def _plot_title(panel: Any) -> str:
+ statistic = str(_field(panel, "statistic", "") or "")
+ labels = {
+ "pair_correlation_g_r": "Pair correlation g(r)",
+ "pair_correlation_g_r_theta": "Pair distance-angle map",
+ "bond_order_psi6": "ψ6 local order - triangular-like neighborhoods",
+ "bond_order_psi4": "ψ4 local order - square-like neighborhoods",
+ "nearest_neighbor_distribution": "Nearest-neighbor distances",
+ "ripley_l_function": "Ripley L",
+ "cluster_size_counts": "Cluster sizes",
+ }
+ if statistic in labels:
+ return labels[statistic]
+ return str(_field(panel, "title", "") or statistic)
+
+
+def _curve_legend_entries(
+ *,
+ has_band: bool,
+ has_central: bool,
+ has_observed: bool = True,
+) -> list[tuple[str, str, str, bool]]:
+ entries: list[tuple[str, str, str, bool]] = []
+ if has_band:
+ entries.append(("model envelope", _CURVE_BAND_COLOR, "line", False))
+ if has_central:
+ entries.append(("model median", _CURVE_MODEL_COLOR, "line", False))
+ if has_observed:
+ entries.append(("observed data", _CURVE_OBSERVED_COLOR, "line", False))
+ return entries
+
+
+def _verdict_label(verdict_id: str) -> str:
+ labels = {
+ "consistent_with_null": "consistent with this model",
+ "inconsistent_with_null": "not consistent with this model",
+ "underpowered": "not enough information to decide",
+ }
+ return labels.get(str(verdict_id), str(verdict_id).replace("_", " "))
+
+
+def _section_label(text: str) -> QLabel:
+ label = QLabel(text)
+ label.setStyleSheet("font-weight: 600;")
+ return label
+
+
+def _table_widget(columns: tuple[Any, ...], rows: tuple[tuple[Any, ...], ...]) -> QTableWidget:
+ max_cols = max([len(tuple(row)) for row in rows] + [len(columns), 1])
+ headers = [str(col) for col in columns]
+ while len(headers) < max_cols:
+ headers.append(f"col {len(headers) + 1}")
+ table = QTableWidget(len(rows), max_cols)
+ table.setHorizontalHeaderLabels(headers[:max_cols])
+ table.setEditTriggers(QTableWidget.NoEditTriggers)
+ table.setSelectionBehavior(QTableWidget.SelectRows)
+ table.setAlternatingRowColors(True)
+ for row_index, row in enumerate(rows):
+ for col_index, value in enumerate(tuple(row)[:max_cols]):
+ table.setItem(row_index, col_index, QTableWidgetItem(str(value)))
+ table.resizeColumnsToContents()
+ table.resizeRowsToContents()
+ return table
+
+
+def _verdict_columns(rows: tuple[tuple[Any, ...], ...]) -> tuple[str, ...]:
+ width = max(len(tuple(row)) for row in rows)
+ if width == 7:
+ return ("model", "statistic", "verdict", "ERL p", "score", "outside", "n_sim")
+ if width == 5:
+ return ("coverage", "model", "statistic", "verdict", "global p")
+ return tuple(f"col {index + 1}" for index in range(width))
+
+
+def _explainer_text(explainer: Any) -> str:
+ pieces = [
+ _field(explainer, "friendly_name", ""),
+ _field(explainer, "plain_summary", ""),
+ _field(explainer, "useful_for", ""),
+ ]
+ cautions = tuple(_field(explainer, "cautions", ()) or ())
+ if cautions:
+ pieces.append("Cautions: " + " ".join(str(item) for item in cautions))
+ return "\n\n".join(str(piece) for piece in pieces if piece)
+
+
+def _scrollable(widget: QWidget) -> QScrollArea:
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.NoFrame)
+ scroll.setWidget(widget)
+ return scroll
+
+
+def _array_or_none(value: Any) -> np.ndarray | None:
+ if value is None:
+ return None
+ arr = np.asarray(value)
+ return arr if arr.size else None
+
+
+def _xy_array_or_none(value: Any) -> np.ndarray | None:
+ arr = _array_or_none(value)
+ if arr is None:
+ return None
+ arr = np.asarray(arr, dtype=float)
+ if arr.ndim != 2 or arr.shape[1] != 2:
+ return None
+ return arr
+
+
+def _finite_float(value: Any) -> float | None:
+ if value in (None, ""):
+ return None
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ return None
+ return number if np.isfinite(number) else None
+
+
+def _particle_xy_or_none(value: Any) -> np.ndarray | None:
+ if value is None:
+ return None
+ xy_nm = _field(value, "xy_nm", None)
+ if xy_nm is not None:
+ return _xy_array_or_none(xy_nm)
+ return _xy_array_or_none(value)
+
+
+def _realspace_marker_style(data_mode: str) -> dict[str, Any]:
+ if _normalise_data_mode(data_mode) == "sandbox":
+ return {
+ "marker": "^",
+ "color": "#f28e2b",
+ "edgecolor": "#3a2200",
+ "size": 38,
+ "label": "generated observed",
+ }
+ return {
+ "marker": "o",
+ "color": "#2f7ed8",
+ "edgecolor": "white",
+ "size": 28,
+ "label": "observed",
+ }
+
+
+def _normalise_data_mode(data_mode: str) -> str:
+ mode = str(data_mode or "real").strip().lower()
+ if mode not in {"real", "sandbox"}:
+ raise ValueError("data_mode must be 'real' or 'sandbox'")
+ return mode
+
+
+def _normalise_curve_mode(curve_mode: str) -> str:
+ mode = str(curve_mode or "comparison").strip().lower()
+ if mode not in {"comparison", "observed_only"}:
+ raise ValueError("curve_mode must be 'comparison' or 'observed_only'")
+ return mode
+
+
+def _field(obj: Any, name: str, default: Any = None) -> Any:
+ if isinstance(obj, dict):
+ return obj.get(name, default)
+ return getattr(obj, name, default)
+
+
+def _short_tab_title(title: str) -> str:
+ text = str(title or "Panel").strip()
+ return text if len(text) <= 28 else text[:25].rstrip() + "..."
diff --git a/probeflow/gui/dialogs/adstat_workbench.py b/probeflow/gui/dialogs/adstat_workbench.py
new file mode 100644
index 0000000..56d7dd7
--- /dev/null
+++ b/probeflow/gui/dialogs/adstat_workbench.py
@@ -0,0 +1,9 @@
+"""Compatibility wrapper for the Particle Statistics tool."""
+
+from __future__ import annotations
+
+from probeflow.gui.dialogs.particle_statistics import ParticleStatisticsDialog
+
+
+class AdStatWorkbenchDialog(ParticleStatisticsDialog):
+ """Legacy AdStat workbench name; opens Particle Statistics."""
diff --git a/probeflow/gui/dialogs/definitions.py b/probeflow/gui/dialogs/definitions.py
index cbe9f7b..8d67b85 100644
--- a/probeflow/gui/dialogs/definitions.py
+++ b/probeflow/gui/dialogs/definitions.py
@@ -2067,6 +2067,363 @@ def _reference_document(
"""
+_PARTICLE_STATISTICS_ENTRIES: tuple[_DefinitionEntry, ...] = (
+ _DefinitionEntry(
+ title="What is a null model?",
+ params=(),
+ summary=(
+ "A null model is the 'boring explanation' you test your points against - "
+ "a precise recipe for what the positions would look like if nothing "
+ "interesting were going on. The most common one is complete randomness: "
+ "points dropped independently, with no clustering, no preferred spacing, "
+ "and no preference for any location. Particle Statistics asks whether "
+ "your real points could plausibly have come from that recipe. If they "
+ "could, you have no evidence for structure; if they clearly could not, "
+ "that is evidence something real is shaping the pattern."
+ ),
+ in_practice=(
+ "Choose the null model that captures the 'boring' explanation you want to "
+ "rule out, then run the comparison. Start with homogeneous Poisson "
+ "(pure randomness) unless you have a specific alternative in mind."
+ ),
+ equations=(),
+ details=(
+ "Think of it as a strawman on purpose: you set up the simplest "
+ "explanation, then see whether your data can knock it down. Rejecting the "
+ "null ('inconsistent') is the informative outcome; failing to reject it "
+ "('consistent') just means this data does not provide evidence against it.",
+ "Different null models encode different boring explanations - pure "
+ "randomness, randomness with a minimum spacing, or randomness biased "
+ "toward a measured feature - so the model you pick defines exactly what "
+ "'no structure' means for your question.",
+ ),
+ cautions=(
+ "A null model is a baseline to argue against, not a description of your "
+ "sample. 'Consistent with the null' is not proof that the null is true - "
+ "only that this data cannot rule it out.",
+ ),
+ ),
+ _DefinitionEntry(
+ title="How a comparison works (null model + envelope test)",
+ params=(
+ "model = homogeneous_poisson | hard_core_random | measured_feature_poisson",
+ "simulations (n_sim)",
+ "random_seed",
+ "alpha = 0.05",
+ ),
+ summary=(
+ "Particle Statistics never judges a pattern in isolation. It measures a "
+ "statistic on your points, then measures the same statistic on many "
+ "random patterns drawn from a chosen null model on the identical "
+ "region, and asks whether your observed curve looks like one of those "
+ "null draws. The spread of the null draws is the envelope; the verdict "
+ "is a single global test, not a glance at the band."
+ ),
+ in_practice=(
+ "Pick a null model, set the number of simulations (more gives a smoother "
+ "envelope and a finer smallest p-value), and run. Use the same region "
+ "for the observed statistic and every simulation."
+ ),
+ equations=(
+ "Observed statistic T_obs(r); null draws T_1(r) … T_n(r) on the same region.\n"
+ "\n"
+ "Pointwise band (display only): at each r, [low, high] = 2.5th-97.5th\n"
+ "percentile of the simulated T_k(r).\n"
+ "\n"
+ "Global verdict - extreme rank length (ERL; Myllymaki et al. 2017):\n"
+ " pool = {T_obs, T_1, ..., T_n} (n+1 functions, exchangeable under H0)\n"
+ " R_k(r) = min( #{j: T_j(r) >= T_k(r)}, #{j: T_j(r) <= T_k(r)} )\n"
+ " (two-sided pointwise rank; smaller = more extreme)\n"
+ " sort each function's ranks ascending, compare lexicographically\n"
+ " p = (1 + #{k>=1 : T_k at least as extreme as T_obs}) / (n_sim + 1)\n"
+ " verdict = inconsistent if p <= alpha (0.05), else consistent",
+ ),
+ details=(
+ "Why uncorrected statistics still give a correct test: the SAME estimator "
+ "- with the same finite-region edge bias - is applied to the observed "
+ "pattern and to every null simulation on the identical region or mask. "
+ "The ERL test only asks whether the observed curve is exchangeable with "
+ "the simulated ones, so any bias they share cancels out. AdStat's "
+ "calibration test confirms the test holds its nominal ~5% false-positive "
+ "rate under a true null.",
+ "The pointwise band is for the eye only. Because it is computed at each r "
+ "separately, some points fall outside it a few percent of the time even "
+ "under a true null - so the verdict comes from the one global ERL test, "
+ "not from counting out-of-band points.",
+ "The smallest possible p-value is 1/(n_sim+1): with 19 simulations the "
+ "best you can claim is p = 0.05; use 99 or more to resolve p <= 0.01.",
+ ),
+ cautions=(
+ "Comparing one pattern against several models (or reading several "
+ "statistics) is multiple testing, and ProbeFlow does not correct for it. "
+ "Treat a single rejection among many comparisons cautiously.",
+ ),
+ ),
+ _DefinitionEntry(
+ title="Null model: Homogeneous Poisson (complete spatial randomness)",
+ params=("N fixed to the observed count",),
+ summary=(
+ "The baseline 'no structure' model: every point is placed independently "
+ "and uniformly across the allowed region, at one average density. It is "
+ "the reference for clustering and spacing questions alike."
+ ),
+ in_practice=(
+ "Start here. If the pattern is already consistent with random placement, "
+ "the more specific models rarely add anything."
+ ),
+ equations=(
+ "Simulate: N points placed independently and uniformly over the region\n"
+ "(mask-aware - only allowed pixels are used).\n"
+ "Intensity (density): lambda = N / A_region\n"
+ "\n"
+ "This is a binomial process: the count is conditioned on the observed N\n"
+ "(not Poisson-distributed N), which is exactly the right question - 'are\n"
+ "THESE N points randomly placed?'.",
+ ),
+ cautions=(
+ "It assumes one homogeneous density over the whole region (stationarity). "
+ "A strong large-scale coverage gradient violates that assumption and can "
+ "masquerade as clustering; restrict the analysis region or mask to a "
+ "uniform area first.",
+ ),
+ ),
+ _DefinitionEntry(
+ title="Null model: Hard-core random (minimum separation)",
+ params=(
+ "hard_core_radius (centre-to-centre minimum, nm)",
+ "attempt_limit",
+ ),
+ summary=(
+ "Random placement, but with a forbidden minimum separation - a simple "
+ "model of particles that cannot overlap or sit too close (excluded "
+ "volume, site blocking)."
+ ),
+ in_practice=(
+ "Use it when nearest-neighbour distances show a clear minimum spacing and "
+ "you want to test whether plain exclusion explains the pattern."
+ ),
+ equations=(
+ "Simple sequential inhibition (SSI / random sequential adsorption):\n"
+ " repeat: draw a uniform candidate; accept iff its distance to every\n"
+ " already-accepted point >= r_hc; stop at N accepted or attempt_limit.\n"
+ "\n"
+ "Each accepted centre carries an exclusion disk of radius r_hc/2; these\n"
+ "disks never overlap. Packing fraction phi = N * pi (r_hc/2)^2 / A.\n"
+ "SSI jams (cannot fit more) near phi ~ 0.547.",
+ ),
+ cautions=(
+ "SSI is a non-equilibrium process: it is NOT the thermodynamic "
+ "equilibrium hard-disk gas. 'Consistent with hard-core' means consistent "
+ "with this sequential-exclusion null, not with a Gibbs hard-disk model.",
+ "If placement cannot fit N points before the attempt limit, lower the "
+ "radius or the count - a failure here means the requested density exceeds "
+ "what exclusion allows.",
+ ),
+ ),
+ _DefinitionEntry(
+ title="Null model: Measured-feature Poisson (association)",
+ params=(
+ "feature_layer (independently measured)",
+ "kernel_sigma_nm (sigma)",
+ "feature_weight (w1)",
+ "background_weight (w0)",
+ ),
+ summary=(
+ "Tests whether points follow an independently measured feature - for "
+ "example, whether adsorbates sit preferentially near step edges. The "
+ "feature defines a non-uniform expected density."
+ ),
+ in_practice=(
+ "Provide a feature set that was measured separately from the particles "
+ "(e.g. step traces). Choose sigma to match how far the influence reaches."
+ ),
+ equations=(
+ "Inhomogeneous Poisson intensity from a Gaussian kernel around the\n"
+ "measured features f_i:\n"
+ " lambda(x) = w0 + w1 * SUM_i exp( - d(x, f_i)^2 / (2 sigma^2) )\n"
+ " d = distance to a point feature, or to the nearest point on a line\n"
+ " segment.\n"
+ "Simulate: sample N points with probability proportional to lambda(x)\n"
+ "(evaluated on an adaptive grid, then jittered within a cell).",
+ ),
+ cautions=(
+ "The feature layer must be measured independently of the particles being "
+ "tested. Using the particles - or anything derived from them - as their "
+ "own feature layer is circular and manufactures a false association.",
+ ),
+ ),
+ _DefinitionEntry(
+ title="Statistic: Pair correlation g(r)",
+ params=(
+ "pair_bin_width_nm",
+ "pair_max_radius_nm",
+ "edge_correction = none | translation",
+ ),
+ summary=(
+ "At each separation r, are there more or fewer neighbour pairs than "
+ "random placement would give? The most sensitive first look at "
+ "short-range clustering or avoidance."
+ ),
+ in_practice=(
+ "A bump above 1 at small r is clustering; a dip below 1 near zero is "
+ "spacing/avoidance. Read it together with the model envelope, not against "
+ "the 1.0 line alone."
+ ),
+ equations=(
+ "Count unordered pairs (i 1 clustering ; < 1 spacing/avoidance\n"
+ "\n"
+ "Optional translation edge weight for a pair separated by (dx, dy):\n"
+ " w = A_region / [ (W - |dx|) (H - |dy|) ]",
+ ),
+ cautions=(
+ "Uncorrected g(r) is biased high near the boundary; the matched "
+ "simulations carry the same bias so the verdict stays valid, but read "
+ "absolute g(r) values at r approaching half the field size with care.",
+ ),
+ ),
+ _DefinitionEntry(
+ title="Statistic: Nearest-neighbour distribution",
+ params=("nn_bin_width_nm", "nn_max_distance_nm"),
+ summary=(
+ "The distribution of each point's distance to its single closest "
+ "neighbour - the clearest first test of spacing versus clumping."
+ ),
+ in_practice=(
+ "Clustering pushes weight toward short distances; exclusion/spacing piles "
+ "the distribution up near a minimum distance."
+ ),
+ equations=(
+ "For each point i: d_NN(i) = min over j != i of || x_i - x_j ||\n"
+ "Histogram d_NN over bins, normalised to a fraction (counts / N).\n"
+ "\n"
+ "CSR reference distribution (intensity lambda):\n"
+ " G(r) = 1 - exp( - lambda * pi * r^2 )",
+ ),
+ details=(
+ "The maximum distance is chosen from the point density so the histogram "
+ "is not truncated for sparse fields. The empirical distribution is "
+ "compared to the simulations, not to the closed-form G(r).",
+ ),
+ ),
+ _DefinitionEntry(
+ title="Statistic: Ripley's L",
+ params=("derived radii", "edge_correction = none | translation"),
+ summary=(
+ "A cumulative, variance-stabilised count of how many neighbours fall "
+ "within each distance - sensitive to clustering or regularity across a "
+ "range of scales at once."
+ ),
+ in_practice=(
+ "L(r) - r above zero is clustering, below zero is regularity. Because it "
+ "is cumulative, read where the curve first leaves the envelope."
+ ),
+ equations=(
+ "K(r) = (A_region / [N(N-1)]) * 2 * C(r),\n"
+ " C(r) = number of unordered pairs with distance <= r\n"
+ "L(r) = sqrt( K(r) / pi )\n"
+ "Under CSR: K(r) = pi r^2 => L(r) - r = 0\n"
+ " L(r) - r > 0 clustering ; < 0 regularity/spacing",
+ ),
+ cautions=(
+ "Being cumulative, it accumulates structure across scales - an excursion "
+ "at large r can reflect structure that really lives at smaller r. Use "
+ "g(r) to localise the scale.",
+ ),
+ ),
+ _DefinitionEntry(
+ title="Statistic: Cluster sizes",
+ params=("cluster_radius_nm (link distance)",),
+ summary=(
+ "Groups points that chain together within a link distance and reports how "
+ "many singletons, pairs, triples, and larger groups there are."
+ ),
+ in_practice=(
+ "An excess of large groups versus the null is clustering. Choose the link "
+ "distance from the scale of grouping you care about."
+ ),
+ equations=(
+ "Build a graph: link i-j iff || x_i - x_j || <= r_link.\n"
+ "Clusters = connected components (single linkage; links are transitive,\n"
+ "so a chain of close points forms one cluster).\n"
+ "Report the histogram of component sizes.",
+ ),
+ cautions=(
+ "The link distance is an analysis threshold, not a physical capture "
+ "radius. Single linkage can chain a sparse bridge of points into one "
+ "large cluster - compare the size histogram to the simulated null, not to "
+ "an absolute expectation.",
+ ),
+ ),
+ _DefinitionEntry(
+ title="Local-order checks: psi4 / psi6 and angular g(r, theta) (opt-in)",
+ params=(
+ "neighbour_radius_nm (~1.35 x median nearest-neighbour distance)",
+ "symmetry n = 4 | 6",
+ "pair_angle_bin_width_deg",
+ ),
+ summary=(
+ "Do neighbours sit at lattice-like angles - square (psi4) or triangular "
+ "(psi6)? These answer a different question from the randomness checks and "
+ "are off by default; tick 'Include local-order checks' to compute them."
+ ),
+ in_practice=(
+ "Reach for these only when you suspect ordered packing or registry. "
+ "Values of |psi_n| near 1 mean strong local n-fold order."
+ ),
+ equations=(
+ "Per point i, over its neighbours j within the cutoff radius:\n"
+ " psi_n(i) = (1 / k_i) * SUM_j exp( i * n * theta_ij ),\n"
+ " theta_ij = atan2(dy, dx)\n"
+ " |psi_n| = 0 isotropic ... 1 perfect n-fold local order\n"
+ "Report the histogram of |psi_n| (psi6 triangular, psi4 square).\n"
+ "\n"
+ "Angular pair map g(r, theta): pair counts in (distance, direction)\n"
+ "bins, with directions folded to [0, 180) since a pair has no arrow.",
+ ),
+ details=(
+ "Validated by known-answer tests: a triangular lattice rejects on psi6 "
+ "and a square lattice on psi4, while random points stay consistent "
+ "(tests/test_adstat_validation.py).",
+ ),
+ cautions=(
+ "Sensitive to the neighbour cutoff and to edges: there is no boundary "
+ "correction, so points near the region edge have truncated "
+ "neighbourhoods and read as less ordered. A high |psi_n| suggests local "
+ "order; it is not proof of a crystal or a specific adsorption site.",
+ ),
+ ),
+ _DefinitionEntry(
+ title="Reading verdicts and limitations",
+ params=(),
+ summary=(
+ "How to read a result and what it does - and does not - prove."
+ ),
+ equations=(),
+ details=(
+ "'Consistent with the model' means the null was not rejected at alpha. It "
+ "is not positive proof there is no structure: a small or noisy set may "
+ "simply lack the power to detect an effect.",
+ "'Inconsistent with the model' is evidence the pattern departs from that "
+ "null - not proof of any particular physical mechanism.",
+ "Pooling several independent images of the same condition is the "
+ "practical way to gain statistical power.",
+ ),
+ cautions=(
+ "This is the newest and least user-tested part of ProbeFlow and may "
+ "contain mistakes; verify important results independently (the maturity "
+ "note at the top of this reference says the same).",
+ "Hard-core compares against a sequential-exclusion null, not an "
+ "equilibrium hard-disk gas; no correction is applied for comparing "
+ "several models or statistics - treat an isolated rejection cautiously.",
+ ),
+ ),
+)
+
+
def render_definitions_html(theme: Mapping[str, object] | None = None) -> str:
"""Return theme-aware HTML for the processing definitions dialog."""
return _render_reference_html(
@@ -2162,10 +2519,33 @@ def render_howto_html(theme: Mapping[str, object] | None = None) -> str:
)
+def render_particle_statistics_html(theme: Mapping[str, object] | None = None) -> str:
+ """Return theme-aware HTML for the Particle Statistics reference."""
+ return _render_reference_html(
+ title="Particle Statistics Reference",
+ intro=(
+ "Particle Statistics asks a spatial question: are these point positions "
+ "consistent with a simple null model, or do they show clustering, "
+ "spacing, or association with an independently measured feature? It never "
+ "judges a pattern alone - it compares the observed statistic with the "
+ "same statistic measured on many random patterns drawn from a chosen "
+ "model on the identical region. Each entry below gives a plain-language "
+ "summary, the exact estimator or algorithm the program uses, practical "
+ "notes, and cautions. Maturity note: this is the newest and least "
+ "user-tested part of ProbeFlow and may contain mistakes - treat verdicts "
+ "as exploratory and verify important results independently. Calculations "
+ "are powered by the AdStat engine."
+ ),
+ entries=_PARTICLE_STATISTICS_ENTRIES,
+ theme=theme,
+ )
+
+
_DEFINITIONS_HTML = render_definitions_html()
_ROI_REFERENCE_HTML = render_roi_reference_html()
_MEASUREMENTS_HTML = render_measurements_html()
_HOWTO_HTML = render_howto_html()
+_PARTICLE_STATISTICS_HTML = render_particle_statistics_html()
class _HtmlReferencePanel(QWidget):
@@ -2227,6 +2607,13 @@ def __init__(self, t: dict, parent=None):
super().__init__(t, render_howto_html(t), parent)
+class _ParticleStatisticsPanel(_HtmlReferencePanel):
+ """Scrollable reference panel describing the Particle Statistics models."""
+
+ def __init__(self, t: dict, parent=None):
+ super().__init__(t, render_particle_statistics_html(t), parent)
+
+
class _DefinitionsDialog(QDialog):
"""Closeable utility window for processing and ROI definitions/help."""
@@ -2242,15 +2629,23 @@ def __init__(self, t: dict, parent=None, *, initial_tab: str = "processing"):
self._panel = _DefinitionsPanel(t, self)
self._measurements_panel = _MeasurementsPanel(t, self)
self._roi_panel = _ROIReferencePanel(t, self)
+ self._particle_stats_panel = _ParticleStatisticsPanel(t, self)
self._tabs.addTab(self._howto_panel, "How-to")
self._tabs.addTab(self._panel, "Processing")
self._tabs.addTab(self._measurements_panel, "Measurements")
self._tabs.addTab(self._roi_panel, "ROI Actions")
+ self._tabs.addTab(self._particle_stats_panel, "Particle Statistics")
lay.addWidget(self._tabs)
self.set_reference_tab(initial_tab)
# Stable tab keys -> tab index (How-to first as the friendliest landing).
- _TAB_INDEX = {"howto": 0, "processing": 1, "measurements": 2, "roi": 3}
+ _TAB_INDEX = {
+ "howto": 0,
+ "processing": 1,
+ "measurements": 2,
+ "roi": 3,
+ "particle_statistics": 4,
+ }
def set_reference_tab(self, tab: str) -> None:
"""Switch to the named reference tab."""
@@ -2261,6 +2656,14 @@ def set_reference_tab(self, tab: str) -> None:
key = "howto"
elif key in {"measurements", "measurement", "measure"}:
key = "measurements"
+ elif key in {
+ "particle_statistics",
+ "particle",
+ "particles",
+ "stats",
+ "statistics",
+ }:
+ key = "particle_statistics"
else:
key = "processing"
self._tabs.setCurrentIndex(self._TAB_INDEX[key])
diff --git a/probeflow/gui/dialogs/feature_finder.py b/probeflow/gui/dialogs/feature_finder.py
index ab8344d..0cec982 100644
--- a/probeflow/gui/dialogs/feature_finder.py
+++ b/probeflow/gui/dialogs/feature_finder.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from pathlib import Path
+from typing import Any
import numpy as np
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
@@ -58,6 +59,9 @@ def __init__(
pixel_size_y_m: float = 1e-10,
roi_mask: np.ndarray | None = None,
theme: dict | None = None,
+ value_scale: float = 1.0,
+ value_unit: str = "",
+ on_send_to_particle_statistics: Any = None,
parent=None,
):
super().__init__(parent)
@@ -70,6 +74,11 @@ def __init__(
self._px_y_m = float(pixel_size_y_m)
self._roi_mask = roi_mask
self._t = theme or {}
+ # Thresholds display in the channel's natural unit (raw_value * value_scale),
+ # e.g. nm or pA, so the spinboxes are readable instead of raw SI.
+ self._value_scale = float(value_scale) if value_scale else 1.0
+ self._value_unit = str(value_unit or "")
+ self._on_send_to_particle_statistics = on_send_to_particle_statistics
self._result: FeatureDetectionResult | None = None
self._build()
@@ -159,8 +168,13 @@ def _build(self) -> None:
v_min = float(arr_finite.min()) if arr_finite.size else 0.0
v_max = float(arr_finite.max()) if arr_finite.size else 1.0
v_range = v_max - v_min or 1.0
- step = v_range / 100.0
- decimals = max(3, -int(np.floor(np.log10(abs(step) + 1e-30))))
+ # Display in the channel's natural unit (raw * scale), e.g. nm / pA.
+ scale = self._value_scale
+ d_min = v_min * scale
+ d_range = v_range * scale
+ step = abs(d_range) / 100.0 or 1.0
+ decimals = max(2, min(6, -int(np.floor(np.log10(step + 1e-30))) + 1))
+ suffix = f" {self._value_unit}" if self._value_unit else ""
# Low threshold
self._low_row = QHBoxLayout()
@@ -169,7 +183,8 @@ def _build(self) -> None:
self._low_spin.setRange(-1e15, 1e15)
self._low_spin.setDecimals(decimals)
self._low_spin.setSingleStep(step)
- self._low_spin.setValue(v_min + 0.5 * v_range)
+ self._low_spin.setSuffix(suffix)
+ self._low_spin.setValue(d_min + 0.5 * d_range)
self._low_row.addWidget(self._low_lbl)
self._low_row.addWidget(self._low_spin)
lay.addLayout(self._low_row)
@@ -181,7 +196,8 @@ def _build(self) -> None:
self._high_spin.setRange(-1e15, 1e15)
self._high_spin.setDecimals(decimals)
self._high_spin.setSingleStep(step)
- self._high_spin.setValue(v_max)
+ self._high_spin.setSuffix(suffix)
+ self._high_spin.setValue(d_min + d_range)
self._high_row.addWidget(self._high_lbl)
self._high_row.addWidget(self._high_spin)
lay.addLayout(self._high_row)
@@ -231,6 +247,22 @@ def _build(self) -> None:
self._count_lbl.setFont(ui_font(9))
lay.addWidget(self._count_lbl)
+ if self._on_send_to_particle_statistics is not None:
+ self._send_btn = QPushButton("Send to Particle Statistics")
+ self._send_btn.setObjectName("featureFinderSendToParticleStatistics")
+ self._send_btn.setFixedHeight(30)
+ self._send_btn.setFont(ui_font(10, weight=QFont.Bold))
+ self._send_btn.setStyleSheet(
+ "QPushButton { background-color: #2fb344; color: #071b0b; "
+ "border: 2px solid #83e89b; font-weight: 800; } "
+ "QPushButton:hover { background-color: #39c956; }"
+ )
+ self._send_btn.setToolTip(
+ "Save these features and open them in Particle Statistics."
+ )
+ self._send_btn.clicked.connect(self._send_to_particle_statistics)
+ lay.addWidget(self._send_btn)
+
# ── Export coordinates ──────────────────────────────────────────────
lay.addWidget(_sep())
export_lbl = QLabel("Export")
@@ -325,8 +357,10 @@ def _run_detection(self) -> None:
else None
)
thr = self._threshold_mode()
- tlo = self._low_spin.value() if thr in ("above", "between") else None
- thi = self._high_spin.value() if thr in ("below", "between") else None
+ # Spinboxes are in display units; convert back to raw for detection.
+ scale = self._value_scale or 1.0
+ tlo = self._low_spin.value() / scale if thr in ("above", "between") else None
+ thi = self._high_spin.value() / scale if thr in ("below", "between") else None
try:
self._result = find_image_features(
self._arr,
@@ -368,6 +402,22 @@ def _redraw(self) -> None:
zorder=5)
self._canvas.draw_idle()
+ # ── Send ────────────────────────────────────────────────────────────────────
+
+ def _send_to_particle_statistics(self) -> None:
+ if self._on_send_to_particle_statistics is None:
+ return
+ if self._result is None or not self._result.points:
+ self._status_lbl.setText("Run detection first.")
+ return
+ try:
+ self._on_send_to_particle_statistics(self._result)
+ self._status_lbl.setText(
+ f"Sent {len(self._result.points)} features to Particle Statistics."
+ )
+ except Exception as exc: # noqa: BLE001 - surface failures in the dialog
+ self._status_lbl.setText(f"Could not send features: {exc}")
+
# ── Export ────────────────────────────────────────────────────────────────
def _export_csv(self) -> None:
diff --git a/probeflow/gui/dialogs/image_viewer_build_mixin.py b/probeflow/gui/dialogs/image_viewer_build_mixin.py
index 5d0d654..08196b2 100644
--- a/probeflow/gui/dialogs/image_viewer_build_mixin.py
+++ b/probeflow/gui/dialogs/image_viewer_build_mixin.py
@@ -914,6 +914,18 @@ def _summary_row(row: int, name: str, attr: str, *, elide: bool = False) -> QLab
self._measurement_panel.pairCorrelationRequested.connect(
self._on_open_pair_correlation
)
+ self._measurement_panel.particleStatisticsRequested.connect(
+ self._on_open_particle_statistics
+ )
+ self._measurement_panel.adstatWorkbenchRequested.connect(
+ self._on_open_adstat_workbench
+ )
+ self._measurement_panel.adstatStatisticsRequested.connect(
+ self._on_open_adstat_statistics
+ )
+ self._measurement_panel.adstatSandboxRequested.connect(
+ self._on_open_adstat_sandbox
+ )
self._measurement_panel.featureToLatticeRequested.connect(
self._on_open_feature_lattice
)
diff --git a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py
index 80c8c9d..c43b56a 100644
--- a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py
+++ b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py
@@ -414,6 +414,23 @@ def _build_viewer_menu_bar(self) -> None:
self._on_open_pair_correlation,
)
features_menu.addAction(pair_corr_action)
+ particle_statistics_action = self._viewer_action(
+ "measure.particle_statistics",
+ lambda: self._on_open_particle_statistics(),
+ )
+ features_menu.addAction(particle_statistics_action)
+ self._viewer_action(
+ "measure.adstat_workbench",
+ lambda: self._on_open_adstat_workbench(),
+ )
+ self._viewer_action(
+ "measure.adstat_statistics",
+ self._on_open_adstat_statistics,
+ )
+ self._viewer_action(
+ "measure.adstat_sandbox",
+ self._on_open_adstat_sandbox,
+ )
feat_lat_action = self._viewer_action(
"measure.feature_lattice",
self._on_open_feature_lattice,
diff --git a/probeflow/gui/dialogs/import_points.py b/probeflow/gui/dialogs/import_points.py
new file mode 100644
index 0000000..751b9b6
--- /dev/null
+++ b/probeflow/gui/dialogs/import_points.py
@@ -0,0 +1,162 @@
+"""Calibration/format confirmation dialog for importing a point table.
+
+Shown after :func:`probeflow.measurements.point_table_io.sniff_point_table`
+detects the file's shape, prefilled with the sniffed guesses, so the user can
+confirm units and physical field size before the points become a feature set.
+ProbeFlow JSON files that carry their own calibration skip this dialog entirely.
+"""
+
+from __future__ import annotations
+
+import math
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QComboBox,
+ QDialog,
+ QDialogButtonBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QLabel,
+ QSpinBox,
+ QVBoxLayout,
+)
+
+from probeflow.measurements.point_table_io import (
+ ACCEPTED_UNITS,
+ PointTablePreview,
+ default_image_shape,
+ default_scan_range_m,
+)
+
+_UNIT_LABELS = {"px": "pixels", "nm": "nanometres (nm)", "um": "micrometres (µm)", "m": "metres (m)"}
+
+_ACCEPTED_FORMATS_NOTE = (
+ "Accepted: CSV position tables (with or without a leading particle-number "
+ "column; units inferred from x_px / x_nm / x_m / x_phys headers or chosen "
+ "here), ProbeFlow Feature Finder / measurements CSV, and ProbeFlow JSON "
+ "(Feature Counting exports and saved feature-set files)."
+)
+
+
+class ImportPointsDialog(QDialog):
+ """Confirm units + physical field size for an imported point table."""
+
+ def __init__(self, preview: PointTablePreview, *, theme: dict | None = None, parent=None):
+ super().__init__(parent)
+ self.setObjectName("importPointsDialog")
+ self.setWindowTitle("Import points")
+ self._preview = preview
+
+ units = preview.units if preview.units in ACCEPTED_UNITS else "nm"
+ scan_range_m, image_shape = _initial_calibration(preview, units)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(14, 14, 14, 14)
+ layout.setSpacing(8)
+
+ summary = QLabel(
+ f"Detected: {_kind_label(preview.kind)} — "
+ f"{preview.n_points} point(s)"
+ + (", leading id column" if preview.has_id_column else "")
+ )
+ summary.setWordWrap(True)
+ layout.addWidget(summary)
+
+ form = QFormLayout()
+ form.setSpacing(6)
+
+ self._units_cb = QComboBox(self)
+ self._units_cb.setObjectName("importPointsUnits")
+ for u in ACCEPTED_UNITS:
+ self._units_cb.addItem(_UNIT_LABELS[u], u)
+ idx = self._units_cb.findData(units)
+ if idx >= 0:
+ self._units_cb.setCurrentIndex(idx)
+ form.addRow("Position units:", self._units_cb)
+
+ self._field_w = _nm_spin(scan_range_m[0] * 1e9)
+ self._field_w.setObjectName("importPointsFieldW")
+ form.addRow("Field width (nm):", self._field_w)
+ self._field_h = _nm_spin(scan_range_m[1] * 1e9)
+ self._field_h.setObjectName("importPointsFieldH")
+ form.addRow("Field height (nm):", self._field_h)
+
+ self._img_w = _px_spin(image_shape[1])
+ self._img_w.setObjectName("importPointsImgW")
+ form.addRow("Image width (px):", self._img_w)
+ self._img_h = _px_spin(image_shape[0])
+ self._img_h.setObjectName("importPointsImgH")
+ form.addRow("Image height (px):", self._img_h)
+ layout.addLayout(form)
+
+ hint = QLabel(
+ "The field size sets the analysis region (and therefore the expected "
+ "random density). The image size is synthetic for an image-less import "
+ "and only affects the pixel-resolution note."
+ )
+ hint.setWordWrap(True)
+ hint.setStyleSheet("color: palette(mid);")
+ layout.addWidget(hint)
+
+ note = QLabel(_ACCEPTED_FORMATS_NOTE)
+ note.setWordWrap(True)
+ note.setStyleSheet("color: palette(mid); font-size: 11px;")
+ layout.addWidget(note)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def result_calibration(self) -> tuple[str, tuple[float, float], tuple[int, int]]:
+ """Return (units, scan_range_m, image_shape) chosen by the user."""
+ units = str(self._units_cb.currentData())
+ scan_range_m = (self._field_w.value() * 1e-9, self._field_h.value() * 1e-9)
+ image_shape = (int(self._img_h.value()), int(self._img_w.value()))
+ return units, scan_range_m, image_shape
+
+
+def _initial_calibration(
+ preview: PointTablePreview, units: str
+) -> tuple[tuple[float, float], tuple[int, int]]:
+ if preview.scan_range_m is not None:
+ sr = preview.scan_range_m
+ img = preview.image_shape or default_image_shape(sr)
+ return sr, img
+ if preview.bbox_raw is None:
+ return (100e-9, 100e-9), (512, 512)
+ if units == "px":
+ # Pixel coordinates: size the image to the extent, default 1 nm/px.
+ _, _, xmax, ymax = preview.bbox_raw
+ nx = int(math.ceil(xmax)) + 1
+ ny = int(math.ceil(ymax)) + 1
+ return (nx * 1e-9, ny * 1e-9), (ny, nx)
+ sr = default_scan_range_m(preview.bbox_raw, units)
+ return sr, default_image_shape(sr)
+
+
+def _kind_label(kind: str) -> str:
+ return {
+ "generic_csv": "generic CSV",
+ "probeflow_csv": "ProbeFlow CSV",
+ "probeflow_json": "ProbeFlow JSON",
+ "feature_set_store_json": "saved feature-set JSON",
+ }.get(kind, kind)
+
+
+def _nm_spin(value: float) -> QDoubleSpinBox:
+ spin = QDoubleSpinBox()
+ spin.setRange(1e-3, 1e9)
+ spin.setDecimals(3)
+ spin.setValue(max(float(value), 1e-3))
+ spin.setAlignment(Qt.AlignRight)
+ return spin
+
+
+def _px_spin(value: int) -> QSpinBox:
+ spin = QSpinBox()
+ spin.setRange(1, 1_000_000)
+ spin.setValue(max(int(value), 1))
+ spin.setAlignment(Qt.AlignRight)
+ return spin
diff --git a/probeflow/gui/dialogs/particle_statistics.py b/probeflow/gui/dialogs/particle_statistics.py
new file mode 100644
index 0000000..552c0de
--- /dev/null
+++ b/probeflow/gui/dialogs/particle_statistics.py
@@ -0,0 +1,5375 @@
+"""ProbeFlow-native Particle Statistics tool powered by AdStat."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass, replace
+from typing import Any
+
+import numpy as np
+
+from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QThreadPool, QTimer, Signal
+from PySide6.QtGui import QAction, QActionGroup, QColor, QFont, QPainter, QPen, QPolygonF
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QDoubleSpinBox,
+ QFormLayout,
+ QFrame,
+ QGridLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QListWidget,
+ QListWidgetItem,
+ QMenuBar,
+ QPushButton,
+ QScrollArea,
+ QSpinBox,
+ QSplitter,
+ QTabWidget,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from probeflow.analysis.adstat_adapter import (
+ ORDERING_STATISTICS,
+ adstat_sandbox_context,
+ adstat_sandbox_preview,
+ adstat_sandbox_state,
+ adstat_sandbox_view_spec,
+ compare_point_set_record_view_spec,
+)
+from probeflow.gui.dialogs.adstat_results import AdStatPlotWidget, AdStatResultView
+from probeflow.gui.config import load_config, save_config
+from probeflow.gui.desktop_layout import (
+ apply_screen_fraction_geometry,
+ qbytearray_to_b64,
+ restore_geometry_or_default,
+)
+from probeflow.gui.viewer.tool_launch import (
+ AdStatStatisticsRequest,
+ adstat_workbench_launch_context,
+)
+from probeflow.gui.workers import _PooledWorker
+
+
+_PATTERN_LABELS = {
+ "random": "Random",
+ "clustered": "Clustered",
+ "no_overlap": "No overlap / hard-core",
+ "ordered_islands": "Ordered islands / lattice chunks",
+ "feature_biased": "Feature-biased",
+}
+
+_MODEL_LABELS = {
+ "homogeneous_poisson": "Homogeneous Poisson",
+ "hard_core_random": "Hard-core random",
+ "measured_feature_poisson": "Measured-feature Poisson",
+ "poisson": "Homogeneous Poisson",
+}
+_ORDERED_ISLAND_LATTICE_LABELS = {
+ "triangular": "Triangular / hexagonal-like",
+ "square": "Square-like",
+}
+_ORDERED_ISLAND_BACKGROUND_LABELS = {
+ "none": "None",
+ "random": "Random particles",
+ "clustered": "Disordered clusters",
+}
+
+_SETUP_COLUMN_STYLE = """
+QFrame#particleStatisticsDataColumn {
+ border: 1px solid rgba(242, 142, 43, 0.90);
+ border-radius: 6px;
+ background: rgba(242, 142, 43, 0.05);
+}
+QFrame#particleStatisticsModelColumn {
+ border: 1px solid rgba(186, 85, 211, 0.90);
+ border-radius: 6px;
+ background: rgba(186, 85, 211, 0.05);
+}
+QFrame#particleStatisticsStatisticColumn {
+ border: 1px solid rgba(47, 129, 247, 0.90);
+ border-radius: 6px;
+ background: rgba(47, 129, 247, 0.05);
+}
+"""
+
+_TUTORIAL_ACTION_STYLE = (
+ "QPushButton { "
+ "background-color: #2fb344; color: #071b0b; border: 2px solid #83e89b; "
+ "font-weight: 800; padding: 5px 12px; } "
+ "QPushButton:hover { background-color: #39c956; } "
+ "QPushButton:pressed { background-color: #238636; color: #ffffff; } "
+ "QPushButton:disabled { background-color: #25352a; color: #8aa891; "
+ "border: 1px solid #3e5c46; }"
+)
+_TUTORIAL_EXIT_STYLE = (
+ "QPushButton { "
+ "background-color: #b3382f; color: #ffffff; border: 1px solid #e8675c; "
+ "font-weight: 700; padding: 5px 12px; } "
+ "QPushButton:hover { background-color: #c9433a; } "
+ "QPushButton:pressed { background-color: #8f2a23; }"
+)
+_TUTORIAL_ACTIVE_CONTROL_STYLE = (
+ "QWidget { background-color: rgba(47, 179, 68, 0.22); "
+ "border: 2px solid #2fb344; }"
+)
+_TUTORIAL_VISITED_CONTROL_STYLE = (
+ "QWidget { background-color: rgba(224, 176, 32, 0.18); "
+ "border: 1px solid #e0b020; }"
+)
+# Statistic selector buttons look like normal ProbeFlow buttons; the active one
+# (whose plot is currently shown) gets a blue highlight.
+_STAT_SELECTED_STYLE = (
+ "QPushButton { border: 2px solid #2f81f7; background: #243044; font-weight: 700; }"
+)
+_REAL_EMPTY_STATE_MESSAGE = (
+ "No points yet — detect features with Feature Finder and 'Send to Particle "
+ "Statistics', tick a saved feature set, or click Start tutorial to learn the tool."
+)
+
+_DEFAULT_FOCUS_STATISTIC = "pair_correlation_g_r"
+_MODEL_SUMMARY_FOCUS = "model_summary"
+_STATISTIC_GROUPS = (
+ (
+ "General spatial pattern",
+ (
+ "pair_correlation_g_r",
+ "nearest_neighbor_distribution",
+ "ripley_l_function",
+ "cluster_size_counts",
+ ),
+ ),
+ (
+ "Local order / ordered islands",
+ (
+ "pair_correlation_g_r_theta",
+ "bond_order_psi6",
+ "bond_order_psi4",
+ ),
+ ),
+)
+_STATISTIC_ORDER = tuple(
+ statistic for _group, statistics in _STATISTIC_GROUPS for statistic in statistics
+)
+_STATISTIC_LABELS = {
+ "pair_correlation_g_r": "Pair correlation",
+ "pair_correlation_g_r_theta": "Pair distance-angle map",
+ "bond_order_psi6": "ψ6 triangular order",
+ "bond_order_psi4": "ψ4 square order",
+ "nearest_neighbor_distribution": "Nearest neighbors",
+ "ripley_l_function": "Ripley L",
+ "cluster_size_counts": "Cluster sizes",
+ _MODEL_SUMMARY_FOCUS: "Model verdict summary",
+}
+_STATISTIC_TITLES = {
+ "pair_correlation_g_r": "Pair correlation g(r)",
+ "pair_correlation_g_r_theta": "Pair distance-angle map",
+ "bond_order_psi6": "ψ6 local order - triangular-like neighborhoods",
+ "bond_order_psi4": "ψ4 local order - square-like neighborhoods",
+ "nearest_neighbor_distribution": "Nearest-neighbor distances",
+ "ripley_l_function": "Ripley L",
+ "cluster_size_counts": "Cluster sizes",
+ _MODEL_SUMMARY_FOCUS: "Model verdict summary",
+}
+_FALLBACK_STAT_GUIDES = {
+ "pair_correlation_g_r": {
+ "title": "Pair correlation g(r)",
+ "focus_question": "At each distance, are there too many or too few pairs?",
+ "before_run": "Computing the observed curve against a simulated envelope…",
+ "how_to_read": "Above the envelope means more pairs than expected; below means fewer.",
+ },
+ "nearest_neighbor_distribution": {
+ "title": "Nearest-neighbor distances",
+ "focus_question": "How far is each point from its closest neighbor?",
+ "before_run": "Computing the closest-neighbor distribution…",
+ "how_to_read": "Small distances indicate close pairs; larger distances indicate separation.",
+ },
+ "ripley_l_function": {
+ "title": "Ripley L",
+ "focus_question": "Does structure accumulate across increasing distance?",
+ "before_run": "Computing cumulative neighbor structure…",
+ "how_to_read": "Above the envelope suggests cumulative clustering; below suggests depletion.",
+ },
+ "cluster_size_counts": {
+ "title": "Cluster sizes",
+ "focus_question": "How many isolated points, pairs, and larger groups exist?",
+ "before_run": "Counting groups against the selected model…",
+ "how_to_read": "More large groups than expected suggests clustering.",
+ },
+ "pair_correlation_g_r_theta": {
+ "title": "Pair distance-angle map",
+ "focus_question": "Do pairs repeat at particular distances and directions?",
+ "before_run": "Computing directional pair density…",
+ "how_to_read": "Repeated angular features suggest preferred neighbor directions.",
+ },
+ "bond_order_psi6": {
+ "title": "ψ6 local order - triangular-like neighborhoods",
+ "focus_question": "Do local neighbors look triangular or hexagonal-like?",
+ "before_run": "Computing sixfold local bond-order values…",
+ "how_to_read": "More particles near |ψ6| = 1 suggests triangular-like local order.",
+ },
+ "bond_order_psi4": {
+ "title": "ψ4 local order - square-like neighborhoods",
+ "focus_question": "Do local neighbors look square-like?",
+ "before_run": "Computing fourfold local bond-order values…",
+ "how_to_read": "More particles near |ψ4| = 1 suggests square-like local order.",
+ },
+ _MODEL_SUMMARY_FOCUS: {
+ "title": "Model verdict summary",
+ "focus_question": "Which model assumptions remain plausible after the comparison?",
+ "before_run": "Comparing verdicts for random, no-overlap, and feature-biased models…",
+ "how_to_read": "Read model verdicts as consistency checks, not mechanism proof.",
+ },
+}
+_SHORT_STAT_READS = {
+ "pair_correlation_g_r": "Orange = observed data; blue = model simulations. Above band = more pairs.",
+ "pair_correlation_g_r_theta": "Radial g(r) collapses direction; this keeps distance and angle.",
+ "bond_order_psi6": "Values near 1 mean strong sixfold-like local angles.",
+ "bond_order_psi4": "Values near 1 mean strong fourfold-like local angles.",
+ "nearest_neighbor_distribution": "Left shift = closer neighbors; right shift = more spacing.",
+ "ripley_l_function": "Above band = accumulated clustering; below band = depletion.",
+ "cluster_size_counts": "More large groups = more clustering.",
+ _MODEL_SUMMARY_FOCUS: "Read consistency by model, not as mechanism proof.",
+}
+_STATISTIC_ANNOTATIONS = {
+ "pair_correlation_g_r_theta": (
+ "r = pair distance; θ = pair direction; colour = relative pair density."
+ ),
+ "bond_order_psi6": (
+ "0 = random-like local angles; 1 = strong sixfold-like local angles."
+ ),
+ "bond_order_psi4": (
+ "0 = random-like local angles; 1 = strong fourfold-like local angles."
+ ),
+}
+_PARTICLE_STATISTICS_LAYOUT_KEY = "particle_statistics"
+
+
+@dataclass(frozen=True)
+class ParticleTutorialStep:
+ title: str
+ body: str = ""
+ question: str = ""
+ look_for: str = ""
+ mode: str = "generated"
+ visible_panel: str = "field"
+ visible_controls: tuple[str, ...] = ()
+ primary_action: str = "Next"
+ action_kind: str = "next"
+ model_label: str = ""
+ statistic_label: str = ""
+ caution: str = ""
+ more_detail: str = ""
+ target_tab: str = "Data"
+ controls: tuple[str, ...] = ()
+ focus_statistic: str = _DEFAULT_FOCUS_STATISTIC
+ focus_curve_mode: str = "comparison"
+ curve_mode: str = ""
+ action_button: str = "next"
+ action_text: str = ""
+ advance_after_run: bool = False
+ compute_on_show: bool = False
+ show_technical_details: bool = False
+ direct_labels: tuple[str, ...] = ()
+ pattern: str | None = None
+ model: str | None = None
+ n: int | None = None
+ seed: int | None = None
+ simulations: int | None = None
+ width_nm: float | None = None
+ height_nm: float | None = None
+ hard_core_radius_nm: float | None = None
+ model_hard_core_radius_nm: float | None = None
+ ordered_island_lattice: str | None = None
+ ordered_island_background: str | None = None
+ show_observed: bool = True
+ show_simulated: bool = True
+ show_features: bool = True
+ show_region: bool = True
+ pool_images: int = 0
+ intro_card: bool = False
+ intro_region: str = "" # which central panel this card introduces: field|plot|info
+ intro_panel_text: str = "" # the one-line label shown in that panel
+ what_changes: str = ""
+ expected_effect: str = ""
+ where_to_check: str = ""
+ why: str = ""
+ statistic_hint: str = ""
+ limitation: str = ""
+
+
+@dataclass(frozen=True)
+class ParticleTutorialExample:
+ key: str
+ title: str
+ summary: str
+ steps: tuple[ParticleTutorialStep, ...]
+
+
+_TUTORIALS: tuple[ParticleTutorialExample, ...] = (
+ ParticleTutorialExample(
+ key="welcome",
+ title="00 - Welcome",
+ summary="What Particle Statistics attempts to solve.",
+ steps=(
+ ParticleTutorialStep(
+ title="Welcome",
+ question="Particle Statistics tests whether particle positions are consistent with simple spatial models.",
+ look_for="Start with the point field: the particles are the data.",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=80,
+ seed=7,
+ simulations=40,
+ show_simulated=False,
+ show_features=False,
+ curve_mode="observed_only",
+ direct_labels=("observed particles",),
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="point_pattern",
+ title="01 - Observed point pattern",
+ summary="Particles become calibrated x,y data.",
+ steps=(
+ ParticleTutorialStep(
+ title="Observed point pattern",
+ question="The image is reduced to calibrated x,y particle positions.",
+ look_for="The observed particles are the only layer shown.",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=7,
+ simulations=60,
+ show_simulated=False,
+ show_features=False,
+ curve_mode="observed_only",
+ direct_labels=("observed particles",),
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="model_baseline_observed",
+ title="02 - Model baseline A",
+ summary="Observed particles only.",
+ steps=(
+ ParticleTutorialStep(
+ title="Observed data",
+ question="These are the observed particle positions. They are the data we want to test.",
+ look_for="Only observed particles are visible.",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=7,
+ simulations=60,
+ show_simulated=False,
+ show_features=False,
+ direct_labels=("observed particles",),
+ primary_action="Show a random model",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="model_baseline_model",
+ title="03 - Model baseline B",
+ summary="One simulated model layout.",
+ steps=(
+ ParticleTutorialStep(
+ title="Model simulation",
+ question="This is one simulated random layout from the model, not the data.",
+ look_for="Only the model simulation is visible.",
+ model_label="Homogeneous Poisson",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=7,
+ simulations=60,
+ show_observed=False,
+ show_simulated=True,
+ show_features=False,
+ direct_labels=("model simulation",),
+ primary_action="Overlay observed particles",
+ more_detail="One simulated layout is not enough for a verdict. It only shows what the model can generate.",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="model_baseline_overlay",
+ title="04 - Model baseline C",
+ summary="Observed particles and one model simulation together.",
+ steps=(
+ ParticleTutorialStep(
+ title="Overlay",
+ question="Observed particles and one model simulation are shown together.",
+ look_for="Visual comparison helps orientation, but it is not the final test.",
+ model_label="Homogeneous Poisson",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=7,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ direct_labels=("observed data", "model simulation"),
+ primary_action="Show the statistic",
+ more_detail="Our eye may see patterns, but the statistical curve compares the data to many model simulations.",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="image_to_statistic",
+ title="05 - From image to statistic",
+ summary="Visual comparison is not enough.",
+ steps=(
+ ParticleTutorialStep(
+ title="From image to statistic",
+ question="The spatial view is only the starting point.",
+ look_for="Pair correlation turns positions into a measurable comparison.",
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair correlation g(r)",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=7,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="pair_correlation_g_r",
+ curve_mode="observed_only",
+ compute_on_show=True,
+ primary_action="Show model band",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="simulation_envelope",
+ title="06 - Simulation envelope",
+ summary="Orange observed statistic versus blue model band.",
+ steps=(
+ ParticleTutorialStep(
+ title="Simulation envelope",
+ question="Many model layouts form the expected blue band.",
+ look_for="Orange is observed data; blue is the model envelope.",
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair correlation g(r)",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=7,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="pair_correlation_g_r",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Read the verdict",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="verdict",
+ title="07 - Verdict language",
+ summary="Consistent or inconsistent with this model.",
+ steps=(
+ ParticleTutorialStep(
+ title="Verdict language",
+ question="Inside the band means consistent with this model.",
+ look_for="Leaving the band means this statistic rules out the model.",
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair correlation g(r)",
+ visible_panel="results",
+ target_tab="Results",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=7,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic=_MODEL_SUMMARY_FOCUS,
+ compute_on_show=True,
+ primary_action="Explain Poisson model",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="homogeneous_poisson",
+ title="08 - Homogeneous Poisson",
+ summary="Random placement with one average density.",
+ steps=(
+ ParticleTutorialStep(
+ title="Homogeneous Poisson",
+ question="Homogeneous Poisson means independent random placement with one average density.",
+ look_for="If appropriate, g(r) fluctuates around 1 and stays inside the band.",
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair correlation g(r)",
+ caution="Not inconsistent with this model does not prove there is no interaction.",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=7,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="pair_correlation_g_r",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Try clustered pattern",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="clustered",
+ title="09 - Clustered pattern",
+ summary="Too many close pairs.",
+ steps=(
+ ParticleTutorialStep(
+ title="Clustered pattern",
+ question="Clustered points create too many close particle pairs.",
+ look_for="A small-distance peak rises above the random-placement band.",
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair correlation g(r)",
+ caution="This shows spatial clustering, not the physical cause.",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="clustered",
+ model="homogeneous_poisson",
+ n=120,
+ seed=8,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="pair_correlation_g_r",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Try ordered islands",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="hard_core_meaning",
+ title="10 - Hard-core model meaning",
+ summary="Minimum separation and no direct overlap.",
+ steps=(
+ ParticleTutorialStep(
+ title="Hard-core meaning",
+ question="A hard-core radius sets a minimum allowed separation.",
+ look_for="Think average particle or molecule size: particles cannot overlap.",
+ model_label="Hard-core random",
+ visible_panel="controls",
+ visible_controls=("model_hard_core_radius",),
+ pattern="no_overlap",
+ model="hard_core_random",
+ n=100,
+ seed=9,
+ simulations=40,
+ hard_core_radius_nm=1.5,
+ model_hard_core_radius_nm=1.5,
+ show_observed=False,
+ show_simulated=True,
+ show_features=False,
+ direct_labels=("model simulation", "minimum separation"),
+ primary_action="Try radius",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="hard_core_parameters",
+ title="11 - Hard-core parameter sandbox",
+ summary="Change radius and particle number; generate points.",
+ steps=(
+ ParticleTutorialStep(
+ title="Try radius",
+ question="Increase the hard-core radius to strengthen exclusion.",
+ look_for="Larger radius removes more very close neighbours.",
+ model_label="Hard-core random",
+ visible_panel="controls",
+ visible_controls=("model_hard_core_radius",),
+ pattern="no_overlap",
+ model="hard_core_random",
+ n=100,
+ seed=9,
+ simulations=40,
+ hard_core_radius_nm=3.0,
+ model_hard_core_radius_nm=3.0,
+ show_observed=False,
+ show_simulated=True,
+ show_features=False,
+ primary_action="Generate points",
+ action_kind="run",
+ caution="Large N or hard-core radius can be slow because overlapping placements are rejected.",
+ direct_labels=("model simulation", "larger radius"),
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="hard_core_statistic",
+ title="12 - Hard-core statistic",
+ summary="Nearest-neighbor distances lose very short separations.",
+ steps=(
+ ParticleTutorialStep(
+ title="Nearest neighbors",
+ question="The nearest-neighbor plot should lose very short distances.",
+ look_for="Close neighbours are forbidden, so the left side is depleted.",
+ model_label="Hard-core random",
+ statistic_label="Nearest-neighbor distance",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="no_overlap",
+ model="hard_core_random",
+ n=100,
+ seed=9,
+ simulations=60,
+ hard_core_radius_nm=3.0,
+ model_hard_core_radius_nm=3.0,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="nearest_neighbor_distribution",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Try local order",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="ordered_cluster_vs_order",
+ title="13 - Order A",
+ summary="Clustering is not the same as local order.",
+ steps=(
+ ParticleTutorialStep(
+ title="Clustering is not order",
+ question="Clustering means particles are near each other; order means neighbor positions repeat.",
+ look_for="The island has a regular internal pattern, not just a dense group.",
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair correlation g(r)",
+ caution="This suggests local order; it does not prove a crystal or mechanism.",
+ visible_panel="field",
+ target_tab="Setup",
+ pattern="ordered_islands",
+ ordered_island_lattice="triangular",
+ ordered_island_background="none",
+ model="homogeneous_poisson",
+ n=120,
+ seed=12,
+ simulations=60,
+ show_simulated=False,
+ show_features=False,
+ visible_controls=("ordered_lattice",),
+ direct_labels=("ordered islands",),
+ primary_action="Show radial spacing",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="ordered_radial_spacing",
+ title="14 - Order B",
+ summary="Radial g(r) shows spacing but removes direction.",
+ steps=(
+ ParticleTutorialStep(
+ title="Radial distances",
+ question="Radial g(r) shows preferred neighbor distances, but removes direction.",
+ look_for="Sharp peaks suggest repeated spacings, not full 2D order.",
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair correlation g(r)",
+ caution="Peaks in g(r) are useful, but they do not identify island symmetry.",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="ordered_islands",
+ ordered_island_lattice="triangular",
+ ordered_island_background="none",
+ model="homogeneous_poisson",
+ n=120,
+ seed=12,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="pair_correlation_g_r",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Show directions",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="ordered_directional_pairs",
+ title="15 - Order C",
+ summary="Directional pair density keeps angle.",
+ steps=(
+ ParticleTutorialStep(
+ title="Distance and angle",
+ question="The pair distance-angle map keeps distance and direction together.",
+ look_for="Repeated angular features appear at preferred neighbor distances.",
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair distance-angle map",
+ caution="Read directional structure against the matched null, not by eye alone.",
+ more_detail="r is pair distance; theta is pair direction; colour is relative pair density.",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="ordered_islands",
+ ordered_island_lattice="triangular",
+ ordered_island_background="none",
+ model="homogeneous_poisson",
+ n=120,
+ seed=12,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="pair_correlation_g_r_theta",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Show ψ6",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="ordered_bond_order",
+ title="16 - Order D",
+ summary="ψ6 asks whether each particle has triangular-like neighbors.",
+ steps=(
+ ParticleTutorialStep(
+ title="ψ6 local order",
+ question="ψ6 measures sixfold-like local order around each particle.",
+ look_for="Values near 1 mean strong triangular-like neighbor geometry.",
+ model_label="Homogeneous Poisson",
+ statistic_label="ψ6 triangular order",
+ caution="The value depends on the neighbor cutoff and detection quality.",
+ more_detail="Nearby neighbor angles are checked for 60 degree repetition. Random patterns stay lower; triangular islands shift toward 1.",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="ordered_islands",
+ ordered_island_lattice="triangular",
+ ordered_island_background="none",
+ model="homogeneous_poisson",
+ n=120,
+ seed=12,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="bond_order_psi6",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Show ψ4",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="ordered_square_order",
+ title="17 - Order E",
+ summary="ψ4 is the square-like local-order check.",
+ steps=(
+ ParticleTutorialStep(
+ title="ψ4 local order",
+ question="ψ4 measures square-like local order around each particle.",
+ look_for="Square islands shift ψ4 toward 1 more than ψ6.",
+ model_label="Homogeneous Poisson",
+ statistic_label="ψ4 square order",
+ caution="Choose the symmetry that matches the structure you want to test.",
+ more_detail="Use ψ6 for triangular-like islands and ψ4 for square-like islands. This suggests local angular order; it is not a lattice fit.",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="ordered_islands",
+ ordered_island_lattice="square",
+ ordered_island_background="none",
+ model="homogeneous_poisson",
+ n=120,
+ seed=14,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="bond_order_psi4",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Mix in disorder",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="ordered_mixed",
+ title="18 - Order F",
+ summary="Mixed ordered and disordered regions need local metrics.",
+ steps=(
+ ParticleTutorialStep(
+ title="Mixed islands",
+ question="Real images can mix ordered islands, disordered clusters, and isolated particles.",
+ look_for="Local order can remain visible even when the field is mixed.",
+ model_label="Homogeneous Poisson",
+ statistic_label="ψ6 triangular order",
+ caution="A single global metric can hide local structure; inspect the image too.",
+ more_detail="Global plots average over the whole field. Local metrics help flag ordered regions inside mixed images.",
+ visible_panel="plot",
+ visible_controls=("ordered_background",),
+ target_tab="Setup",
+ pattern="ordered_islands",
+ ordered_island_lattice="triangular",
+ ordered_island_background="clustered",
+ model="homogeneous_poisson",
+ n=120,
+ seed=13,
+ simulations=60,
+ show_simulated=True,
+ show_features=False,
+ focus_statistic="bond_order_psi6",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Try feature-biased model",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="feature_biased",
+ title="19 - Feature-biased model",
+ summary="Association with an independently measured feature layer.",
+ steps=(
+ ParticleTutorialStep(
+ title="Feature layer",
+ question="Feature-biased models test association with an independent feature layer.",
+ look_for="Inspect the feature layer before adding particles.",
+ model_label="Measured-feature Poisson",
+ caution="The feature layer must be independent of the particles being tested.",
+ more_detail="Reusing particles as their own feature layer makes the result circular.",
+ pattern="feature_biased",
+ model="measured_feature_poisson",
+ n=120,
+ seed=10,
+ simulations=60,
+ show_observed=False,
+ show_simulated=False,
+ show_features=True,
+ visible_controls=("layer_features",),
+ direct_labels=("feature layer",),
+ primary_action="Add particles",
+ ),
+ ParticleTutorialStep(
+ title="Particles and features",
+ question="Do particles sit preferentially near the independent features?",
+ look_for="Compare orange particles with the feature layer.",
+ model_label="Measured-feature Poisson",
+ caution="If the same detections define both layers, the test is circular.",
+ pattern="feature_biased",
+ model="measured_feature_poisson",
+ n=120,
+ seed=10,
+ simulations=60,
+ show_simulated=False,
+ show_features=True,
+ visible_controls=("layer_observed",),
+ direct_labels=("observed particles", "feature layer"),
+ primary_action="Read feature verdict",
+ ),
+ ParticleTutorialStep(
+ title="Feature-biased verdict",
+ question="Which model assumption stays consistent for these particles?",
+ look_for="Read the model verdict cards, not just the picture.",
+ model_label="Measured-feature Poisson",
+ statistic_label="Model verdict summary",
+ caution="Independent feature measurement is required.",
+ visible_panel="results",
+ target_tab="Results",
+ pattern="feature_biased",
+ model="measured_feature_poisson",
+ n=120,
+ seed=10,
+ simulations=60,
+ show_simulated=True,
+ show_features=True,
+ focus_statistic=_MODEL_SUMMARY_FOCUS,
+ compute_on_show=True,
+ primary_action="Review statistics",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="other_statistics",
+ title="20 - Statistics reference",
+ summary="Four statistics answer different spatial questions.",
+ steps=(
+ ParticleTutorialStep(
+ title="Nearest neighbors",
+ question="How far is each particle from its closest neighbor?",
+ look_for="Use this for overlap, exclusion, or close aggregation.",
+ statistic_label="Nearest-neighbor distance",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="no_overlap",
+ model="hard_core_random",
+ n=100,
+ seed=9,
+ simulations=60,
+ hard_core_radius_nm=3.0,
+ model_hard_core_radius_nm=3.0,
+ focus_statistic="nearest_neighbor_distribution",
+ curve_mode="comparison",
+ compute_on_show=True,
+ ),
+ ParticleTutorialStep(
+ title="Ripley L",
+ question="Does structure accumulate over increasing length scale?",
+ look_for="Use Ripley L to see whether structure persists.",
+ statistic_label="Ripley L",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="clustered",
+ model="homogeneous_poisson",
+ n=120,
+ seed=8,
+ simulations=60,
+ focus_statistic="ripley_l_function",
+ curve_mode="comparison",
+ compute_on_show=True,
+ ),
+ ParticleTutorialStep(
+ title="Cluster sizes",
+ question="How many isolated particles, pairs, and larger groups exist?",
+ look_for="Cluster sizes depend on the chosen linking distance.",
+ statistic_label="Cluster-size distribution",
+ caution="Document the linking distance when reporting cluster sizes.",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="clustered",
+ model="homogeneous_poisson",
+ n=120,
+ seed=8,
+ simulations=60,
+ focus_statistic="cluster_size_counts",
+ curve_mode="comparison",
+ compute_on_show=True,
+ primary_action="Learn pooling",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="pooling_single",
+ title="21 - Pooling A",
+ summary="One generated image gives one noisy statistic.",
+ steps=(
+ ParticleTutorialStep(
+ title="Single image",
+ question="One image gives one noisy estimate of the statistic.",
+ look_for="The curve is jagged and the model envelope is wide.",
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair correlation g(r)",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=11,
+ simulations=40,
+ focus_statistic="pair_correlation_g_r",
+ curve_mode="comparison",
+ compute_on_show=True,
+ direct_labels=("single image",),
+ primary_action="Add second image",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="pooling_two",
+ title="22 - Pooling B",
+ summary="Pool two independent generated images.",
+ steps=(
+ ParticleTutorialStep(
+ title="Two-image pool",
+ question="Now pool two independent images from the same condition.",
+ look_for=(
+ "Blue is pooled mean/spread; orange is one-image reference."
+ ),
+ model_label="Homogeneous Poisson",
+ statistic_label="Pair correlation g(r)",
+ caution="Pool only independent images from the same experimental condition.",
+ visible_panel="plot",
+ target_tab="Setup",
+ pattern="random",
+ model="homogeneous_poisson",
+ n=120,
+ seed=11,
+ simulations=30,
+ pool_images=2,
+ focus_statistic="pair_correlation_g_r",
+ curve_mode="comparison",
+ compute_on_show=True,
+ direct_labels=("pooled: 2 images",),
+ primary_action="Compare one vs two",
+ more_detail=(
+ "The orange reference is one generated image. The blue pooled "
+ "curve uses two independent images; its band shows image-to-image "
+ "spread, not a model envelope."
+ ),
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="model_simulations_sandbox",
+ title="23 - Model simulations sandbox",
+ summary="Explore model parameters outside the linear tutorial.",
+ steps=(
+ ParticleTutorialStep(
+ title="Model simulations",
+ question="Use Model simulations to ask what each model can generate.",
+ look_for="Change model, N, seed, field size, radius, then generate points.",
+ mode="sandbox",
+ visible_panel="controls",
+ visible_controls=("generated_model",),
+ target_tab="Setup",
+ pattern="no_overlap",
+ model="hard_core_random",
+ n=100,
+ seed=9,
+ simulations=40,
+ hard_core_radius_nm=3.0,
+ model_hard_core_radius_nm=3.0,
+ show_simulated=True,
+ show_features=False,
+ primary_action="Move to real data",
+ more_detail="The sandbox uses the same point field, layer controls, statistic plot, and results view as real analysis.",
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="real_workflow",
+ title="24 - Real ProbeFlow workflow",
+ summary="Feature Finder to Particle Statistics to verdict.",
+ steps=(
+ ParticleTutorialStep(
+ title="Real workflow",
+ question="Real analysis begins with Feature Finder positions.",
+ look_for="Use saved feature sets, then choose region, model, statistic, simulations, and verdicts.",
+ mode="real",
+ model_label="Choose model from real controls",
+ visible_panel="controls",
+ visible_controls=("feature_sets",),
+ target_tab="Setup",
+ show_simulated=False,
+ show_features=False,
+ primary_action="Final caution",
+ more_detail="Workflow: detect particles, send them to Particle Statistics, confirm calibration and region, choose a model and statistic, run simulations, read verdicts, and pool comparable images.",
+ caution=(
+ "Pooling is only valid for independent images from the same "
+ "experimental condition. Pixel-level point ROIs also limit the "
+ "smallest meaningful distance bin."
+ ),
+ ),
+ ),
+ ),
+ ParticleTutorialExample(
+ key="final_caution",
+ title="25 - Final caution",
+ summary="Statistical verdicts need physical interpretation.",
+ steps=(
+ ParticleTutorialStep(
+ title="Final caution",
+ question="Particle Statistics can rule out simple spatial models, not prove a mechanism.",
+ look_for="Use verdicts as evidence, then interpret with the experiment.",
+ mode="real",
+ visible_panel="results",
+ target_tab="Results",
+ focus_statistic=_MODEL_SUMMARY_FOCUS,
+ primary_action="Restart tutorial",
+ action_kind="restart",
+ caution="Physical mechanism requires experimental interpretation.",
+ ),
+ ),
+ ),
+)
+
+
+@dataclass(frozen=True)
+class ParticleFieldModel:
+ """Display-only point field for the Particle Statistics window."""
+
+ observed_xy_nm: np.ndarray
+ width_nm: float
+ height_nm: float
+ mode: str = "real"
+ source_label: str = ""
+ region_label: str = "Full field"
+ model_label: str = ""
+ status: str = ""
+ mask: np.ndarray | None = None
+ simulated_xy_nm: np.ndarray | None = None
+ feature_xy_nm: np.ndarray | None = None
+ show_observed: bool = True
+ show_simulated: bool = True
+ show_features: bool = True
+ show_region: bool = True
+ direct_labels: tuple[str, ...] = ()
+
+
+class ParticleFieldView(QWidget):
+ """Qt-native field renderer for real and generated particle patterns."""
+
+ def __init__(self, *, theme: dict | None = None, parent=None):
+ super().__init__(parent)
+ self.setObjectName("particleStatisticsField")
+ self.setMinimumSize(440, 300)
+ self._theme = theme or {}
+ self._model = ParticleFieldModel(
+ observed_xy_nm=np.empty((0, 2), dtype=float),
+ width_nm=100.0,
+ height_nm=100.0,
+ status="No points to display.",
+ )
+
+ @property
+ def point_count(self) -> int:
+ return int(len(self._model.observed_xy_nm))
+
+ @property
+ def data_mode(self) -> str:
+ return self._model.mode
+
+ @property
+ def marker_style(self) -> dict[str, str]:
+ return _marker_style(self._model.mode)
+
+ @property
+ def layer_visibility(self) -> dict[str, bool]:
+ model = self._model
+ return {
+ "observed": bool(model.show_observed),
+ "simulated": bool(model.show_simulated),
+ "features": bool(model.show_features),
+ "region": bool(model.show_region),
+ }
+
+ @property
+ def layer_availability(self) -> dict[str, bool]:
+ model = self._model
+ return {
+ "observed": bool(len(model.observed_xy_nm)),
+ "simulated": bool(model.simulated_xy_nm is not None and len(model.simulated_xy_nm)),
+ "features": bool(model.feature_xy_nm is not None and len(model.feature_xy_nm)),
+ "region": bool(model.mask is not None and model.mask.size),
+ }
+
+ @property
+ def direct_labels(self) -> tuple[str, ...]:
+ return tuple(self._model.direct_labels)
+
+ def set_field_model(self, model: ParticleFieldModel) -> None:
+ self._model = model
+ self.update()
+
+ def set_layer_visibility(
+ self,
+ *,
+ observed: bool | None = None,
+ simulated: bool | None = None,
+ features: bool | None = None,
+ region: bool | None = None,
+ ) -> None:
+ model = self._model
+ self.set_field_model(
+ replace(
+ model,
+ show_observed=model.show_observed if observed is None else bool(observed),
+ show_simulated=model.show_simulated if simulated is None else bool(simulated),
+ show_features=model.show_features if features is None else bool(features),
+ show_region=model.show_region if region is None else bool(region),
+ )
+ )
+
+ def set_direct_labels(self, labels: tuple[str, ...] | list[str]) -> None:
+ model = self._model
+ self.set_field_model(
+ replace(model, direct_labels=tuple(str(label) for label in labels if label))
+ )
+
+ def set_points(
+ self,
+ observed_xy_nm: Any,
+ *,
+ field_size_nm: tuple[float, float],
+ mode: str = "real",
+ source_label: str = "",
+ region_label: str = "Full field",
+ model_label: str = "",
+ status: str = "",
+ mask: Any = None,
+ simulated_xy_nm: Any = None,
+ feature_xy_nm: Any = None,
+ direct_labels: tuple[str, ...] = (),
+ ) -> None:
+ self.set_field_model(
+ ParticleFieldModel(
+ observed_xy_nm=_xy_array(observed_xy_nm),
+ width_nm=float(field_size_nm[0]),
+ height_nm=float(field_size_nm[1]),
+ mode=_normalise_field_mode(mode),
+ source_label=str(source_label or ""),
+ region_label=str(region_label or "Full field"),
+ model_label=str(model_label or ""),
+ status=str(status or ""),
+ mask=_mask_or_none(mask),
+ simulated_xy_nm=_xy_array_or_none(simulated_xy_nm),
+ feature_xy_nm=_xy_array_or_none(feature_xy_nm),
+ direct_labels=tuple(str(label) for label in direct_labels if label),
+ )
+ )
+
+ def set_region(self, *, region_label: str, mask: Any = None) -> None:
+ model = self._model
+ self.set_field_model(
+ replace(
+ model,
+ region_label=str(region_label or "Full field"),
+ mask=_mask_or_none(mask),
+ )
+ )
+
+ def set_mode(self, mode: str) -> None:
+ model = self._model
+ self.set_field_model(
+ replace(model, mode=_normalise_field_mode(mode))
+ )
+
+ def paintEvent(self, event) -> None: # noqa: N802 - Qt override
+ super().paintEvent(event)
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ bg = _theme_qcolor(self._theme, ("figure.facecolor", "surface", "bg"), "#161a20")
+ fg = _theme_qcolor(self._theme, ("text.color", "fg"), "#e8edf4")
+ border = _theme_qcolor(self._theme, ("border", "sep"), "#3b4250")
+ painter.fillRect(self.rect(), bg)
+
+ plot_rect = self._plot_rect()
+ if plot_rect.width() < 40 or plot_rect.height() < 40:
+ return
+
+ model = self._model
+ painter.save()
+ painter.setPen(QPen(border, 1.0))
+ painter.setBrush(QColor("#101419"))
+ painter.drawRect(plot_rect)
+ if model.show_region:
+ self._draw_mask(painter, plot_rect)
+ painter.restore()
+
+ transform = _FieldTransform(plot_rect, model.width_nm, model.height_nm)
+ plot_w = plot_rect.width()
+ painter.save()
+ painter.setClipRect(plot_rect.adjusted(1, 1, -1, -1))
+ visible_layers = 0
+ if model.mode == "generated" and model.show_simulated and model.simulated_xy_nm is not None:
+ visible_layers += int(len(model.simulated_xy_nm) > 0)
+ _draw_marker_series(
+ painter,
+ transform,
+ model.simulated_xy_nm,
+ marker="o",
+ color="#b96adf",
+ radius=_field_marker_radius(plot_w, len(model.simulated_xy_nm)),
+ hollow=True,
+ )
+ if model.mode == "generated" and model.show_features and model.feature_xy_nm is not None:
+ visible_layers += int(len(model.feature_xy_nm) > 0)
+ _draw_marker_series(
+ painter,
+ transform,
+ model.feature_xy_nm,
+ marker="x",
+ color="#9b5de5",
+ radius=max(4.0, _field_marker_radius(plot_w, len(model.feature_xy_nm))),
+ )
+ if model.show_observed:
+ visible_layers += int(len(model.observed_xy_nm) > 0)
+ style = _marker_style(model.mode)
+ _draw_marker_series(
+ painter,
+ transform,
+ model.observed_xy_nm,
+ marker=style["marker"],
+ color=style["color"],
+ radius=_field_marker_radius(plot_w, len(model.observed_xy_nm)),
+ )
+ painter.restore()
+
+ self._draw_chrome(painter, plot_rect, fg, border)
+ self._draw_labels(painter, fg)
+ self._draw_legend(painter, plot_rect, fg)
+ self._draw_direct_labels(painter, plot_rect, fg)
+ if visible_layers == 0 and model.status:
+ painter.save()
+ painter.setPen(fg)
+ font = QFont(painter.font())
+ font.setPointSize(10)
+ painter.setFont(font)
+ painter.drawText(plot_rect.adjusted(20, 20, -20, -20), Qt.AlignCenter | Qt.TextWordWrap, model.status)
+ painter.restore()
+
+ def _plot_rect(self) -> QRectF:
+ margin_l, margin_t, margin_r, margin_b = 68.0, 52.0, 28.0, 86.0
+ available = QRectF(
+ margin_l,
+ margin_t,
+ max(1.0, self.width() - margin_l - margin_r),
+ max(1.0, self.height() - margin_t - margin_b),
+ )
+ return _aspect_fit_rect(available, self._model.width_nm, self._model.height_nm)
+
+ def _draw_mask(self, painter: QPainter, plot_rect: QRectF) -> None:
+ mask = self._model.mask
+ if mask is None or mask.size == 0:
+ return
+ rows, cols = mask.shape
+ step_y = max(1, rows // 96)
+ step_x = max(1, cols // 96)
+ painter.setPen(Qt.NoPen)
+ painter.setBrush(QColor(0, 229, 255, 42))
+ for y in range(0, rows, step_y):
+ y1 = min(rows, y + step_y)
+ for x in range(0, cols, step_x):
+ x1 = min(cols, x + step_x)
+ if not bool(mask[y:y1, x:x1].any()):
+ continue
+ rx = plot_rect.left() + (x / cols) * plot_rect.width()
+ ry = plot_rect.top() + (y / rows) * plot_rect.height()
+ rw = ((x1 - x) / cols) * plot_rect.width()
+ rh = ((y1 - y) / rows) * plot_rect.height()
+ painter.drawRect(QRectF(rx, ry, max(1.0, rw), max(1.0, rh)))
+
+ def _draw_chrome(
+ self,
+ painter: QPainter,
+ plot_rect: QRectF,
+ fg: QColor,
+ border: QColor,
+ ) -> None:
+ model = self._model
+ painter.save()
+ painter.setPen(QPen(border, 1.0))
+ painter.drawRect(plot_rect)
+ painter.setPen(QPen(fg, 1.0))
+ font = QFont(painter.font())
+ font.setPointSize(10)
+ painter.setFont(font)
+ painter.drawText(
+ QRectF(plot_rect.left(), plot_rect.bottom() + 8, plot_rect.width(), 22),
+ Qt.AlignCenter,
+ f"x: 0 to {model.width_nm:g} nm",
+ )
+ painter.save()
+ painter.translate(20, plot_rect.center().y())
+ painter.rotate(-90)
+ painter.drawText(QRectF(-plot_rect.height() / 2, 0, plot_rect.height(), 22), Qt.AlignCenter, f"y: 0 to {model.height_nm:g} nm")
+ painter.restore()
+ painter.restore()
+
+ def _draw_labels(self, painter: QPainter, fg: QColor) -> None:
+ model = self._model
+ painter.save()
+ painter.setPen(fg)
+ title_font = QFont(painter.font())
+ title_font.setPointSize(13)
+ title_font.setBold(True)
+ painter.setFont(title_font)
+ title = "Generated particle field" if model.mode == "generated" else "Observed particle field"
+ painter.drawText(QRectF(14, 8, self.width() - 28, 26), Qt.AlignLeft, title)
+ body_font = QFont(painter.font())
+ body_font.setPointSize(10)
+ body_font.setBold(False)
+ painter.setFont(body_font)
+ detail = " ".join(
+ part
+ for part in (
+ model.source_label,
+ model.region_label,
+ model.model_label,
+ f"N={len(model.observed_xy_nm)}",
+ )
+ if part
+ )
+ painter.drawText(QRectF(14, 32, self.width() - 28, 22), Qt.AlignLeft, detail)
+ painter.restore()
+
+ def _draw_legend(self, painter: QPainter, plot_rect: QRectF, fg: QColor) -> None:
+ model = self._model
+ legend = []
+ if model.show_observed:
+ legend.append(("observed", _marker_style(model.mode)["color"], _marker_style(model.mode)["marker"], False))
+ if model.mode == "generated" and model.show_simulated and model.simulated_xy_nm is not None:
+ legend.append(("model sample", "#b96adf", "o", True))
+ if model.mode == "generated" and model.show_features and model.feature_xy_nm is not None:
+ legend.append(("feature layer", "#9b5de5", "x", False))
+ if not legend:
+ return
+ # Horizontal strip in the bottom margin (below the x-axis label) so the legend
+ # never sits on top of the points.
+ painter.save()
+ font = QFont(painter.font())
+ font.setPointSize(10)
+ painter.setFont(font)
+ metrics = painter.fontMetrics()
+ gap = 18.0
+ sample_w = 20.0
+ items = [(label, color, marker, hollow, float(metrics.horizontalAdvance(label))) for label, color, marker, hollow in legend]
+ total = sum(sample_w + tw for *_unused, tw in items) + gap * (len(items) - 1)
+ y = plot_rect.bottom() + 40.0
+ x = max(plot_rect.left(), plot_rect.center().x() - total / 2.0)
+ for label, color, marker, hollow, text_w in items:
+ _draw_marker(painter, QPointF(x + 7, y), marker, QColor(color), 4.4, hollow=hollow)
+ painter.setPen(QPen(fg))
+ painter.drawText(QRectF(x + sample_w, y - 9.0, text_w + 4.0, 18.0), Qt.AlignLeft | Qt.AlignVCenter, label)
+ x += sample_w + text_w + gap
+ painter.restore()
+
+ def _draw_direct_labels(self, painter: QPainter, plot_rect: QRectF, fg: QColor) -> None:
+ labels = tuple(self._model.direct_labels)
+ if not labels:
+ return
+ painter.save()
+ font = QFont(painter.font())
+ font.setPointSize(10)
+ font.setBold(True)
+ painter.setFont(font)
+ metrics = painter.fontMetrics()
+ x = plot_rect.left() + 10.0
+ y = plot_rect.top() + 10.0
+ for label in labels[:4]:
+ text_w = float(metrics.horizontalAdvance(label))
+ box = QRectF(x, y, text_w + 18.0, 24.0)
+ painter.setPen(QPen(QColor("#2fb344"), 1.2))
+ painter.setBrush(QColor(47, 179, 68, 52))
+ painter.drawRoundedRect(box, 4.0, 4.0)
+ painter.setPen(QPen(fg))
+ painter.drawText(box.adjusted(9, 0, -9, 0), Qt.AlignLeft | Qt.AlignVCenter, label)
+ y += 28.0
+ painter.restore()
+
+
+class FocusedStatisticPanel(QFrame):
+ """Large teaching panel for the currently focused statistic."""
+
+ def __init__(self, *, theme: dict | None = None, parent=None):
+ super().__init__(parent)
+ self.setObjectName("particleStatisticsFocusedStatistic")
+ self.setFrameShape(QFrame.StyledPanel)
+ self.setMinimumSize(440, 300)
+ self._theme = theme or {}
+ self._statistic_id = _DEFAULT_FOCUS_STATISTIC
+ self._has_plot = False
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(8, 8, 8, 8)
+ layout.setSpacing(4)
+
+ self._title = QLabel("", self)
+ self._title.setObjectName("particleStatisticsFocusTitle")
+ self._title.setStyleSheet("font-weight: 700;")
+ self._title.setWordWrap(True)
+ layout.addWidget(self._title)
+
+ self._body = QLabel("", self)
+ self._body.setObjectName("particleStatisticsFocusBody")
+ self._body.setWordWrap(True)
+ self._body.setMaximumHeight(54)
+ self._body.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ layout.addWidget(self._body)
+
+ self._annotation = QLabel("", self)
+ self._annotation.setObjectName("particleStatisticsFocusAnnotation")
+ self._annotation.setWordWrap(True)
+ self._annotation.setStyleSheet(
+ "color: #9ecbff; border: 1px solid rgba(47, 129, 247, 0.45); "
+ "padding: 3px 6px;"
+ )
+ self._annotation.setVisible(False)
+ layout.addWidget(self._annotation)
+
+ self._plot_host = QWidget(self)
+ self._plot_layout = QVBoxLayout(self._plot_host)
+ self._plot_layout.setContentsMargins(0, 0, 0, 0)
+ self._plot_layout.setSpacing(0)
+ layout.addWidget(self._plot_host, 1)
+
+ @property
+ def focused_statistic(self) -> str:
+ return self._statistic_id
+
+ @property
+ def has_plot(self) -> bool:
+ return self._has_plot
+
+ def set_statistic(
+ self,
+ statistic_id: str,
+ *,
+ panel: Any = None,
+ data_mode: str = "real",
+ curve_mode: str = "comparison",
+ has_result: bool = False,
+ empty_message: str | None = None,
+ show_observed_curve: bool = True,
+ show_model_curves: bool = True,
+ ) -> None:
+ self._statistic_id = str(statistic_id or _DEFAULT_FOCUS_STATISTIC)
+ self._clear_plot()
+ self._has_plot = panel is not None
+ title = _display_statistic_title(self._statistic_id)
+ question = _guide_text(self._statistic_id, "focus_question")
+ self._title.setText(title)
+ annotation = _plot_annotation_text(self._statistic_id)
+ self._annotation.setText(annotation)
+ self._annotation.setVisible(bool(annotation and panel is not None))
+ if panel is not None:
+ if str(getattr(panel, "kind", "")) == "series_curve":
+ quick_read = _series_focus_read_text(panel)
+ else:
+ quick_read = _focus_read_text(self._statistic_id, curve_mode)
+ body = f"{question} {quick_read}"
+ self._body.setText(body)
+ plot = AdStatPlotWidget(
+ panel,
+ theme=self._theme,
+ data_mode=data_mode,
+ curve_mode=curve_mode,
+ show_observed_curve=show_observed_curve,
+ show_model_curves=show_model_curves,
+ parent=self._plot_host,
+ )
+ plot.setMinimumHeight(240)
+ self._plot_layout.addWidget(plot, 1)
+ return
+
+ if self._statistic_id == _MODEL_SUMMARY_FOCUS and has_result:
+ message = "Read the grouped model verdict cards in Results. The focus here is which model assumption stayed plausible."
+ elif has_result:
+ message = "This result does not include this panel. Choose another statistic card or rerun with a supported model."
+ elif empty_message:
+ message = empty_message
+ elif data_mode == "sandbox":
+ message = "Computing the statistic…"
+ else:
+ message = "Run a comparison to plot this statistic against the model envelope."
+ self._body.setText(
+ f"{question} {_guide_text(self._statistic_id, 'before_run')}"
+ )
+ placeholder = QLabel(message, self._plot_host)
+ placeholder.setObjectName("particleStatisticsFocusPlaceholder")
+ placeholder.setAlignment(Qt.AlignCenter)
+ placeholder.setWordWrap(True)
+ placeholder.setStyleSheet("border: 1px solid #3b4250; padding: 12px;")
+ self._plot_layout.addWidget(placeholder, 1)
+
+ def _clear_plot(self) -> None:
+ while self._plot_layout.count():
+ item = self._plot_layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+
+
+class _ParticleRealWorkerSignals(QObject):
+ finished = Signal(int, object)
+
+
+class _ParticleRealWorker(_PooledWorker):
+ def __init__(
+ self,
+ *,
+ generation: int,
+ point_sources: list[Any],
+ scan: Any,
+ image_shape: tuple[int, int] | None,
+ request: AdStatStatisticsRequest,
+ ):
+ super().__init__(_ParticleRealWorkerSignals())
+ self._generation = int(generation)
+ self._point_sources = list(point_sources)
+ self._scan = scan
+ self._image_shape = image_shape
+ self._request = request
+
+ def work(self) -> None:
+ context = adstat_workbench_launch_context(
+ self._point_sources,
+ scan=self._scan,
+ image_shape=self._image_shape,
+ request=self._request,
+ )
+ self.signals.finished.emit(self._generation, context)
+
+
+class _ParticleFeatureSetWorkerSignals(QObject):
+ finished = Signal(int, object, str)
+
+
+class _ParticleFeatureSetWorker(_PooledWorker):
+ """Run a single-set or pooled multi-set comparison from saved feature sets."""
+
+ def __init__(
+ self,
+ *,
+ generation: int,
+ feature_sets: list[Any],
+ request: AdStatStatisticsRequest,
+ feature_layer: Any = None,
+ ):
+ super().__init__(_ParticleFeatureSetWorkerSignals())
+ self._generation = int(generation)
+ self._feature_sets = list(feature_sets)
+ self._request = request
+ self._feature_layer = feature_layer
+
+ def work(self) -> None:
+ from probeflow.analysis.adstat_adapter import (
+ compare_point_set_record_view_spec,
+ compare_point_set_records_view_spec,
+ )
+
+ try:
+ records = [fs.to_point_set_record() for fs in self._feature_sets]
+ models = self._request.models or ("poisson",)
+ feature_layers = (
+ [self._feature_layer.to_feature_layer()]
+ if self._feature_layer is not None
+ else ()
+ )
+ if len(records) == 1:
+ spec = compare_point_set_record_view_spec(
+ records[0],
+ models=models,
+ feature_layers=feature_layers,
+ n_simulations=self._request.n_simulations,
+ random_seed=self._request.random_seed,
+ include_ordering=self._request.include_ordering,
+ )
+ else:
+ spec = compare_point_set_records_view_spec(
+ records,
+ models=models,
+ n_simulations=self._request.n_simulations,
+ random_seed=self._request.random_seed,
+ )
+ except Exception as exc: # noqa: BLE001 - report to GUI shell
+ self.signals.finished.emit(self._generation, None, str(exc))
+ return
+ self.signals.finished.emit(self._generation, spec, "")
+
+
+class _ParticleSandboxWorkerSignals(QObject):
+ finished = Signal(int, object, str)
+
+
+class _ParticleSandboxWorker(_PooledWorker):
+ def __init__(self, state: Any, operation: str, generation: int):
+ super().__init__(_ParticleSandboxWorkerSignals())
+ self._state = state
+ self._operation = operation
+ self._generation = int(generation)
+
+ def work(self) -> None:
+ try:
+ if self._operation == "new_pattern":
+ self._state.new_random_pattern()
+ elif self._operation == "reset":
+ self._state.reset()
+ else:
+ self._state.run()
+ except Exception as exc: # noqa: BLE001 - report to GUI shell
+ self.signals.finished.emit(self._generation, None, str(exc))
+ return
+ self.signals.finished.emit(self._generation, self._state, "")
+
+
+class ParticleStatisticsDialog(QDialog):
+ """Standalone Particle Statistics tool window."""
+
+ def __init__(
+ self,
+ *,
+ point_sources: list[Any] | None = None,
+ scan: Any = None,
+ active_area_roi: Any = None,
+ active_mask: Any = None,
+ image_shape: tuple[int, int] | None = None,
+ feature_sets: Any = (),
+ feature_set_store: Any = None,
+ theme: dict | None = None,
+ initial_mode: str = "landing",
+ parent=None,
+ pool: QThreadPool | None = None,
+ context_refresh_fn: Any = None,
+ ):
+ super().__init__(parent)
+ self.setObjectName("particleStatisticsDialog")
+ self.setWindowFlags(
+ self.windowFlags() | Qt.WindowMinimizeButtonHint | Qt.WindowMaximizeButtonHint
+ )
+ self.setWindowTitle("Particle Statistics")
+ self.setMinimumSize(1300, 850)
+ self.resize(1500, 900)
+ self.setAttribute(Qt.WA_DeleteOnClose, False)
+ self._theme = theme or {}
+ self._pool = pool or QThreadPool.globalInstance()
+ self._context_refresh_fn = context_refresh_fn
+ self._point_sources = list(point_sources or ())
+ # When a shared FeatureSetStore is provided it is the live source of
+ # truth; ``self._feature_sets`` is a cache resynced from it on each
+ # populate. Otherwise the static ``feature_sets`` tuple is used.
+ self._feature_set_store_ref = feature_set_store
+ self._feature_sets = list(
+ feature_set_store.all() if feature_set_store is not None else (feature_sets or ())
+ )
+ self._scan = scan
+ self._active_area_roi = active_area_roi
+ self._active_mask = _valid_mask(active_mask, image_shape)
+ self._image_shape = image_shape
+ self._generation = 0
+ self._sandbox_generation = 0
+ self._sandbox_context = None
+ self._sandbox_state = None
+ self._updating_generated_controls = False
+ self._updating_layer_controls = False
+ self._updating_mode = False
+ self._updating_tutorial_highlights = False
+ self._force_close = False
+ self._active_mode = ""
+ self._tutorial_active = str(initial_mode).lower() == "learn"
+ self._tutorial_step_index = 0
+ self._tutorial_run_in_progress = False
+ self._pooling_reference_curve: dict[str, Any] | None = None
+ self._focused_statistic = _DEFAULT_FOCUS_STATISTIC
+ self._focused_curve_mode = "comparison"
+ self._last_view_spec = _empty_view_spec("Run a comparison to populate result panels.")
+ self._field = ParticleFieldView(theme=self._theme, parent=self)
+ self._focus_panel = FocusedStatisticPanel(theme=self._theme, parent=self)
+ self._info_lbl = QLabel("")
+ self._info_lbl.setObjectName("particleStatisticsInfo")
+ self._info_lbl.setWordWrap(True)
+ self._status_lbl = QLabel("")
+ self._status_lbl.setObjectName("particleStatisticsStatus")
+ self._status_lbl.setWordWrap(True)
+ self._result_view = AdStatResultView(
+ self._last_view_spec,
+ source_label="Particle Statistics",
+ theme=self._theme,
+ data_mode="real",
+ show_banner=False,
+ # The per-statistic plots and point-pattern panel are already the
+ # always-visible top panel and left field; show only the verdict
+ # summary and technical details here to avoid duplication.
+ show_panels=False,
+ parent=self,
+ )
+ self._statistic_buttons: dict[str, QPushButton] = {}
+ self._controls: list[QWidget] = []
+ self._generated_controls: list[QWidget] = []
+
+ try:
+ self._sandbox_context = adstat_sandbox_context()
+ self._sandbox_state = adstat_sandbox_state()
+ except ImportError as exc:
+ self._sandbox_error = str(exc)
+ else:
+ self._sandbox_error = ""
+
+ self._build()
+ self._restore_particle_statistics_layout()
+ self._sync_generated_controls_from_state()
+ initial = str(initial_mode).lower().replace(" ", "_")
+ start_mode = (
+ "generated"
+ if self._tutorial_active
+ else "sandbox"
+ if initial in {"sandbox", "model_simulations"}
+ else "landing"
+ if initial in {"landing", "start", "home"}
+ else "real"
+ )
+ self._set_mode(start_mode)
+ if self._tutorial_active:
+ self._apply_tutorial_step(self._current_tutorial_step_obj(), stage_generated=True)
+
+ @property
+ def current_mode(self) -> str:
+ """Public workflow mode, decoupled from the internal data-surface name.
+
+ Two vocabularies coexist on purpose:
+
+ - ``self._active_mode`` is the internal *control/data surface*:
+ ``landing`` (workflow chooser), ``real`` (scan points),
+ ``generated`` (tutorial-staged generated data), or ``sandbox``
+ (free-play Model simulations). ``self._field.data_mode`` mirrors the
+ data source as ``real``/``generated``/``sandbox``.
+ - This property reports the *workflow the user is in*: ``landing``,
+ ``real``, ``learn`` (the guided tutorial, whatever generated surface it
+ stages), or ``model_simulations`` (the sandbox).
+
+ So ``_active_mode == "generated"`` maps to ``learn`` while the tutorial is
+ active, and ``_active_mode == "sandbox"`` maps to ``model_simulations``.
+ """
+ if self._active_mode == "landing":
+ return "landing"
+ if self._tutorial_active:
+ return "learn"
+ return "model_simulations" if self._active_mode == "sandbox" else "real"
+
+ @property
+ def field_point_count(self) -> int:
+ return self._field.point_count
+
+ @property
+ def current_tutorial_key(self) -> str:
+ if not hasattr(self, "_tutorial_cb"):
+ return ""
+ return str(self._tutorial_cb.currentData() or "")
+
+ @property
+ def current_tutorial_step(self) -> int:
+ return int(self._tutorial_step_index)
+
+ @property
+ def focused_statistic(self) -> str:
+ return self._focused_statistic
+
+ @property
+ def focus_has_plot(self) -> bool:
+ return self._focus_panel.has_plot
+
+ def set_current_mode(self, initial_mode: str) -> None:
+ # Maps a public workflow name to an internal data surface (see the
+ # ``current_mode`` docstring): ``learn`` -> ``generated`` surface with the
+ # tutorial active; ``sandbox``/``model_simulations`` -> ``sandbox``.
+ mode = str(initial_mode).lower().replace(" ", "_")
+ tutorial = mode == "learn"
+ target = (
+ "generated"
+ if tutorial
+ else "sandbox"
+ if mode in {"sandbox", "model_simulations"}
+ else "landing"
+ if mode in {"landing", "start", "home"}
+ else "real"
+ )
+ self._set_mode(target, tutorial_active=tutorial)
+ if tutorial:
+ self._apply_tutorial_step(self._current_tutorial_step_obj(), stage_generated=True)
+ self._ensure_tutorial_comparison(force=True)
+ else:
+ self._clear_tutorial_highlights()
+
+ def return_to_landing_page(self) -> None:
+ """Return to the three-card workflow chooser without closing the dialog."""
+
+ self._sandbox_generation += 1
+ self._tutorial_run_in_progress = False
+ self._tutorial_active = False
+ self._clear_tutorial_highlights()
+ self._set_mode("landing", tutorial_active=False)
+ # Keep this dialog in front after the mode switch (see exit_tutorial).
+ self._raise_self()
+
+ def focus_statistic(self, statistic_id: str, *, curve_mode: str | None = None) -> None:
+ self._focused_statistic = str(statistic_id or _DEFAULT_FOCUS_STATISTIC)
+ self._focused_curve_mode = str(curve_mode or "comparison")
+ self._refresh_focus_panel()
+ self._sync_statistic_buttons()
+ self._raise_self()
+
+ def refresh_probe_context(
+ self,
+ *,
+ point_sources: list[Any] | None = None,
+ scan: Any = None,
+ active_area_roi: Any = None,
+ active_mask: Any = None,
+ image_shape: tuple[int, int] | None = None,
+ feature_sets: Any = None,
+ feature_set_store: Any = None,
+ ) -> None:
+ """Refresh real ProbeFlow inputs without resetting generated examples."""
+
+ selected_source = self._current_source_label() if hasattr(self, "_source_cb") else ""
+ selected_region = str(self._region_cb.currentData() or "full") if hasattr(self, "_region_cb") else "full"
+ self._point_sources = list(point_sources or ())
+ if feature_set_store is not None:
+ self._feature_set_store_ref = feature_set_store
+ if feature_sets is not None and self._feature_set_store_ref is None:
+ self._feature_sets = list(feature_sets or ())
+ self._scan = scan
+ self._active_area_roi = active_area_roi
+ self._image_shape = image_shape
+ self._active_mask = _valid_mask(active_mask, image_shape)
+ if hasattr(self, "_source_cb"):
+ self._populate_sources()
+ _set_combo_value(self._source_cb, selected_source)
+ if hasattr(self, "_region_cb"):
+ self._populate_regions()
+ _set_combo_value(self._region_cb, selected_region)
+ self._populate_feature_sets()
+ if getattr(self, "_active_mode", "real") == "real":
+ self._refresh_real_field()
+
+ def force_close(self) -> None:
+ self._force_close = True
+ self.close()
+
+ def closeEvent(self, event) -> None: # noqa: N802 - Qt override
+ if self.isVisible():
+ self._save_particle_statistics_layout()
+ if self._force_close:
+ super().closeEvent(event)
+ return
+ event.ignore()
+ self.hide()
+
+ def _restore_particle_statistics_layout(self) -> None:
+ cfg = load_config()
+ layout = cfg.get("layout", {}).get(_PARTICLE_STATISTICS_LAYOUT_KEY, {})
+ restore_geometry_or_default(self, layout.get("geometry"), 0.92)
+
+ def _save_particle_statistics_layout(self) -> None:
+ cfg = load_config()
+ layout_root = cfg.setdefault("layout", {})
+ layout = layout_root.setdefault(_PARTICLE_STATISTICS_LAYOUT_KEY, {})
+ layout["geometry"] = qbytearray_to_b64(self.saveGeometry())
+ save_config(cfg)
+
+ def _use_wide_layout(self) -> None:
+ apply_screen_fraction_geometry(self, 0.92)
+
+ def _reset_particle_statistics_window_size(self) -> None:
+ cfg = load_config()
+ if isinstance(cfg.get("layout"), dict):
+ cfg["layout"].pop(_PARTICLE_STATISTICS_LAYOUT_KEY, None)
+ save_config(cfg)
+ self._use_wide_layout()
+
+ def _set_result_view_spec(
+ self,
+ view_spec: Any,
+ *,
+ source_label: str,
+ data_mode: str,
+ ) -> None:
+ self._last_view_spec = view_spec
+ self._result_view.set_view_spec(
+ view_spec,
+ source_label=source_label,
+ data_mode=data_mode,
+ )
+ self._refresh_focus_panel()
+
+ def _refresh_focus_panel(self) -> None:
+ if not hasattr(self, "_focus_panel"):
+ return
+ panel = _panel_for_statistic(self._last_view_spec, self._focused_statistic)
+ empty_message = None
+ if not _view_spec_has_result(self._last_view_spec):
+ empty_message = str(
+ getattr(self._last_view_spec, "metadata", {}).get("message", "") or ""
+ ) or None
+ self._focus_panel.set_statistic(
+ self._focused_statistic,
+ panel=panel,
+ data_mode="sandbox" if self._active_mode in {"generated", "sandbox"} else "real",
+ curve_mode=self._focused_curve_mode,
+ has_result=_view_spec_has_result(self._last_view_spec),
+ empty_message=empty_message,
+ show_observed_curve=self._show_observed_curve_in_focus(),
+ show_model_curves=self._show_model_curves_in_focus(),
+ )
+
+ def _show_observed_curve_in_focus(self) -> bool:
+ if not hasattr(self, "_observed_layer_cb"):
+ return True
+ return bool(self._observed_layer_cb.isChecked())
+
+ def _show_model_curves_in_focus(self) -> bool:
+ if not hasattr(self, "_simulation_layer_cb"):
+ return True
+ # In real-data mode the field has no one-simulation overlay, but the focus
+ # plot still has a model envelope. Keep it visible there.
+ if self._active_mode not in {"generated", "sandbox"}:
+ return True
+ return bool(self._simulation_layer_cb.isChecked())
+
+ def _sync_statistic_buttons(self) -> None:
+ for statistic_id, button in getattr(self, "_statistic_buttons", {}).items():
+ selected = statistic_id == self._focused_statistic
+ button.setChecked(selected)
+ # Normal ProbeFlow button when unselected; highlighted when its plot is shown.
+ button.setStyleSheet(_STAT_SELECTED_STYLE if selected else "")
+ self._refresh_selected_statistic_help()
+ self._sync_workflow_actions()
+
+ def _refresh_selected_statistic_help(self) -> None:
+ if not hasattr(self, "_selected_statistic_help_lbl"):
+ return
+ label = _STATISTIC_LABELS.get(self._focused_statistic, self._focused_statistic)
+ question = _guide_text(self._focused_statistic, "focus_question")
+ self._selected_statistic_help_lbl.setText(
+ f"Selected: {label}
{question}"
+ )
+
+ def _build(self) -> None:
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(8, 8, 8, 8)
+ outer.setSpacing(6)
+ outer.setMenuBar(self._view_menu_bar())
+
+ toolbar = QHBoxLayout()
+ title = QLabel("Particle Statistics")
+ title.setObjectName("dialogTitle")
+ title.setStyleSheet("font-weight: 700;")
+ toolbar.addWidget(title)
+ self._landing_btn = QPushButton("Workflows", self)
+ self._landing_btn.setObjectName("particleStatisticsReturnToLanding")
+ self._landing_btn.setToolTip("Return to the workflow start page.")
+ self._landing_btn.clicked.connect(self.return_to_landing_page)
+ toolbar.addWidget(self._landing_btn)
+ self._start_tutorial_btn = QPushButton("Start tutorial", self)
+ self._start_tutorial_btn.setObjectName("particleStatisticsStartTutorial")
+ self._start_tutorial_btn.setStyleSheet(_TUTORIAL_ACTION_STYLE)
+ self._start_tutorial_btn.setToolTip("Open the guided walkthrough with generated example data.")
+ self._start_tutorial_btn.clicked.connect(self.start_tutorial)
+ toolbar.addWidget(self._start_tutorial_btn)
+ toolbar.addStretch(1)
+ self._mode_label = QLabel("Mode:")
+ toolbar.addWidget(self._mode_label)
+ self._mode_cb = QComboBox(self)
+ self._mode_cb.setObjectName("particleStatisticsMode")
+ self._mode_cb.addItem("Analyze scan points", "real")
+ self._mode_cb.addItem("Learn with tutorial", "generated")
+ self._mode_cb.addItem("Model simulations", "sandbox")
+ self._mode_cb.currentIndexChanged.connect(self._on_mode_changed)
+ toolbar.addWidget(self._mode_cb)
+ self._run_btn = QPushButton("Run comparison", self)
+ self._run_btn.setObjectName("particleStatisticsRun")
+ self._run_btn.clicked.connect(self._run_comparison)
+ toolbar.addWidget(self._run_btn)
+ outer.addLayout(toolbar)
+
+ self._landing_panel = self._landing_page()
+ outer.addWidget(self._landing_panel, 1)
+
+ self._workspace_panel = QWidget(self)
+ workspace = QVBoxLayout(self._workspace_panel)
+ workspace.setContentsMargins(0, 0, 0, 0)
+ workspace.setSpacing(6)
+
+ self._generated_banner = QLabel("TEST MODE - GENERATED DATA")
+ self._generated_banner.setObjectName("particleStatisticsGeneratedBanner")
+ self._generated_banner.setAlignment(Qt.AlignCenter)
+ self._generated_banner.setStyleSheet(
+ "background: #f59f00; color: #1f1300; font-weight: 800; "
+ "padding: 6px; border: 1px solid #b36b00;"
+ )
+ workspace.addWidget(self._generated_banner)
+ self._tutorial_panel = self._tutorial_drawer()
+ workspace.addWidget(self._tutorial_panel)
+
+ split = QSplitter(Qt.Vertical, self)
+ self._main_splitter = split
+ split.addWidget(self._top_panel())
+ split.addWidget(self._workflow_tabs())
+ split.setStretchFactor(0, 3)
+ split.setStretchFactor(1, 2)
+ split.setSizes([520, 380])
+ split.setChildrenCollapsible(False)
+ workspace.addWidget(split, 1)
+ outer.addWidget(self._workspace_panel, 1)
+
+ def _view_menu_bar(self) -> QMenuBar:
+ menu_bar = QMenuBar(self)
+ workflow_menu = menu_bar.addMenu("Workflow")
+ self._show_workflows_action = QAction("Workflow start page", self)
+ self._show_workflows_action.triggered.connect(self.return_to_landing_page)
+ workflow_menu.addAction(self._show_workflows_action)
+ self._analyze_scan_points_action = QAction("Analyze scan points", self)
+ self._analyze_scan_points_action.triggered.connect(
+ lambda: self.set_current_mode("real")
+ )
+ workflow_menu.addAction(self._analyze_scan_points_action)
+ self._model_simulations_action = QAction("Model simulations", self)
+ self._model_simulations_action.triggered.connect(
+ lambda: self.set_current_mode("sandbox")
+ )
+ workflow_menu.addAction(self._model_simulations_action)
+ self._start_tutorial_action = QAction("Start tutorial", self)
+ self._start_tutorial_action.triggered.connect(self.start_tutorial)
+ workflow_menu.addAction(self._start_tutorial_action)
+ workflow_menu.addSeparator()
+ self._run_comparison_action = QAction("Run comparison", self)
+ self._run_comparison_action.triggered.connect(self._run_comparison)
+ workflow_menu.addAction(self._run_comparison_action)
+
+ data_menu = menu_bar.addMenu("Data")
+ self._show_observed_action = QAction("Show observed/fake data", self)
+ self._show_observed_action.setCheckable(True)
+ self._show_observed_action.triggered.connect(
+ lambda checked=False: self._set_layer_action("observed", checked)
+ )
+ data_menu.addAction(self._show_observed_action)
+ self._show_feature_layer_action = QAction("Show feature layer", self)
+ self._show_feature_layer_action.setCheckable(True)
+ self._show_feature_layer_action.triggered.connect(
+ lambda checked=False: self._set_layer_action("features", checked)
+ )
+ data_menu.addAction(self._show_feature_layer_action)
+ self._show_region_action = QAction("Show region / mask", self)
+ self._show_region_action.setCheckable(True)
+ self._show_region_action.triggered.connect(
+ lambda checked=False: self._set_layer_action("region", checked)
+ )
+ data_menu.addAction(self._show_region_action)
+ data_menu.addSeparator()
+ self._new_pattern_action = QAction("New generated pattern", self)
+ self._new_pattern_action.triggered.connect(self.new_generated_pattern)
+ data_menu.addAction(self._new_pattern_action)
+ self._reset_pattern_action = QAction("Reset generated pattern", self)
+ self._reset_pattern_action.triggered.connect(self.reset_generated)
+ data_menu.addAction(self._reset_pattern_action)
+ data_menu.addSeparator()
+ self._refresh_sources_action = QAction("Refresh real sources", self)
+ self._refresh_sources_action.triggered.connect(self.refresh_probe_sources)
+ data_menu.addAction(self._refresh_sources_action)
+ self._clear_real_action = QAction("Clear real field", self)
+ self._clear_real_action.triggered.connect(self.clear_real_view)
+ data_menu.addAction(self._clear_real_action)
+
+ model_menu = menu_bar.addMenu("Model")
+ self._show_model_action = QAction("Show model simulation/envelope", self)
+ self._show_model_action.setCheckable(True)
+ self._show_model_action.triggered.connect(
+ lambda checked=False: self._set_layer_action("simulated", checked)
+ )
+ model_menu.addAction(self._show_model_action)
+ self._link_hard_core_radii_action = QAction(
+ "Link data/model hard-core radius",
+ self,
+ )
+ self._link_hard_core_radii_action.setCheckable(True)
+ self._link_hard_core_radii_action.triggered.connect(
+ self._set_link_hard_core_radii
+ )
+ model_menu.addAction(self._link_hard_core_radii_action)
+ model_menu.addSeparator()
+ self._model_action_group = QActionGroup(self)
+ self._model_action_group.setExclusive(True)
+ self._model_actions: dict[str, QAction] = {}
+ for model_id in (
+ "poisson",
+ "hard_core_random",
+ "measured_feature_poisson",
+ ):
+ action = QAction(_MODEL_LABELS.get(model_id, model_id), self)
+ action.setCheckable(True)
+ action.triggered.connect(
+ lambda _checked=False, value=model_id: self._choose_model_from_menu(value)
+ )
+ self._model_action_group.addAction(action)
+ self._model_actions[model_id] = action
+ model_menu.addAction(action)
+
+ statistic_menu = menu_bar.addMenu("Statistic")
+ self._statistic_action_group = QActionGroup(self)
+ self._statistic_action_group.setExclusive(True)
+ self._statistic_actions: dict[str, QAction] = {}
+ for group_title, statistic_ids in _STATISTIC_GROUPS:
+ section = QAction(group_title, self)
+ section.setEnabled(False)
+ statistic_menu.addAction(section)
+ for statistic_id in statistic_ids:
+ action = QAction(_STATISTIC_LABELS.get(statistic_id, statistic_id), self)
+ action.setCheckable(True)
+ action.triggered.connect(
+ lambda _checked=False, value=statistic_id: self.focus_statistic(value)
+ )
+ self._statistic_action_group.addAction(action)
+ self._statistic_actions[statistic_id] = action
+ statistic_menu.addAction(action)
+
+ export_menu = menu_bar.addMenu("Export")
+ self._export_csv_action = QAction("Export curves + verdicts (CSV folder)…", self)
+ self._export_csv_action.setToolTip(
+ "Write one CSV per statistic (g(r), nearest-neighbour, Ripley L, …) "
+ "plus a verdicts table, for reproducing the plots elsewhere."
+ )
+ self._export_csv_action.triggered.connect(self._export_results_csv)
+ export_menu.addAction(self._export_csv_action)
+ self._export_json_action = QAction("Export full result (JSON)…", self)
+ self._export_json_action.setToolTip(
+ "Write the entire result (all panels, curves, and verdicts) to one JSON file."
+ )
+ self._export_json_action.triggered.connect(self._export_results_json)
+ export_menu.addAction(self._export_json_action)
+
+ view_menu = menu_bar.addMenu("View")
+ self._use_wide_layout_action = QAction("Use wide layout", self)
+ self._use_wide_layout_action.triggered.connect(self._use_wide_layout)
+ view_menu.addAction(self._use_wide_layout_action)
+ self._reset_window_size_action = QAction(
+ "Reset Particle Statistics window size", self
+ )
+ self._reset_window_size_action.triggered.connect(
+ self._reset_particle_statistics_window_size
+ )
+ view_menu.addAction(self._reset_window_size_action)
+
+ definitions_menu = menu_bar.addMenu("Definitions")
+ self._show_definitions_action = QAction("Show Definitions tab", self)
+ self._show_definitions_action.triggered.connect(self.show_definitions_tab)
+ definitions_menu.addAction(self._show_definitions_action)
+ self._definitions_tutorial_action = QAction("Start tutorial", self)
+ self._definitions_tutorial_action.triggered.connect(self.start_tutorial)
+ definitions_menu.addAction(self._definitions_tutorial_action)
+ return menu_bar
+
+ def _landing_page(self) -> QWidget:
+ page = QWidget(self)
+ page.setObjectName("particleStatisticsLanding")
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(32, 32, 32, 32)
+ layout.setSpacing(18)
+ heading = QLabel("Particle Statistics", page)
+ heading.setObjectName("particleStatisticsLandingTitle")
+ heading.setStyleSheet("font-size: 24px; font-weight: 800;")
+ heading.setAlignment(Qt.AlignCenter)
+ layout.addWidget(heading)
+ summary = QLabel(
+ "Compare detected particle positions with simulated spatial models.",
+ page,
+ )
+ summary.setObjectName("particleStatisticsLandingSummary")
+ summary.setWordWrap(True)
+ summary.setAlignment(Qt.AlignCenter)
+ layout.addWidget(summary)
+ prompt = QLabel("Choose a workflow:", page)
+ prompt.setAlignment(Qt.AlignCenter)
+ prompt.setStyleSheet("font-weight: 700;")
+ layout.addWidget(prompt)
+ cards = QGridLayout()
+ cards.setHorizontalSpacing(14)
+ cards.setVerticalSpacing(14)
+ cards.addWidget(
+ self._landing_card(
+ page,
+ object_name="particleStatisticsLandingAnalyze",
+ title="Analyze scan points",
+ body="Use detected particles from Feature Finder or saved feature sets.",
+ button_text="Choose point source",
+ mode="real",
+ ),
+ 0,
+ 0,
+ )
+ cards.addWidget(
+ self._landing_card(
+ page,
+ object_name="particleStatisticsLandingSimulations",
+ title="Model simulations",
+ body=(
+ "Generate random, clustered, hard-core, feature-biased, "
+ "or ordered patterns."
+ ),
+ button_text="Open simulations",
+ mode="sandbox",
+ ),
+ 0,
+ 1,
+ )
+ cards.addWidget(
+ self._landing_card(
+ page,
+ object_name="particleStatisticsLandingTutorial",
+ title="Tutorial",
+ body="Learn the workflow with guided generated examples.",
+ button_text="Start tutorial",
+ mode="learn",
+ ),
+ 0,
+ 2,
+ )
+ layout.addLayout(cards)
+ layout.addStretch(1)
+ return page
+
+ def _landing_card(
+ self,
+ parent: QWidget,
+ *,
+ object_name: str,
+ title: str,
+ body: str,
+ button_text: str,
+ mode: str,
+ ) -> QFrame:
+ card = QFrame(parent)
+ card.setObjectName(object_name)
+ card.setFrameShape(QFrame.StyledPanel)
+ card.setStyleSheet(
+ "QFrame { border: 1px solid #3b4250; border-radius: 6px; }"
+ )
+ layout = QVBoxLayout(card)
+ layout.setContentsMargins(14, 14, 14, 14)
+ layout.setSpacing(10)
+ title_label = QLabel(f"{title}", card)
+ title_label.setWordWrap(True)
+ layout.addWidget(title_label)
+ body_label = QLabel(body, card)
+ body_label.setWordWrap(True)
+ layout.addWidget(body_label)
+ layout.addStretch(1)
+ button = QPushButton(button_text, card)
+ button.setObjectName(f"{object_name}Button")
+ button.clicked.connect(lambda _checked=False, value=mode: self._choose_landing_workflow(value))
+ layout.addWidget(button)
+ return card
+
+ def _choose_landing_workflow(self, mode: str) -> None:
+ if mode == "learn":
+ self.start_tutorial()
+ return
+ self._set_mode("sandbox" if mode == "sandbox" else "real", tutorial_active=False)
+
+ def _set_layer_action(self, layer: str, checked: bool) -> None:
+ mapping = {
+ "observed": "_observed_layer_cb",
+ "simulated": "_simulation_layer_cb",
+ "features": "_feature_layer_cb",
+ "region": "_region_layer_cb",
+ }
+ attr = mapping.get(str(layer))
+ checkbox = getattr(self, attr, None) if attr else None
+ if isinstance(checkbox, QCheckBox):
+ checkbox.setChecked(bool(checked))
+ self._sync_workflow_actions()
+ self._raise_self()
+
+ def _set_link_hard_core_radii(self, checked: bool) -> None:
+ checkbox = getattr(self, "_link_hard_core_radii_cb", None)
+ if isinstance(checkbox, QCheckBox):
+ checkbox.setChecked(bool(checked))
+ self._sync_workflow_actions()
+ self._raise_self()
+
+ def _choose_model_from_menu(self, model_id: str) -> None:
+ if self._active_mode in {"generated", "sandbox"}:
+ sandbox_id = (
+ "homogeneous_poisson" if str(model_id) == "poisson" else str(model_id)
+ )
+ _set_combo_value(self._generated_model_cb, sandbox_id)
+ self._raise_self()
+ return
+ _set_combo_value(self._real_model_cb, str(model_id))
+ self._raise_self()
+
+ def show_definitions_tab(self) -> None:
+ """Open the shared ProbeFlow Definitions document at the Particle Statistics tab."""
+ from probeflow.gui.dialogs.definitions import _DefinitionsDialog
+
+ dlg = getattr(self, "_definitions_dialog", None)
+ try:
+ if dlg is not None:
+ dlg.isVisible()
+ except RuntimeError:
+ dlg = None
+ if dlg is None:
+ dlg = _DefinitionsDialog(
+ self._theme, self, initial_tab="particle_statistics"
+ )
+ self._definitions_dialog = dlg
+ else:
+ dlg.set_reference_tab("particle_statistics")
+ dlg.show()
+ dlg.raise_()
+ dlg.activateWindow()
+
+ def _top_panel(self) -> QWidget:
+ panel = QWidget(self)
+ layout = QHBoxLayout(panel)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(8)
+ layout.addWidget(self._field, 2)
+ layout.addWidget(self._focus_panel, 4)
+
+ info_panel = QFrame(panel)
+ info_panel.setObjectName("particleStatisticsInfoPanel")
+ info_panel.setFrameShape(QFrame.StyledPanel)
+ info_panel.setMinimumWidth(260)
+ info_panel.setMaximumWidth(340)
+ self._info_panel = info_panel
+ info_layout = QVBoxLayout(info_panel)
+ info_layout.setContentsMargins(8, 8, 8, 8)
+ info_layout.setSpacing(8)
+ heading = QLabel("Field")
+ heading.setStyleSheet("font-weight: 700;")
+ info_layout.addWidget(heading)
+ info_layout.addWidget(self._info_lbl)
+ info_layout.addWidget(self._status_lbl)
+
+ layer_group = QGroupBox("View layers", info_panel)
+ self._layer_group = layer_group
+ layer_layout = QVBoxLayout(layer_group)
+ layer_layout.setContentsMargins(8, 8, 8, 8)
+ layer_layout.setSpacing(4)
+ self._observed_layer_cb = QCheckBox("Observed points", layer_group)
+ self._observed_layer_cb.setObjectName("particleStatisticsLayerObserved")
+ self._simulation_layer_cb = QCheckBox("Model simulation", layer_group)
+ self._simulation_layer_cb.setObjectName("particleStatisticsLayerSimulation")
+ self._feature_layer_cb = QCheckBox("Feature layer", layer_group)
+ self._feature_layer_cb.setObjectName("particleStatisticsLayerFeature")
+ self._region_layer_cb = QCheckBox("Region / mask", layer_group)
+ self._region_layer_cb.setObjectName("particleStatisticsLayerRegion")
+ for checkbox in (
+ self._observed_layer_cb,
+ self._simulation_layer_cb,
+ self._feature_layer_cb,
+ self._region_layer_cb,
+ ):
+ checkbox.setChecked(True)
+ checkbox.toggled.connect(self._on_layer_toggled)
+ layer_layout.addWidget(checkbox)
+ self._layer_hint_lbl = QLabel("", layer_group)
+ self._layer_hint_lbl.setObjectName("particleStatisticsLayerHint")
+ self._layer_hint_lbl.setWordWrap(True)
+ layer_layout.addWidget(self._layer_hint_lbl)
+ info_layout.addWidget(layer_group)
+ info_layout.addStretch(1)
+ layout.addWidget(info_panel)
+ return panel
+
+ def _workflow_tabs(self) -> QTabWidget:
+ self._tabs = QTabWidget(self)
+ self._tabs.setObjectName("particleStatisticsTabs")
+ self._tabs.addTab(self._scrollable(self._setup_tab()), "Setup")
+ self._tabs.addTab(self._scrollable(self._results_tab()), "Results")
+ # Both the feature-layer picker and the model combo now exist, so set
+ # the picker's enabled state for the default model.
+ self._sync_feature_layer_controls()
+ return self._tabs
+
+ def _scrollable(self, page: QWidget) -> QScrollArea:
+ """Wrap a tab page so tall content (e.g. the Results plots) scrolls instead
+ of being clipped below the window when the bottom pane is short."""
+ area = QScrollArea(self)
+ area.setWidgetResizable(True)
+ area.setFrameShape(QFrame.NoFrame)
+ area.setWidget(page)
+ return area
+
+ def _setup_tab(self) -> QWidget:
+ page = QWidget(self)
+ page.setStyleSheet(_SETUP_COLUMN_STYLE)
+ layout = QHBoxLayout(page)
+ layout.setContentsMargins(8, 8, 8, 8)
+ layout.setSpacing(10)
+
+ self._setup_data_column, data_layout = self._setup_column(
+ page, "particleStatisticsDataColumn", "Data / observed pattern"
+ )
+ self._build_data_column(data_layout)
+ layout.addWidget(self._setup_data_column, 1)
+
+ self._setup_model_column, model_layout = self._setup_column(
+ page, "particleStatisticsModelColumn", "Model / null hypothesis"
+ )
+ self._build_model_column(model_layout)
+ layout.addWidget(self._setup_model_column, 1)
+
+ self._setup_statistic_column, statistic_layout = self._setup_column(
+ page, "particleStatisticsStatisticColumn", "Statistic / question"
+ )
+ self._build_statistic_column(statistic_layout)
+ layout.addWidget(self._setup_statistic_column, 1)
+ return page
+
+ def _setup_column(
+ self, parent: QWidget, object_name: str, title: str
+ ) -> tuple[QFrame, QVBoxLayout]:
+ frame = QFrame(parent)
+ frame.setObjectName(object_name)
+ frame.setFrameShape(QFrame.StyledPanel)
+ layout = QVBoxLayout(frame)
+ layout.setContentsMargins(10, 8, 10, 10)
+ layout.setSpacing(8)
+ header = QLabel(f"{title}", frame)
+ header.setWordWrap(True)
+ layout.addWidget(header)
+ return frame, layout
+
+ def _build_data_column(self, layout: QVBoxLayout) -> None:
+ parent = layout.parentWidget()
+ self._real_data_group = QGroupBox("Analyze scan points", parent)
+ real_form = QFormLayout(self._real_data_group)
+ self._source_cb = QComboBox(self._real_data_group)
+ self._source_cb.setObjectName("particleStatisticsSource")
+ self._populate_sources()
+ self._source_cb.currentIndexChanged.connect(self._refresh_real_field)
+ real_form.addRow("Point source:", self._source_cb)
+ self._region_cb = QComboBox(self._real_data_group)
+ self._region_cb.setObjectName("particleStatisticsRegion")
+ self._populate_regions()
+ self._region_cb.currentIndexChanged.connect(self._refresh_real_field)
+ real_form.addRow("Region:", self._region_cb)
+ source_buttons = QHBoxLayout()
+ self._refresh_sources_btn = QPushButton("Refresh sources", self._real_data_group)
+ self._refresh_sources_btn.setToolTip("Reload ProbeFlow point sources, active ROI, and active mask from the viewer.")
+ self._refresh_sources_btn.clicked.connect(self.refresh_probe_sources)
+ source_buttons.addWidget(self._refresh_sources_btn)
+ self._clear_real_btn = QPushButton("Clear", self._real_data_group)
+ self._clear_real_btn.setObjectName("particleStatisticsClearReal")
+ self._clear_real_btn.setToolTip("Clear the field, plot, and results.")
+ self._clear_real_btn.clicked.connect(self.clear_real_view)
+ source_buttons.addWidget(self._clear_real_btn)
+ real_form.addRow("", source_buttons)
+ layout.addWidget(self._real_data_group)
+
+ self._feature_sets_group = QGroupBox("Saved feature sets", parent)
+ sets_layout = QVBoxLayout(self._feature_sets_group)
+ sets_layout.setContentsMargins(8, 8, 8, 8)
+ sets_layout.setSpacing(6)
+ sets_hint = QLabel(
+ "Tick one set for a single-image comparison, or two or more to pool them "
+ "(replicates) into one combined verdict."
+ )
+ sets_hint.setWordWrap(True)
+ sets_layout.addWidget(sets_hint)
+ self._feature_sets_list = QListWidget(self._feature_sets_group)
+ self._feature_sets_list.setObjectName("particleStatisticsFeatureSets")
+ self._feature_sets_list.setMinimumHeight(120)
+ sets_layout.addWidget(self._feature_sets_list, 1)
+ feature_layer_row = QHBoxLayout()
+ feature_layer_row.addWidget(QLabel("Feature layer:"))
+ self._feature_layer_set_cb = QComboBox(self._feature_sets_group)
+ self._feature_layer_set_cb.setObjectName("particleStatisticsFeatureLayer")
+ self._feature_layer_set_cb.setToolTip(
+ "For the Measured-feature Poisson model: an independently-measured set "
+ "(e.g. step edges) the particles may follow. Must differ from the tested set."
+ )
+ feature_layer_row.addWidget(self._feature_layer_set_cb, 1)
+ sets_layout.addLayout(feature_layer_row)
+ self._run_feature_sets_btn = QPushButton("Run selected sets", self._feature_sets_group)
+ self._run_feature_sets_btn.setObjectName("particleStatisticsRunFeatureSets")
+ self._run_feature_sets_btn.clicked.connect(self.run_selected_feature_sets)
+ sets_layout.addWidget(self._run_feature_sets_btn)
+
+ io_row = QHBoxLayout()
+ self._import_sets_btn = QPushButton("Load points from disk…", self._feature_sets_group)
+ self._import_sets_btn.setObjectName("particleStatisticsImportSets")
+ self._import_sets_btn.setToolTip(
+ "Import a CSV position table or a ProbeFlow JSON file as a feature set."
+ )
+ self._import_sets_btn.clicked.connect(self.import_points_from_disk)
+ io_row.addWidget(self._import_sets_btn)
+ self._save_sets_btn = QPushButton("Save feature sets…", self._feature_sets_group)
+ self._save_sets_btn.setObjectName("particleStatisticsSaveSets")
+ self._save_sets_btn.setToolTip("Save all current feature sets to a JSON file.")
+ self._save_sets_btn.clicked.connect(self.save_feature_sets_to_disk)
+ io_row.addWidget(self._save_sets_btn)
+ sets_layout.addLayout(io_row)
+
+ layout.addWidget(self._feature_sets_group)
+ self._populate_feature_sets()
+
+ self._generated_data_group = QGroupBox("Generated / fake data", parent)
+ gen_form = QFormLayout(self._generated_data_group)
+ gen_form.setContentsMargins(8, 8, 8, 8)
+ gen_form.setHorizontalSpacing(8)
+ gen_form.setVerticalSpacing(4)
+ self._pattern_cb = QComboBox(self._generated_data_group)
+ self._pattern_cb.setObjectName("particleStatisticsPattern")
+ self._populate_combo(
+ self._pattern_cb,
+ getattr(self._sandbox_context, "SANDBOX_PATTERNS", ()),
+ _PATTERN_LABELS,
+ )
+ self._pattern_cb.currentIndexChanged.connect(self._stage_generated_from_controls)
+ gen_form.addRow("Generated pattern:", self._pattern_cb)
+
+ self._ordered_lattice_cb = QComboBox(self._generated_data_group)
+ self._ordered_lattice_cb.setObjectName("particleStatisticsOrderedLattice")
+ self._populate_combo(
+ self._ordered_lattice_cb,
+ getattr(self._sandbox_context, "ORDERED_ISLAND_LATTICES", ("triangular", "square")),
+ _ORDERED_ISLAND_LATTICE_LABELS,
+ )
+ self._ordered_lattice_cb.currentIndexChanged.connect(
+ self._stage_generated_from_controls
+ )
+ self._ordered_lattice_lbl = QLabel("Island lattice:", self._generated_data_group)
+ gen_form.addRow(self._ordered_lattice_lbl, self._ordered_lattice_cb)
+
+ self._ordered_background_cb = QComboBox(self._generated_data_group)
+ self._ordered_background_cb.setObjectName(
+ "particleStatisticsOrderedBackground"
+ )
+ self._populate_combo(
+ self._ordered_background_cb,
+ getattr(
+ self._sandbox_context,
+ "ORDERED_ISLAND_BACKGROUNDS",
+ ("none", "random", "clustered"),
+ ),
+ _ORDERED_ISLAND_BACKGROUND_LABELS,
+ )
+ self._ordered_background_cb.currentIndexChanged.connect(
+ self._stage_generated_from_controls
+ )
+ self._ordered_background_lbl = QLabel("Background:", self._generated_data_group)
+ gen_form.addRow(self._ordered_background_lbl, self._ordered_background_cb)
+
+ self._n_spin = QSpinBox(self._generated_data_group)
+ self._n_spin.setObjectName("particleStatisticsN")
+ self._n_spin.setRange(2, 500)
+ self._n_spin.valueChanged.connect(self._stage_generated_from_controls)
+
+ self._seed_spin = QSpinBox(self._generated_data_group)
+ self._seed_spin.setObjectName("particleStatisticsSeed")
+ self._seed_spin.setRange(0, 2_147_483_647)
+ self._seed_spin.valueChanged.connect(self._stage_generated_from_controls)
+ count_seed_row = QHBoxLayout()
+ count_seed_row.setContentsMargins(0, 0, 0, 0)
+ count_seed_row.setSpacing(6)
+ count_seed_row.addWidget(self._n_spin, 1)
+ count_seed_row.addWidget(QLabel("Seed:", self._generated_data_group))
+ count_seed_row.addWidget(self._seed_spin, 1)
+ gen_form.addRow("N:", count_seed_row)
+
+ self._hard_core_radius_spin = QDoubleSpinBox(self._generated_data_group)
+ self._hard_core_radius_spin.setObjectName("particleStatisticsHardCoreRadius")
+ self._hard_core_radius_spin.setRange(0.0, 50.0)
+ self._hard_core_radius_spin.setDecimals(2)
+ self._hard_core_radius_spin.setSingleStep(0.5)
+ self._hard_core_radius_spin.setSuffix(" nm")
+ self._hard_core_radius_spin.setToolTip(
+ "Minimum allowed separation for no-overlap / hard-core generated points."
+ )
+ self._hard_core_radius_spin.valueChanged.connect(self._stage_generated_from_controls)
+ gen_form.addRow("Data hard-core radius:", self._hard_core_radius_spin)
+
+ self._width_spin = QDoubleSpinBox(self._generated_data_group)
+ self._width_spin.setObjectName("particleStatisticsFieldWidth")
+ self._width_spin.setRange(10.0, 1000.0)
+ self._width_spin.setDecimals(1)
+ self._width_spin.setSuffix(" nm")
+ self._width_spin.valueChanged.connect(self._stage_generated_from_controls)
+
+ self._height_spin = QDoubleSpinBox(self._generated_data_group)
+ self._height_spin.setObjectName("particleStatisticsFieldHeight")
+ self._height_spin.setRange(10.0, 1000.0)
+ self._height_spin.setDecimals(1)
+ self._height_spin.setSuffix(" nm")
+ self._height_spin.valueChanged.connect(self._stage_generated_from_controls)
+ field_size_row = QHBoxLayout()
+ field_size_row.setContentsMargins(0, 0, 0, 0)
+ field_size_row.setSpacing(6)
+ field_size_row.addWidget(self._width_spin, 1)
+ field_size_row.addWidget(QLabel("H:", self._generated_data_group))
+ field_size_row.addWidget(self._height_spin, 1)
+ gen_form.addRow("Field size:", field_size_row)
+
+ self._sandbox_warning_lbl = QLabel("", self._generated_data_group)
+ self._sandbox_warning_lbl.setObjectName("particleStatisticsSandboxWarning")
+ self._sandbox_warning_lbl.setWordWrap(True)
+ gen_form.addRow("", self._sandbox_warning_lbl)
+ button_row = QHBoxLayout()
+ self._new_pattern_btn = QPushButton("New pattern", self._generated_data_group)
+ self._new_pattern_btn.setObjectName("particleStatisticsNewPattern")
+ self._new_pattern_btn.clicked.connect(self.new_generated_pattern)
+ button_row.addWidget(self._new_pattern_btn)
+ self._reset_btn = QPushButton("Reset", self._generated_data_group)
+ self._reset_btn.setObjectName("particleStatisticsReset")
+ self._reset_btn.clicked.connect(self.reset_generated)
+ button_row.addWidget(self._reset_btn)
+ gen_form.addRow("", button_row)
+ layout.addWidget(self._generated_data_group)
+ layout.addStretch(1)
+ self._generated_controls.extend(
+ [
+ self._pattern_cb,
+ self._ordered_lattice_cb,
+ self._ordered_background_cb,
+ self._n_spin,
+ self._hard_core_radius_spin,
+ self._width_spin,
+ self._height_spin,
+ self._seed_spin,
+ self._new_pattern_btn,
+ self._reset_btn,
+ ]
+ )
+
+ def _build_model_column(self, layout: QVBoxLayout) -> None:
+ parent = layout.parentWidget()
+ self._real_model_group = QGroupBox("Real-data model", parent)
+ real_form = QFormLayout(self._real_model_group)
+ real_form.setContentsMargins(8, 8, 8, 8)
+ real_form.setHorizontalSpacing(8)
+ real_form.setVerticalSpacing(4)
+ self._real_model_cb = QComboBox(self._real_model_group)
+ self._real_model_cb.setObjectName("particleStatisticsRealModel")
+ self._real_model_cb.addItem("Homogeneous Poisson", "poisson")
+ self._real_model_cb.addItem("Hard-core random", "hard_core_random")
+ self._real_model_cb.addItem("Measured-feature Poisson", "measured_feature_poisson")
+ self._real_model_cb.currentIndexChanged.connect(self._on_real_model_changed)
+ real_form.addRow("Comparison model:", self._real_model_cb)
+ self._real_sim_spin = QSpinBox(self._real_model_group)
+ self._real_sim_spin.setObjectName("particleStatisticsRealSimulations")
+ self._real_sim_spin.setRange(1, 500)
+ self._real_sim_spin.setValue(100)
+ self._real_sim_spin.setToolTip("Number of null-model simulations used to build the comparison envelope.")
+ real_form.addRow("Simulations:", self._real_sim_spin)
+ self._real_seed_spin = QSpinBox(self._real_model_group)
+ self._real_seed_spin.setObjectName("particleStatisticsRealSeed")
+ self._real_seed_spin.setRange(0, 2_147_483_647)
+ self._real_seed_spin.setValue(0)
+ self._real_seed_spin.setToolTip("Random seed for reproducible simulation envelopes.")
+ real_form.addRow("Seed:", self._real_seed_spin)
+ real_note = QLabel(
+ "Use measured-feature only with a different, independently measured feature layer."
+ )
+ real_note.setWordWrap(True)
+ real_form.addRow("", real_note)
+ layout.addWidget(self._real_model_group)
+
+ self._generated_model_group = QGroupBox("Model comparison", parent)
+ gen_form = QFormLayout(self._generated_model_group)
+ gen_form.setContentsMargins(8, 8, 8, 8)
+ gen_form.setHorizontalSpacing(8)
+ gen_form.setVerticalSpacing(4)
+ self._generated_model_cb = QComboBox(self._generated_model_group)
+ self._generated_model_cb.setObjectName("particleStatisticsGeneratedModel")
+ self._populate_combo(
+ self._generated_model_cb,
+ getattr(self._sandbox_context, "SANDBOX_MODELS", ()),
+ _MODEL_LABELS,
+ )
+ self._generated_model_cb.currentIndexChanged.connect(self._on_generated_model_changed)
+ gen_form.addRow("Comparison model:", self._generated_model_cb)
+ self._link_hard_core_radii_cb = QCheckBox(
+ "Link data/model hard-core radius",
+ self._generated_model_group,
+ )
+ self._link_hard_core_radii_cb.setObjectName(
+ "particleStatisticsLinkHardCoreRadii"
+ )
+ self._link_hard_core_radii_cb.setChecked(True)
+ self._link_hard_core_radii_cb.setToolTip(
+ "When checked, the comparison model uses the same hard-core radius "
+ "as the generated/fake data."
+ )
+ self._link_hard_core_radii_cb.toggled.connect(
+ self._on_link_hard_core_radii_toggled
+ )
+ gen_form.addRow("", self._link_hard_core_radii_cb)
+ self._model_hard_core_radius_spin = QDoubleSpinBox(self._generated_model_group)
+ self._model_hard_core_radius_spin.setObjectName(
+ "particleStatisticsModelHardCoreRadius"
+ )
+ self._model_hard_core_radius_spin.setRange(0.0, 50.0)
+ self._model_hard_core_radius_spin.setDecimals(2)
+ self._model_hard_core_radius_spin.setSingleStep(0.5)
+ self._model_hard_core_radius_spin.setSuffix(" nm")
+ self._model_hard_core_radius_spin.setToolTip(
+ "Minimum separation assumed by the hard-core comparison model."
+ )
+ self._model_hard_core_radius_spin.valueChanged.connect(
+ self._stage_generated_from_controls
+ )
+ gen_form.addRow("Model hard-core radius:", self._model_hard_core_radius_spin)
+ self._sim_spin = QSpinBox(self._generated_model_group)
+ self._sim_spin.setObjectName("particleStatisticsSimulations")
+ self._sim_spin.setRange(1, 500)
+ self._sim_spin.valueChanged.connect(self._stage_generated_from_controls)
+ gen_form.addRow("Simulations:", self._sim_spin)
+ layout.addWidget(self._generated_model_group)
+
+ cards = QWidget(parent)
+ self._model_reference_cards = cards
+ card_layout = QVBoxLayout(cards)
+ card_layout.setContentsMargins(0, 0, 0, 0)
+ note = QLabel(
+ "Working distinction
"
+ "Generated pattern makes the orange test data. "
+ "Comparison model builds the blue envelope. "
+ "Use Definitions for model details."
+ )
+ note.setWordWrap(True)
+ note.setMaximumHeight(74)
+ card_layout.addWidget(note)
+ layout.addWidget(cards)
+ self._generated_controls.extend(
+ [
+ self._generated_model_cb,
+ self._link_hard_core_radii_cb,
+ self._model_hard_core_radius_spin,
+ self._sim_spin,
+ ]
+ )
+ layout.addStretch(1)
+
+ def _tutorial_drawer(self) -> QWidget:
+ panel = QFrame(self)
+ panel.setObjectName("particleStatisticsTutorial")
+ panel.setFrameShape(QFrame.StyledPanel)
+ panel.setStyleSheet(
+ "QFrame#particleStatisticsTutorial { "
+ "background-color: rgba(245, 159, 0, 0.10); border: 1px solid #9f6b00; }"
+ )
+ layout = QVBoxLayout(panel)
+ layout.setContentsMargins(8, 6, 8, 6)
+ layout.setSpacing(5)
+
+ # Hidden example picker — kept because ``current_tutorial_key`` reads it and
+ # the load/highlight machinery references it; navigation is now linear.
+ self._tutorial_picker_lbl = QLabel("Guided example")
+ self._tutorial_picker_lbl.setVisible(False)
+ self._tutorial_cb = QComboBox(panel)
+ self._tutorial_cb.setObjectName("particleStatisticsTutorialExample")
+ for example in _TUTORIALS:
+ self._tutorial_cb.addItem(example.title, example.key)
+ self._tutorial_cb.currentIndexChanged.connect(self._on_tutorial_changed)
+ self._tutorial_cb.setVisible(False)
+ layout.addWidget(self._tutorial_picker_lbl)
+ layout.addWidget(self._tutorial_cb)
+
+ # Navigation row: previous lesson | current lesson (prominent) | next lesson.
+ nav = QHBoxLayout()
+ self._prev_tutorial_btn = QPushButton("◂ Previous", panel)
+ self._prev_tutorial_btn.setObjectName("particleStatisticsPrevTutorial")
+ self._prev_tutorial_btn.setMaximumWidth(260)
+ self._prev_tutorial_btn.setToolTip("Go back to the previous lesson.")
+ self._prev_tutorial_btn.clicked.connect(self.previous_tutorial_step)
+ nav.addWidget(self._prev_tutorial_btn, 0)
+
+ title_box = QVBoxLayout()
+ title_box.setSpacing(0)
+ self._tutorial_progress_lbl = QLabel("", panel)
+ self._tutorial_progress_lbl.setAlignment(Qt.AlignCenter)
+ self._tutorial_progress_lbl.setStyleSheet("color: #c9d1d9; font-size: 9pt;")
+ self._tutorial_title_lbl = QLabel("", panel)
+ self._tutorial_title_lbl.setObjectName("particleStatisticsTutorialTitle")
+ self._tutorial_title_lbl.setAlignment(Qt.AlignCenter)
+ self._tutorial_title_lbl.setWordWrap(True)
+ self._tutorial_title_lbl.setStyleSheet("font-size: 15pt; font-weight: 800;")
+ title_box.addWidget(self._tutorial_progress_lbl)
+ title_box.addWidget(self._tutorial_title_lbl)
+ nav.addLayout(title_box, 1)
+
+ self._next_tutorial_btn = QPushButton("Next ▸", panel)
+ self._next_tutorial_btn.setObjectName("particleStatisticsNextTutorial")
+ self._next_tutorial_btn.setMaximumWidth(260)
+ self._next_tutorial_btn.setToolTip("Go on to the next lesson.")
+ self._next_tutorial_btn.clicked.connect(self.next_tutorial_step)
+ nav.addWidget(self._next_tutorial_btn, 0)
+ layout.addLayout(nav)
+
+ # Action + meta row: the call-to-action (run/load) is separate from
+ # navigation; More detail / Restart / Exit are always available.
+ meta = QHBoxLayout()
+ self._run_tutorial_btn = QPushButton("Run this example", panel)
+ self._run_tutorial_btn.setObjectName("particleStatisticsRunTutorial")
+ self._run_tutorial_btn.clicked.connect(self.run_current_tutorial_example)
+ meta.addWidget(self._run_tutorial_btn)
+ self._load_tutorial_btn = QPushButton("Load example", panel)
+ self._load_tutorial_btn.setObjectName("particleStatisticsLoadTutorial")
+ self._load_tutorial_btn.clicked.connect(self.load_current_tutorial_example)
+ meta.addWidget(self._load_tutorial_btn)
+ self._tutorial_detail_btn = QToolButton(panel)
+ self._tutorial_detail_btn.setObjectName("particleStatisticsTutorialDetail")
+ self._tutorial_detail_btn.setCheckable(True)
+ self._tutorial_detail_btn.setText("More detail ▸")
+ self._tutorial_detail_btn.toggled.connect(self._on_tutorial_detail_toggled)
+ meta.addWidget(self._tutorial_detail_btn)
+ meta.addStretch(1)
+ self._restart_tutorial_btn = QPushButton("Restart tutorial", panel)
+ self._restart_tutorial_btn.setObjectName("particleStatisticsRestartTutorial")
+ self._restart_tutorial_btn.clicked.connect(self.restart_tutorial)
+ meta.addWidget(self._restart_tutorial_btn)
+ self._exit_tutorial_btn = QPushButton("Exit tutorial", panel)
+ self._exit_tutorial_btn.setObjectName("particleStatisticsExitTutorial")
+ self._exit_tutorial_btn.setStyleSheet(_TUTORIAL_EXIT_STYLE)
+ self._exit_tutorial_btn.setToolTip("Leave the guided tutorial and switch to analysing real scan points.")
+ self._exit_tutorial_btn.clicked.connect(self.exit_tutorial)
+ meta.addWidget(self._exit_tutorial_btn)
+ layout.addLayout(meta)
+
+ self._tutorial_step_lbl = QLabel("", panel)
+ self._tutorial_step_lbl.setWordWrap(True)
+ self._tutorial_step_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ layout.addWidget(self._tutorial_step_lbl)
+
+ # "Why it matters" always visible with a green importance bar (SEMITIP-style),
+ # so the eye lands on the takeaway without reading everything.
+ self._tutorial_why_frame, self._tutorial_why_lbl = _bar_text_row(panel, "#2fb344")
+ layout.addWidget(self._tutorial_why_frame)
+
+ # Collapsible depth: "What to look for" (blue) + "Careful" (red).
+ self._tutorial_detail_container = QWidget(panel)
+ detail_layout = QVBoxLayout(self._tutorial_detail_container)
+ detail_layout.setContentsMargins(0, 0, 0, 0)
+ detail_layout.setSpacing(4)
+ self._tutorial_look_frame, self._tutorial_look_lbl = _bar_text_row(self._tutorial_detail_container, "#2f81f7")
+ self._tutorial_careful_frame, self._tutorial_careful_lbl = _bar_text_row(self._tutorial_detail_container, "#b3382f")
+ detail_layout.addWidget(self._tutorial_look_frame)
+ detail_layout.addWidget(self._tutorial_careful_frame)
+ self._tutorial_detail_container.setVisible(False)
+ layout.addWidget(self._tutorial_detail_container)
+ return panel
+
+ def _build_statistic_column(self, layout: QVBoxLayout) -> None:
+ parent = layout.parentWidget()
+ layout.setSpacing(8)
+ intro = QLabel(
+ "Choose the question for the chart above.",
+ parent,
+ )
+ self._statistics_intro_lbl = intro
+ intro.setWordWrap(True)
+ layout.addWidget(intro)
+
+ self._include_ordering_cb = QCheckBox(
+ "Include local-order checks (ψ4/ψ6, angle map)", parent
+ )
+ self._include_ordering_cb.setObjectName("particleStatisticsIncludeOrdering")
+ self._include_ordering_cb.setToolTip(
+ "Local-order statistics answer a different question — is there square "
+ "or triangular lattice order? — and are sensitive to the neighbour "
+ "cutoff and edge effects. Off by default; enable to compute and show "
+ "ψ4, ψ6, and the angular pair map."
+ )
+ self._include_ordering_cb.toggled.connect(self._on_include_ordering_toggled)
+ layout.addWidget(self._include_ordering_cb)
+
+ self._statistic_buttons = {}
+ self._ordering_stat_buttons: dict[str, QPushButton] = {}
+ self._statistic_description_labels: dict[str, QLabel] = {}
+ self._statistic_group_labels: list[QLabel] = []
+ for group_title, statistic_ids in _STATISTIC_GROUPS:
+ group_label = QLabel(f"{group_title}", parent)
+ group_label.setObjectName(
+ "particleStatisticsStatisticGroup_"
+ + group_title.lower().replace(" ", "_").replace("/", "_")
+ )
+ self._statistic_group_labels.append(group_label)
+ layout.addWidget(group_label)
+ grid = QGridLayout()
+ grid.setContentsMargins(0, 0, 0, 4)
+ grid.setHorizontalSpacing(8)
+ grid.setVerticalSpacing(6)
+ for index, statistic_id in enumerate(statistic_ids):
+ button = QPushButton(
+ _STATISTIC_LABELS.get(statistic_id, statistic_id), parent
+ )
+ button.setObjectName(f"particleStatisticsFocus_{statistic_id}")
+ button.setCheckable(True)
+ button.setMinimumWidth(150)
+ button.setMinimumHeight(30)
+ button.setToolTip(_statistic_row_description(statistic_id))
+ button.clicked.connect(
+ lambda _checked=False, value=statistic_id: self.focus_statistic(value)
+ )
+ self._statistic_buttons[statistic_id] = button
+ if statistic_id in ORDERING_STATISTICS:
+ self._ordering_stat_buttons[statistic_id] = button
+ desc = QLabel(_statistic_row_description(statistic_id), parent)
+ desc.setObjectName(f"particleStatisticsDesc_{statistic_id}")
+ desc.setWordWrap(True)
+ desc.setVisible(False)
+ self._statistic_description_labels[statistic_id] = desc
+ grid.addWidget(button, index // 2, index % 2)
+ layout.addLayout(grid)
+ self._selected_statistic_help_lbl = QLabel("", parent)
+ self._selected_statistic_help_lbl.setObjectName(
+ "particleStatisticsSelectedStatisticHelp"
+ )
+ self._selected_statistic_help_lbl.setWordWrap(True)
+ self._selected_statistic_help_lbl.setStyleSheet(
+ "border: 1px solid rgba(47, 129, 247, 0.45); padding: 5px;"
+ )
+ layout.addWidget(self._selected_statistic_help_lbl)
+ layout.addStretch(1)
+ self._sync_statistic_buttons()
+ self._sync_ordering_card_state()
+
+ def _results_tab(self) -> QWidget:
+ page = QWidget(self)
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._result_view, 1)
+ return page
+
+ def refresh_probe_sources(self) -> None:
+ if callable(self._context_refresh_fn):
+ context = self._context_refresh_fn()
+ if isinstance(context, dict):
+ self.refresh_probe_context(**context)
+ return
+ self._refresh_real_field()
+
+ def _on_layer_toggled(self, *_args) -> None:
+ if self._updating_layer_controls:
+ return
+ self._apply_layer_controls()
+
+ def _sync_layer_controls(self) -> None:
+ if not hasattr(self, "_observed_layer_cb"):
+ return
+ availability = self._field.layer_availability
+ previous = getattr(self, "_last_layer_availability", {})
+ mapping = {
+ "observed": self._observed_layer_cb,
+ "simulated": self._simulation_layer_cb,
+ "features": self._feature_layer_cb,
+ "region": self._region_layer_cb,
+ }
+ self._updating_layer_controls = True
+ try:
+ for key, checkbox in mapping.items():
+ available = bool(availability.get(key, False))
+ checkbox.setEnabled(available)
+ if not available:
+ checkbox.setChecked(False)
+ elif not bool(previous.get(key, False)):
+ checkbox.setChecked(True)
+ finally:
+ self._updating_layer_controls = False
+ self._last_layer_availability = availability
+ self._apply_layer_controls()
+
+ def _apply_layer_controls(self) -> None:
+ if not hasattr(self, "_observed_layer_cb"):
+ return
+ self._field.set_layer_visibility(
+ observed=self._observed_layer_cb.isChecked(),
+ simulated=self._simulation_layer_cb.isChecked(),
+ features=self._feature_layer_cb.isChecked(),
+ region=self._region_layer_cb.isChecked(),
+ )
+ self._update_layer_hint()
+ self._refresh_focus_panel()
+ self._sync_workflow_actions()
+
+ def _set_layer_controls_from_step(self, step: ParticleTutorialStep) -> None:
+ if not hasattr(self, "_observed_layer_cb"):
+ return
+ availability = self._field.layer_availability
+ self._updating_layer_controls = True
+ try:
+ values = {
+ "observed": step.show_observed,
+ "simulated": step.show_simulated,
+ "features": step.show_features,
+ "region": step.show_region,
+ }
+ for key, checkbox in (
+ ("observed", self._observed_layer_cb),
+ ("simulated", self._simulation_layer_cb),
+ ("features", self._feature_layer_cb),
+ ("region", self._region_layer_cb),
+ ):
+ if availability.get(key, False):
+ checkbox.setChecked(bool(values[key]))
+ finally:
+ self._updating_layer_controls = False
+ self._apply_layer_controls()
+
+ def _update_layer_hint(self) -> None:
+ if not hasattr(self, "_layer_hint_lbl"):
+ return
+ availability = self._field.layer_availability
+ hidden: list[str] = []
+ if availability.get("observed") and not self._observed_layer_cb.isChecked():
+ hidden.append(
+ "Observed/fake data hidden from the field and supported plots; "
+ "still used for comparison."
+ )
+ if availability.get("simulated") and not self._simulation_layer_cb.isChecked():
+ hidden.append(
+ "Model simulation hidden from the field and supported plots; "
+ "still used for comparison."
+ )
+ if availability.get("features") and not self._feature_layer_cb.isChecked():
+ hidden.append("Feature layer hidden from view.")
+ if availability.get("region") and not self._region_layer_cb.isChecked():
+ hidden.append("Region or mask hidden from view.")
+ self._layer_hint_lbl.setText(" ".join(hidden))
+
+ @staticmethod
+ def _tutorial_step_controls(step: ParticleTutorialStep) -> tuple[str, ...]:
+ return tuple(step.visible_controls or step.controls)
+
+ @staticmethod
+ def _tutorial_step_curve_mode(step: ParticleTutorialStep) -> str:
+ return str(step.curve_mode or step.focus_curve_mode or "comparison")
+
+ def _refresh_generated_banner(self) -> None:
+ if not hasattr(self, "_generated_banner"):
+ return
+ is_generated = self._active_mode in {"generated", "sandbox"}
+ self._generated_banner.setVisible(is_generated)
+ if self._tutorial_active and is_generated:
+ self._generated_banner.setText("Tutorial: generated example")
+ self._generated_banner.setStyleSheet(
+ "background: rgba(47, 179, 68, 0.14); color: #d8ffe0; "
+ "font-weight: 700; padding: 4px; border: 1px solid #2fb344;"
+ )
+ return
+ if self._active_mode == "sandbox":
+ self._generated_banner.setText("Model simulations")
+ self._generated_banner.setStyleSheet(
+ "background: rgba(47, 129, 247, 0.14); color: #dceaff; "
+ "font-weight: 700; padding: 4px; border: 1px solid #2f81f7;"
+ )
+ return
+ self._generated_banner.setText("TEST MODE - GENERATED DATA")
+ self._generated_banner.setStyleSheet(
+ "background: #f59f00; color: #1f1300; font-weight: 800; "
+ "padding: 6px; border: 1px solid #b36b00;"
+ )
+
+ def _apply_staged_tutorial_visibility(
+ self,
+ step: ParticleTutorialStep | None = None,
+ ) -> None:
+ if not hasattr(self, "_tabs"):
+ return
+ if step is None and self._tutorial_active:
+ step = self._current_tutorial_step_obj()
+ if not self._tutorial_active or step is None:
+ self._tabs.setVisible(True)
+ self._tabs.tabBar().setVisible(True)
+ self._focus_panel.setVisible(True)
+ self._info_panel.setVisible(True)
+ self._layer_group.setVisible(True)
+ self._setup_data_column.setVisible(True)
+ self._setup_model_column.setVisible(True)
+ self._setup_statistic_column.setVisible(True)
+ is_generated = self._active_mode in {"generated", "sandbox"}
+ self._real_data_group.setVisible(not is_generated)
+ self._real_model_group.setVisible(not is_generated)
+ self._feature_sets_group.setVisible(not is_generated)
+ self._generated_data_group.setVisible(is_generated)
+ self._generated_model_group.setVisible(is_generated)
+ self._model_reference_cards.setVisible(True)
+ self._statistics_intro_lbl.setVisible(True)
+ for label in getattr(self, "_statistic_group_labels", ()):
+ label.setVisible(True)
+ self._selected_statistic_help_lbl.setVisible(True)
+ for statistic_id, button in self._statistic_buttons.items():
+ button.setVisible(True)
+ label = self._statistic_description_labels.get(statistic_id)
+ if label is not None:
+ label.setVisible(False)
+ self._result_view.set_technical_details_visible(True)
+ self._refresh_generated_banner()
+ return
+
+ controls = set(self._tutorial_step_controls(step))
+ panel = str(step.visible_panel or "field").lower()
+ is_generated = self._active_mode in {"generated", "sandbox"}
+ layer_keys = {"layer_observed", "layer_simulated", "layer_features", "layer_region"}
+ generated_data_keys = {
+ "pattern",
+ "n",
+ "generated_seed",
+ "field_size",
+ "hard_core_radius",
+ "data_hard_core_radius",
+ }
+ generated_model_keys = {
+ "generated_model",
+ "generated_simulations",
+ "hard_core_radius",
+ "link_hard_core_radii",
+ "model_hard_core_radius",
+ }
+ real_data_keys = {"source", "region"}
+ real_model_keys = {"real_model", "real_simulations", "real_seed"}
+ statistic_keys = {
+ "stat_pair",
+ "stat_nearest",
+ "stat_ripley",
+ "stat_clusters",
+ "stat_directional",
+ "stat_psi6",
+ "stat_psi4",
+ }
+
+ tabs_visible = panel in {
+ "controls",
+ "results",
+ "learn",
+ "definitions",
+ "statistics_reference",
+ }
+ self._tabs.setVisible(tabs_visible)
+ self._tabs.tabBar().setVisible(False)
+ self._focus_panel.setVisible(panel in {"plot", "results"})
+ self._info_panel.setVisible(panel in {"controls", "info"} or bool(controls & layer_keys))
+ self._layer_group.setVisible(bool(controls & layer_keys))
+
+ show_generated_data = (
+ is_generated and panel == "controls" and bool(controls & generated_data_keys)
+ )
+ show_generated_model = (
+ is_generated and panel == "controls" and bool(controls & generated_model_keys)
+ )
+ show_real_data = (
+ (not is_generated)
+ and panel == "controls"
+ and (not controls or bool(controls & real_data_keys))
+ )
+ show_feature_sets = (
+ (not is_generated)
+ and panel == "controls"
+ and (not controls or "feature_sets" in controls or "feature_layer" in controls)
+ )
+ show_real_model = (
+ (not is_generated) and panel == "controls" and bool(controls & real_model_keys)
+ )
+ show_statistics_reference = panel == "statistics_reference"
+ show_statistic_controls = (
+ show_statistics_reference or (panel == "controls" and bool(controls & statistic_keys))
+ )
+
+ self._generated_data_group.setVisible(show_generated_data)
+ self._generated_model_group.setVisible(show_generated_model)
+ self._real_data_group.setVisible(show_real_data)
+ self._feature_sets_group.setVisible(
+ show_feature_sets
+ )
+ self._real_model_group.setVisible(show_real_model)
+ self._model_reference_cards.setVisible(False)
+ self._setup_data_column.setVisible(
+ show_generated_data or show_real_data or show_feature_sets
+ )
+ self._setup_model_column.setVisible(show_generated_model or show_real_model)
+ self._setup_statistic_column.setVisible(show_statistic_controls)
+
+ self._statistics_intro_lbl.setVisible(show_statistics_reference)
+ for label in getattr(self, "_statistic_group_labels", ()):
+ label.setVisible(show_statistic_controls)
+ self._selected_statistic_help_lbl.setVisible(show_statistic_controls)
+ for statistic_id, button in self._statistic_buttons.items():
+ visible = show_statistics_reference or (
+ show_statistic_controls and statistic_id == step.focus_statistic
+ )
+ button.setVisible(visible)
+ label = self._statistic_description_labels.get(statistic_id)
+ if label is not None:
+ label.setVisible(False)
+
+ self._result_view.set_technical_details_visible(bool(step.show_technical_details))
+ self._refresh_generated_banner()
+
+ def _tutorial_by_key(self, key: str) -> ParticleTutorialExample:
+ for example in _TUTORIALS:
+ if example.key == key:
+ return example
+ return _TUTORIALS[0]
+
+ def _current_tutorial(self) -> ParticleTutorialExample:
+ return self._tutorial_by_key(self.current_tutorial_key)
+
+ def _current_tutorial_step_obj(self) -> ParticleTutorialStep:
+ tutorial = self._current_tutorial()
+ index = max(0, min(self._tutorial_step_index, len(tutorial.steps) - 1))
+ self._tutorial_step_index = index
+ return tutorial.steps[index]
+
+ def _next_tutorial_key(self) -> str:
+ current = self.current_tutorial_key
+ for index, example in enumerate(_TUTORIALS):
+ if example.key == current and index < len(_TUTORIALS) - 1:
+ return _TUTORIALS[index + 1].key
+ return ""
+
+ def _next_tutorial_title(self) -> str:
+ key = self._next_tutorial_key()
+ return self._tutorial_by_key(key).title if key else ""
+
+ def _on_tutorial_changed(self) -> None:
+ self._tutorial_step_index = 0
+ self.load_current_tutorial_example()
+
+ def start_tutorial(self) -> None:
+ """Enter tutorial mode at the first guided example (the workspace tour)."""
+ self.load_tutorial_example(_TUTORIALS[0].key)
+
+ def restart_tutorial(self) -> None:
+ """Return to the first guided example (the workspace tour) at step one."""
+ self.load_tutorial_example(_TUTORIALS[0].key)
+
+ def exit_tutorial(self) -> None:
+ """Leave the guided tutorial and switch to the real-data (Analyze) workflow."""
+ # Invalidate any in-flight tutorial comparison so its late result cannot
+ # repopulate the real view, then switch and clear.
+ self._sandbox_generation += 1
+ self._tutorial_run_in_progress = False
+ self._tutorial_active = False
+ self._clear_tutorial_highlights()
+ self._set_mode("real", tutorial_active=False)
+ self.clear_real_view()
+ # Switching modes rebuilds the field/tabs, which can drop this modeless
+ # dialog behind the Browse/Image Viewer windows on macOS. Keep it in front
+ # so exiting the tutorial leaves the user on Particle Statistics.
+ self._raise_self()
+
+ def clear_real_view(self) -> None:
+ """Reset the real-data field, focused statistic, and result panels to empty."""
+ self._set_result_view_spec(
+ _empty_view_spec("Run a comparison to populate result panels."),
+ source_label="Particle Statistics",
+ data_mode="real",
+ )
+ self._refresh_real_field()
+ self._status_lbl.setText(_REAL_EMPTY_STATE_MESSAGE)
+
+ def load_tutorial_example(self, key: str) -> None:
+ _set_combo_value(self._tutorial_cb, key)
+ self._tutorial_step_index = 0
+ step = self._current_tutorial_step_obj()
+ self._tutorial_active = True
+ self._set_mode(self._tutorial_step_mode(step), tutorial_active=True)
+ self._apply_tutorial_step(step, stage_generated=True)
+ self._ensure_tutorial_comparison(force=True)
+
+ def load_current_tutorial_example(self) -> None:
+ step = self._current_tutorial_step_obj()
+ self._tutorial_active = True
+ self._set_mode(self._tutorial_step_mode(step), tutorial_active=True)
+ self._apply_tutorial_step(step, stage_generated=True)
+ self._ensure_tutorial_comparison(force=True)
+
+ def _tutorial_step_mode(self, step: ParticleTutorialStep) -> str:
+ mode = str(step.mode).lower()
+ if mode == "real":
+ return "real"
+ if mode == "sandbox":
+ return "sandbox"
+ return "generated"
+
+ def _ensure_tutorial_comparison(self, *, force: bool = False) -> None:
+ """Compute the statistic up front so the chart is live from step one.
+
+ The statistic is the core of this module, so the focus panel should show a
+ real curve immediately rather than a text concept card. Stepping then just
+ toggles emphasis (observed-only -> model envelope) on the existing result.
+
+ Only fires while the dialog is visible: production always shows the window,
+ while bare construction (e.g. in tests) must not spawn a background worker
+ that mutates the shared sandbox state.
+ """
+ if self._active_mode not in {"generated", "sandbox"} or self._sandbox_state is None:
+ return
+ if not self.isVisible():
+ return
+ step = self._current_tutorial_step_obj()
+ if step.intro_card:
+ # Soft-intro cards are read-only; the panels are set by _apply_intro_card.
+ return
+ if not step.compute_on_show:
+ return
+ if step.pool_images > 0:
+ return
+ if not force and _view_spec_has_result(self._last_view_spec):
+ return
+ self._set_result_view_spec(
+ _empty_view_spec("Computing the statistic…"),
+ source_label="Generated examples",
+ data_mode="sandbox",
+ )
+ self._start_generated_worker("run")
+
+ def run_current_tutorial_example(self) -> None:
+ step = self._current_tutorial_step_obj()
+ self._set_mode(self._tutorial_step_mode(step), tutorial_active=True)
+ self._apply_tutorial_step(step, stage_generated=True)
+ self._tutorial_run_in_progress = True
+ if self._active_mode in {"generated", "sandbox"}:
+ self._start_generated_worker("run")
+ else:
+ self._start_real_worker()
+
+ def previous_tutorial_step(self) -> None:
+ if self._tutorial_step_index <= 0:
+ prev_key = self._prev_tutorial_key()
+ if not prev_key:
+ return
+ prev = self._tutorial_by_key(prev_key)
+ _set_combo_value(self._tutorial_cb, prev_key)
+ self._tutorial_step_index = max(0, len(prev.steps) - 1)
+ else:
+ self._tutorial_step_index -= 1
+ step = self._current_tutorial_step_obj()
+ self._set_mode(self._tutorial_step_mode(step), tutorial_active=True)
+ self._apply_tutorial_step(step, stage_generated=True)
+ self._ensure_tutorial_comparison(force=True)
+
+ def next_tutorial_step(self) -> None:
+ tutorial = self._current_tutorial()
+ if self._tutorial_step_index >= len(tutorial.steps) - 1:
+ next_key = self._next_tutorial_key()
+ if next_key:
+ self.load_tutorial_example(next_key)
+ else:
+ self._apply_tutorial_step(self._current_tutorial_step_obj(), stage_generated=True)
+ return
+ self._tutorial_step_index += 1
+ step = self._current_tutorial_step_obj()
+ self._set_mode(self._tutorial_step_mode(step), tutorial_active=True)
+ self._apply_tutorial_step(step, stage_generated=True)
+ self._ensure_tutorial_comparison(force=True)
+
+ def _on_tutorial_detail_toggled(self, expanded: bool) -> None:
+ if not hasattr(self, "_tutorial_detail_container"):
+ return
+ self._tutorial_detail_btn.setText("Less detail ▾" if expanded else "More detail ▸")
+ has_detail = self._tutorial_look_frame.isVisibleTo(self._tutorial_detail_container) or bool(
+ self._tutorial_look_lbl.text() or self._tutorial_careful_lbl.text()
+ )
+ self._tutorial_detail_container.setVisible(bool(expanded) and has_detail)
+
+ def _apply_tutorial_step(
+ self,
+ step: ParticleTutorialStep,
+ *,
+ stage_generated: bool,
+ ) -> None:
+ self._tutorial_active = True
+ self._set_mode(self._tutorial_step_mode(step), tutorial_active=True)
+ self._set_ordering_for_step(step)
+ if step.intro_card:
+ self._apply_intro_card(step)
+ return
+ if stage_generated and self._active_mode in {"generated", "sandbox"} and self._sandbox_state is not None:
+ config = self._sandbox_state.config
+ pattern = step.pattern or config.pattern
+ n = int(step.n if step.n is not None else config.n)
+ seed = int(step.seed if step.seed is not None else config.seed)
+ simulations = int(step.simulations if step.simulations is not None else config.n_simulations)
+ width_nm = float(step.width_nm if step.width_nm is not None else config.width_nm)
+ height_nm = float(step.height_nm if step.height_nm is not None else config.height_nm)
+ hard_core_radius_nm = float(
+ step.hard_core_radius_nm
+ if step.hard_core_radius_nm is not None
+ else config.hard_core_radius_nm
+ )
+ config_model_radius = getattr(
+ config, "model_hard_core_radius_nm", None
+ )
+ if config_model_radius is None:
+ config_model_radius = config.hard_core_radius_nm
+ model_hard_core_radius_nm = float(
+ step.model_hard_core_radius_nm
+ if step.model_hard_core_radius_nm is not None
+ else config_model_radius
+ )
+ config_lattice = getattr(config, "ordered_island_lattice", "triangular")
+ config_background = getattr(config, "ordered_island_background", "none")
+ ordered_island_lattice = step.ordered_island_lattice or config_lattice
+ ordered_island_background = (
+ step.ordered_island_background or config_background
+ )
+ active_model = str(getattr(self._sandbox_state, "active_model", ""))
+ needs_model_update = bool(step.model and active_model != str(step.model))
+ needs_stage = (
+ str(config.pattern) != str(pattern)
+ or int(config.n) != n
+ or int(config.seed) != seed
+ or int(config.n_simulations) != simulations
+ or float(config.width_nm) != width_nm
+ or float(config.height_nm) != height_nm
+ or float(config.hard_core_radius_nm) != hard_core_radius_nm
+ or float(config_model_radius) != model_hard_core_radius_nm
+ or str(config_lattice) != str(ordered_island_lattice)
+ or str(config_background) != str(ordered_island_background)
+ )
+ if needs_model_update:
+ self._sandbox_state.set_model(step.model)
+ if needs_stage:
+ changes = {
+ "pattern": pattern,
+ "n": n,
+ "seed": seed,
+ "width_nm": width_nm,
+ "height_nm": height_nm,
+ "hard_core_radius_nm": hard_core_radius_nm,
+ "n_simulations": simulations,
+ }
+ if hasattr(config, "model_hard_core_radius_nm"):
+ changes["model_hard_core_radius_nm"] = model_hard_core_radius_nm
+ if hasattr(config, "ordered_island_lattice"):
+ changes["ordered_island_lattice"] = ordered_island_lattice
+ if hasattr(config, "ordered_island_background"):
+ changes["ordered_island_background"] = ordered_island_background
+ self._sandbox_state.stage(**changes)
+ self._sync_generated_controls_from_state()
+ self._refresh_generated_field()
+ elif self._active_mode == "real":
+ if step.model:
+ model = "poisson" if step.model == "homogeneous_poisson" else step.model
+ _set_combo_value(self._real_model_cb, model)
+ if step.simulations is not None:
+ self._real_sim_spin.setValue(int(step.simulations))
+ if step.seed is not None:
+ self._real_seed_spin.setValue(int(step.seed))
+ self._refresh_real_field()
+ self._set_layer_controls_from_step(step)
+ self._field.set_direct_labels(step.direct_labels)
+ self.focus_statistic(
+ step.focus_statistic or _DEFAULT_FOCUS_STATISTIC,
+ curve_mode=self._tutorial_step_curve_mode(step),
+ )
+ self._select_tutorial_tab(step.target_tab)
+ self._refresh_tutorial_text()
+ self._apply_staged_tutorial_visibility(step)
+ self._apply_tutorial_highlights()
+ if step.pool_images > 0:
+ self._run_generated_pooling_demo(step)
+ self._raise_self()
+
+ def _apply_intro_card(self, step: ParticleTutorialStep) -> None:
+ """Soft-intro card: keep the three central panels blank, introducing one
+ region at a time with a short label, so the eye stays on the top-bar text."""
+ region = step.intro_region
+ # Left point field: blank, with the region label only when this card owns it.
+ self._field.set_points(
+ np.empty((0, 2), dtype=float),
+ field_size_nm=(100.0, 100.0),
+ mode="generated",
+ source_label="",
+ region_label="",
+ status=step.intro_panel_text if region == "field" else "",
+ )
+ # Right Field-info panel: blank unless this card introduces it.
+ self._info_lbl.setText(step.intro_panel_text if region == "info" else "")
+ self._status_lbl.setText("")
+ # Centre statistic plot: a neutral placeholder, labelled only on its card.
+ self._focused_statistic = _DEFAULT_FOCUS_STATISTIC
+ self._focus_panel.set_statistic(
+ _DEFAULT_FOCUS_STATISTIC,
+ panel=None,
+ data_mode="sandbox",
+ empty_message=step.intro_panel_text if region == "plot" else " ",
+ )
+ self._sync_statistic_buttons()
+ self._select_tutorial_tab(step.target_tab)
+ self._refresh_tutorial_text()
+ self._apply_staged_tutorial_visibility(step)
+ self._apply_tutorial_highlights()
+ self._raise_self()
+
+ def _run_generated_pooling_demo(self, step: ParticleTutorialStep) -> None:
+ """Pool several independent generated images live, to show statistics smoothing.
+
+ Builds ``step.pool_images`` synthetic random fields (varying seed) into feature
+ sets and pools them with the same engine path as real saved sets, so the
+ learner sees the jagged single-image curve become a smooth pooled one.
+ """
+ if self._sandbox_state is None or not self.isVisible():
+ return
+ from probeflow.measurements.feature_sets import FeatureSet
+
+ config = self._sandbox_state.config
+ base_seed = int(step.seed if step.seed is not None else config.seed)
+ n = int(step.n if step.n is not None else config.n)
+ image_shape = (256, 256)
+ sets: list[Any] = []
+ self._pooling_reference_curve = None
+ for index in range(int(step.pool_images)):
+ cfg = replace(config, pattern="random", n=n, seed=base_seed + index)
+ try:
+ preview = adstat_sandbox_preview(cfg, active_model="homogeneous_poisson")
+ except Exception: # noqa: BLE001 - optional engine errors surface as empty
+ return
+ xy_nm = np.asarray(preview.xy_nm, dtype=float)
+ width_nm = float(preview.width_nm)
+ height_nm = float(preview.height_nm)
+ denom = np.array([max(width_nm, 1e-9), max(height_nm, 1e-9)])
+ points_px = xy_nm / denom * np.array([image_shape[1], image_shape[0]])
+ sets.append(
+ FeatureSet.from_points(
+ name=f"image {index + 1}",
+ points_px=points_px,
+ points_m=xy_nm * 1e-9,
+ scan_range_m=(width_nm * 1e-9, height_nm * 1e-9),
+ image_shape=image_shape,
+ image_label=f"image {index + 1}",
+ )
+ )
+ if len(sets) < 2:
+ return
+ try:
+ single_spec = compare_point_set_record_view_spec(
+ sets[0].to_point_set_record(),
+ models=("poisson",),
+ n_simulations=int(step.simulations or 30),
+ random_seed=0,
+ )
+ self._pooling_reference_curve = _single_curve_reference_from_spec(
+ single_spec,
+ step.focus_statistic or _DEFAULT_FOCUS_STATISTIC,
+ )
+ except Exception: # noqa: BLE001 - optional teaching aid only
+ self._pooling_reference_curve = None
+ self._sandbox_generation += 1
+ generation = self._sandbox_generation
+ self._set_controls_enabled(False)
+ self._set_result_view_spec(
+ _empty_view_spec("Pooling images…"),
+ source_label="Generated examples",
+ data_mode="sandbox",
+ )
+ self._status_lbl.setText(f"Pooling {len(sets)} images…")
+ request = AdStatStatisticsRequest(
+ point_source_label="Pooled images",
+ region_mode="full",
+ roi_or_mask=None,
+ models=("poisson",),
+ n_simulations=int(step.simulations or 30),
+ random_seed=0,
+ )
+ worker = _ParticleFeatureSetWorker(
+ generation=generation, feature_sets=sets, request=request
+ )
+ worker.signals.finished.connect(self._on_pooling_demo_finished)
+ self._pool.start(worker)
+
+ def _on_pooling_demo_finished(self, generation: int, spec: Any, error: str) -> None:
+ if int(generation) != self._sandbox_generation:
+ return
+ self._set_controls_enabled(True)
+ if self._active_mode not in {"generated", "sandbox"}:
+ return
+ if error or spec is None:
+ self._status_lbl.setText(error or "Pooling failed.")
+ self._pooling_reference_curve = None
+ return
+ step = self._current_tutorial_step_obj()
+ if self._pooling_reference_curve is not None:
+ spec = _with_series_reference_curve(
+ spec,
+ step.focus_statistic or _DEFAULT_FOCUS_STATISTIC,
+ self._pooling_reference_curve,
+ )
+ self._pooling_reference_curve = None
+ self._set_result_view_spec(
+ spec, source_label="Generated examples — pooled", data_mode="sandbox"
+ )
+ self.focus_statistic(
+ step.focus_statistic or _DEFAULT_FOCUS_STATISTIC,
+ curve_mode=self._tutorial_step_curve_mode(step),
+ )
+ self._apply_staged_tutorial_visibility(step)
+ self._status_lbl.setText("Pooled comparison complete.")
+ self._raise_self()
+
+ def showEvent(self, event) -> None:
+ super().showEvent(event)
+ # First show in generated mode: compute the statistic so the chart is live.
+ self._ensure_tutorial_comparison()
+
+ def _raise_self(self) -> None:
+ """Keep the dialog above the main window while the tutorial is driven.
+
+ Rebuilding the field, swapping tabs, re-creating plot widgets, and async
+ comparison runs can drop this modeless dialog behind the Browse/main window
+ on macOS. A synchronous ``raise_()`` inside the click handler is too early:
+ Qt/Cocoa can re-order the stack *after* the handler returns. Defer the raise
+ to the next event-loop tick so it lands after the stack has settled.
+ """
+ if not self.isVisible():
+ return
+ QTimer.singleShot(0, self._raise_now)
+
+ def _raise_now(self) -> None:
+ try:
+ if not self.isVisible():
+ return
+ # Raise only this dialog. Raising the owning Image Viewer too (an earlier
+ # belt-and-suspenders) reordered the Image Viewer/Browse windows in the
+ # background on every async completion.
+ self.raise_()
+ self.activateWindow()
+ except Exception:
+ pass
+
+ def _select_tutorial_tab(self, tab_name: str) -> None:
+ if not hasattr(self, "_tabs"):
+ return
+ target = str(tab_name or "").lower()
+ for index in range(self._tabs.count()):
+ if self._tabs.tabText(index).lower() == target:
+ self._tabs.setCurrentIndex(index)
+ return
+
+ def _refresh_tutorial_text(self) -> None:
+ if not hasattr(self, "_tutorial_step_lbl"):
+ return
+ tutorial = self._current_tutorial()
+ step = self._current_tutorial_step_obj()
+ has_next = (
+ self._tutorial_step_index < len(tutorial.steps) - 1
+ or bool(self._next_tutorial_key())
+ )
+ has_prev = self._tutorial_step_index > 0 or bool(self._prev_tutorial_key())
+
+ # Prominent current-lesson title + linear progress.
+ self._tutorial_title_lbl.setText(step.title)
+ position, total = self._tutorial_position()
+ self._tutorial_progress_lbl.setText(f"Lesson {position} of {total}")
+
+ # Navigation always available and named by its destination, so the eye
+ # never confuses a forward action with the current lesson.
+ self._prev_tutorial_btn.setVisible(True)
+ self._prev_tutorial_btn.setEnabled(has_prev)
+ self._prev_tutorial_btn.setText(
+ self._elide("◂ " + (self._prev_step_title() or "Previous"))
+ )
+ self._next_tutorial_btn.setVisible(True)
+ self._next_tutorial_btn.setEnabled(has_next)
+ self._next_tutorial_btn.setText(
+ self._elide((self._next_step_title() or "Finish") + " ▸")
+ )
+
+ action_button = self._tutorial_action_button(step)
+ run_text = step.primary_action or step.action_text or "Run this example"
+ self._run_tutorial_btn.setText(run_text if action_button == "run" else "Run this example")
+ self._load_tutorial_btn.setText(
+ step.primary_action or step.action_text or "Load example"
+ )
+ # The call-to-action button is only for steps that actually run or load;
+ # navigation steps are handled by Previous/Next above.
+ self._load_tutorial_btn.setVisible(action_button == "load")
+ self._run_tutorial_btn.setVisible(action_button == "run")
+ self._restart_tutorial_btn.setVisible(True)
+ self._restart_tutorial_btn.setText("Restart tutorial")
+
+ # Body: the question, what to look for, and context chips (title is above).
+ question = step.question or step.body
+ look_for = step.look_for or step.statistic_hint
+ parts = [question]
+ if look_for:
+ parts.append(f"Look for: {look_for}")
+ context_chips = []
+ if step.model_label:
+ context_chips.append(f"Model: {step.model_label}")
+ if step.statistic_label:
+ context_chips.append(f"Statistic: {step.statistic_label}")
+ if context_chips:
+ parts.append(
+ ""
+ + " ".join(context_chips)
+ + ""
+ )
+ if hasattr(self, "_tutorial_why_lbl"):
+ self._tutorial_why_lbl.setText("")
+ self._tutorial_why_frame.setVisible(False)
+ self._tutorial_step_lbl.setText("
".join(p for p in parts if p))
+ self._refresh_tutorial_detail(step)
+ self._refresh_tutorial_action_style()
+
+ @staticmethod
+ def _elide(text: str, limit: int = 30) -> str:
+ text = str(text)
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
+
+ def _tutorial_position(self) -> tuple[int, int]:
+ """1-based index of the current step across all examples, and the total."""
+ total = 0
+ position = 1
+ current_key = self.current_tutorial_key
+ for example in _TUTORIALS:
+ if example.key == current_key:
+ position = total + min(self._tutorial_step_index, len(example.steps) - 1) + 1
+ total += len(example.steps)
+ return position, max(total, 1)
+
+ def _prev_tutorial_key(self) -> str:
+ current = self.current_tutorial_key
+ for index, example in enumerate(_TUTORIALS):
+ if example.key == current and index > 0:
+ return _TUTORIALS[index - 1].key
+ return ""
+
+ def _prev_step_title(self) -> str:
+ tutorial = self._current_tutorial()
+ if self._tutorial_step_index > 0:
+ return tutorial.steps[self._tutorial_step_index - 1].title
+ prev_key = self._prev_tutorial_key()
+ if prev_key:
+ prev = self._tutorial_by_key(prev_key)
+ return prev.steps[-1].title if prev.steps else prev.title
+ return ""
+
+ def _next_step_title(self) -> str:
+ tutorial = self._current_tutorial()
+ if self._tutorial_step_index < len(tutorial.steps) - 1:
+ return tutorial.steps[self._tutorial_step_index + 1].title
+ next_key = self._next_tutorial_key()
+ if next_key:
+ nxt = self._tutorial_by_key(next_key)
+ return nxt.steps[0].title if nxt.steps else nxt.title
+ return ""
+
+ def _refresh_tutorial_detail(self, step: ParticleTutorialStep) -> None:
+ if not hasattr(self, "_tutorial_detail_container"):
+ return
+ look_rows = []
+ for label, value in (
+ ("Model", step.model_label),
+ ("Statistic", step.statistic_label),
+ ("More detail", step.more_detail),
+ ("Change", step.what_changes),
+ ("Expected", step.expected_effect),
+ ("Check", step.where_to_check),
+ ):
+ if value:
+ look_rows.append(f"{label}: {value}")
+ self._tutorial_look_lbl.setText("
".join(look_rows))
+ self._tutorial_look_frame.setVisible(bool(look_rows))
+ caution = step.caution or step.limitation
+ self._tutorial_careful_lbl.setText(
+ f"Careful: {caution}" if caution else ""
+ )
+ self._tutorial_careful_frame.setVisible(bool(caution))
+ has_detail = bool(look_rows or caution)
+ self._tutorial_look_frame.setVisible(bool(look_rows))
+ self._tutorial_detail_btn.setEnabled(has_detail)
+ self._tutorial_detail_container.setVisible(has_detail and self._tutorial_detail_btn.isChecked())
+
+ def _tutorial_action_button(self, step: ParticleTutorialStep) -> str:
+ action = str(step.action_kind or step.action_button or "").lower()
+ if action == "run":
+ return "run"
+ if action == "load":
+ return "load"
+ if action == "restart":
+ return "restart"
+ if action == "next_example":
+ return "next_example" if self._next_tutorial_key() else "none"
+ if self._next_tutorial_btn.isEnabled():
+ return "next"
+ return "none"
+
+ def _refresh_tutorial_action_style(self) -> None:
+ if not hasattr(self, "_run_tutorial_btn"):
+ return
+ buttons = (
+ self._load_tutorial_btn,
+ self._run_tutorial_btn,
+ self._prev_tutorial_btn,
+ self._next_tutorial_btn,
+ self._restart_tutorial_btn,
+ )
+ for button in buttons:
+ button.setStyleSheet("")
+ # The red Exit button keeps its style at all times.
+ self._exit_tutorial_btn.setStyleSheet(_TUTORIAL_EXIT_STYLE)
+ if not self._tutorial_active:
+ return
+ action_button = self._tutorial_action_button(self._current_tutorial_step_obj())
+ if action_button in {"next", "next_example"} and self._next_tutorial_btn.isEnabled():
+ self._next_tutorial_btn.setStyleSheet(_TUTORIAL_ACTION_STYLE)
+ elif action_button == "run" and self._run_tutorial_btn.isEnabled():
+ self._run_tutorial_btn.setStyleSheet(_TUTORIAL_ACTION_STYLE)
+ elif action_button == "load" and self._load_tutorial_btn.isEnabled():
+ self._load_tutorial_btn.setStyleSheet(_TUTORIAL_ACTION_STYLE)
+ elif action_button == "restart":
+ self._restart_tutorial_btn.setStyleSheet(_TUTORIAL_ACTION_STYLE)
+ self._apply_tutorial_highlights()
+
+ def _tutorial_control_widgets(self) -> dict[str, tuple[QWidget, ...]]:
+ statistic = getattr(self, "_statistic_buttons", {})
+ return {
+ "mode": (self._mode_cb,),
+ "pattern": (self._pattern_cb,),
+ "ordered_lattice": (self._ordered_lattice_cb,),
+ "ordered_background": (self._ordered_background_cb,),
+ "n": (self._n_spin,),
+ "generated_seed": (self._seed_spin,),
+ "generated_model": (self._generated_model_cb,),
+ "generated_simulations": (self._sim_spin,),
+ "field_size": (self._width_spin, self._height_spin),
+ "data_hard_core_radius": (self._hard_core_radius_spin,),
+ "link_hard_core_radii": (self._link_hard_core_radii_cb,),
+ "hard_core_radius": (self._model_hard_core_radius_spin,),
+ "model_hard_core_radius": (self._model_hard_core_radius_spin,),
+ "source": (self._source_cb,),
+ "region": (self._region_cb,),
+ "real_model": (self._real_model_cb,),
+ "real_simulations": (self._real_sim_spin,),
+ "real_seed": (self._real_seed_spin,),
+ "feature_sets": (self._feature_sets_list,),
+ "feature_layer": (self._feature_layer_set_cb,),
+ "run_comparison": (self._run_btn,),
+ "run_tutorial": (self._run_tutorial_btn,),
+ "run_selected_sets": (self._run_feature_sets_btn,),
+ "load_tutorial": (self._load_tutorial_btn,),
+ "next": (self._next_tutorial_btn,),
+ "restart": (self._restart_tutorial_btn,),
+ "layer_observed": (self._observed_layer_cb,),
+ "layer_simulated": (self._simulation_layer_cb,),
+ "layer_features": (self._feature_layer_cb,),
+ "layer_region": (self._region_layer_cb,),
+ "stat_pair": tuple(
+ w for w in (statistic.get("pair_correlation_g_r"),) if w is not None
+ ),
+ "stat_nearest": tuple(
+ w for w in (statistic.get("nearest_neighbor_distribution"),) if w is not None
+ ),
+ "stat_ripley": tuple(
+ w for w in (statistic.get("ripley_l_function"),) if w is not None
+ ),
+ "stat_clusters": tuple(
+ w for w in (statistic.get("cluster_size_counts"),) if w is not None
+ ),
+ "stat_directional": tuple(
+ w for w in (statistic.get("pair_correlation_g_r_theta"),) if w is not None
+ ),
+ "stat_psi6": tuple(
+ w for w in (statistic.get("bond_order_psi6"),) if w is not None
+ ),
+ "stat_psi4": tuple(
+ w for w in (statistic.get("bond_order_psi4"),) if w is not None
+ ),
+ }
+
+ def _tutorial_action_control(self, step: ParticleTutorialStep) -> str:
+ action = self._tutorial_action_button(step)
+ if action in {"next", "next_example"}:
+ return "next"
+ if action == "run":
+ return (
+ "run_tutorial"
+ if self._active_mode in {"generated", "sandbox"} and self._tutorial_active
+ else "run_comparison"
+ )
+ if action == "load":
+ return "load_tutorial"
+ if action == "restart":
+ return "restart"
+ return ""
+
+ def _clear_tutorial_highlights(self) -> None:
+ if not hasattr(self, "_run_tutorial_btn") or self._updating_tutorial_highlights:
+ return
+ self._updating_tutorial_highlights = True
+ try:
+ seen: set[int] = set()
+ for widgets in self._tutorial_control_widgets().values():
+ for widget in widgets:
+ if id(widget) in seen:
+ continue
+ seen.add(id(widget))
+ if isinstance(widget, QPushButton) and widget in self._statistic_buttons.values():
+ style = _STAT_SELECTED_STYLE if widget.isChecked() else ""
+ elif widget is self._exit_tutorial_btn:
+ style = _TUTORIAL_EXIT_STYLE
+ elif widget is self._start_tutorial_btn:
+ style = _TUTORIAL_ACTION_STYLE
+ else:
+ style = ""
+ widget.setStyleSheet(style)
+ self._exit_tutorial_btn.setStyleSheet(_TUTORIAL_EXIT_STYLE)
+ finally:
+ self._updating_tutorial_highlights = False
+
+ def _apply_tutorial_highlights(self) -> None:
+ if not getattr(self, "_tutorial_active", False):
+ self._clear_tutorial_highlights()
+ return
+ if not hasattr(self, "_tutorial_cb") or self._updating_tutorial_highlights:
+ return
+ self._updating_tutorial_highlights = True
+ try:
+ self._updating_tutorial_highlights = False
+ self._clear_tutorial_highlights()
+ self._updating_tutorial_highlights = True
+ registry = self._tutorial_control_widgets()
+ tutorial = self._current_tutorial()
+ step = self._current_tutorial_step_obj()
+ active = list(self._tutorial_step_controls(step))
+ action_control = self._tutorial_action_control(step)
+ if action_control:
+ active.append(action_control)
+ active_set = set(active)
+ visited: list[str] = []
+ for earlier in tutorial.steps[: self._tutorial_step_index]:
+ for key in self._tutorial_step_controls(earlier):
+ if key not in active_set and key not in visited:
+ visited.append(key)
+ for key in visited:
+ for widget in registry.get(key, ()):
+ widget.setStyleSheet(_TUTORIAL_VISITED_CONTROL_STYLE)
+ for key in active:
+ for widget in registry.get(key, ()):
+ if isinstance(widget, QPushButton):
+ widget.setStyleSheet(_TUTORIAL_ACTION_STYLE)
+ else:
+ widget.setStyleSheet(_TUTORIAL_ACTIVE_CONTROL_STYLE)
+ finally:
+ self._updating_tutorial_highlights = False
+
+ def _on_mode_changed(self) -> None:
+ if self._updating_mode:
+ return
+ mode = str(self._mode_cb.currentData() or "real")
+ tutorial = mode == "generated"
+ self._set_mode(mode, tutorial_active=tutorial)
+ if tutorial:
+ self._apply_tutorial_step(self._current_tutorial_step_obj(), stage_generated=True)
+ self._ensure_tutorial_comparison()
+ elif mode == "sandbox":
+ self._refresh_generated_field()
+ else:
+ self._clear_tutorial_highlights()
+
+ def _set_mode(self, mode: str, *, tutorial_active: bool | None = None) -> None:
+ mode = mode if mode in {"landing", "real", "generated", "sandbox"} else "real"
+ if tutorial_active is not None:
+ self._tutorial_active = bool(tutorial_active)
+ if mode == "landing":
+ self._tutorial_active = False
+ previous = self._active_mode
+ is_landing = mode == "landing"
+ is_sandbox_like = mode in {"generated", "sandbox"}
+ target_index = {"real": 0, "generated": 1, "sandbox": 2}.get(mode)
+ if target_index is not None and self._mode_cb.currentIndex() != target_index:
+ self._updating_mode = True
+ try:
+ self._mode_cb.setCurrentIndex(target_index)
+ finally:
+ self._updating_mode = False
+ self._active_mode = mode
+ self._landing_panel.setVisible(is_landing)
+ self._workspace_panel.setVisible(not is_landing)
+ self._landing_btn.setVisible(not is_landing and not self._tutorial_active)
+ self._mode_label.setVisible(not is_landing and not self._tutorial_active)
+ self._mode_cb.setVisible(not is_landing and not self._tutorial_active)
+ self._run_btn.setVisible(not is_landing and not self._tutorial_active)
+ self._tutorial_panel.setVisible(self._tutorial_active)
+ self._start_tutorial_btn.setVisible(False)
+ if is_landing:
+ self._run_btn.setEnabled(False)
+ self._clear_tutorial_highlights()
+ self._sync_workflow_actions()
+ self._raise_self()
+ return
+ self._real_data_group.setVisible(not is_sandbox_like)
+ self._real_model_group.setVisible(not is_sandbox_like)
+ if hasattr(self, "_feature_sets_group"):
+ self._feature_sets_group.setVisible(not is_sandbox_like)
+ if hasattr(self, "_generated_data_group"):
+ self._generated_data_group.setTitle(
+ "Generated pattern" if mode == "sandbox" else "Generated / fake data"
+ )
+ if hasattr(self, "_generated_model_group"):
+ self._generated_model_group.setTitle(
+ "Comparison model" if mode == "sandbox" else "Model comparison"
+ )
+ self._generated_data_group.setVisible(is_sandbox_like)
+ self._generated_model_group.setVisible(is_sandbox_like)
+ if previous != mode:
+ self._set_result_view_spec(
+ _empty_view_spec("Run a comparison to populate result panels."),
+ source_label=self._sandbox_source_label() if is_sandbox_like else "Particle Statistics",
+ data_mode="sandbox" if is_sandbox_like else "real",
+ )
+ if is_sandbox_like:
+ self._refresh_generated_field()
+ self._refresh_tutorial_text()
+ else:
+ self._refresh_real_field()
+ self._refresh_tutorial_text()
+ if self._tutorial_active:
+ self._apply_staged_tutorial_visibility(self._current_tutorial_step_obj())
+ self._apply_tutorial_highlights()
+ else:
+ self._apply_staged_tutorial_visibility(None)
+ self._clear_tutorial_highlights()
+ self._sync_workflow_actions()
+ self._raise_self()
+
+ def _sync_workflow_actions(self) -> None:
+ if not hasattr(self, "_run_comparison_action"):
+ return
+ is_landing = self._active_mode == "landing"
+ is_tutorial = bool(self._tutorial_active)
+ self._show_workflows_action.setEnabled(not is_landing)
+ self._analyze_scan_points_action.setEnabled(not (self._active_mode == "real" and not is_tutorial))
+ self._model_simulations_action.setEnabled(self._active_mode != "sandbox")
+ self._start_tutorial_action.setEnabled(not is_tutorial)
+ self._run_comparison_action.setEnabled(
+ bool(self._run_btn.isEnabled() and self._run_btn.isVisible())
+ )
+ if not hasattr(self, "_show_observed_action"):
+ return
+ is_sandbox_like = self._active_mode in {"generated", "sandbox"}
+ workspace_active = not is_landing
+ global_controls_enabled = workspace_active and not is_tutorial
+ for action, checkbox in (
+ (self._show_observed_action, getattr(self, "_observed_layer_cb", None)),
+ (self._show_model_action, getattr(self, "_simulation_layer_cb", None)),
+ (self._show_feature_layer_action, getattr(self, "_feature_layer_cb", None)),
+ (self._show_region_action, getattr(self, "_region_layer_cb", None)),
+ ):
+ if isinstance(checkbox, QCheckBox):
+ action.setChecked(checkbox.isChecked())
+ action.setEnabled(workspace_active and checkbox.isEnabled())
+ else:
+ action.setEnabled(False)
+ self._new_pattern_action.setEnabled(
+ global_controls_enabled and is_sandbox_like and self._new_pattern_btn.isEnabled()
+ )
+ self._reset_pattern_action.setEnabled(
+ global_controls_enabled and is_sandbox_like and self._reset_btn.isEnabled()
+ )
+ self._refresh_sources_action.setEnabled(
+ global_controls_enabled and self._active_mode == "real"
+ )
+ self._clear_real_action.setEnabled(
+ global_controls_enabled and self._active_mode == "real"
+ )
+ if hasattr(self, "_link_hard_core_radii_cb"):
+ self._link_hard_core_radii_action.setChecked(
+ self._link_hard_core_radii_cb.isChecked()
+ )
+ self._link_hard_core_radii_action.setEnabled(
+ global_controls_enabled and self._link_hard_core_radii_cb.isEnabled()
+ )
+ current_model = (
+ str(self._generated_model_cb.currentData() or "")
+ if is_sandbox_like
+ else str(self._real_model_cb.currentData() or "")
+ )
+ if current_model == "homogeneous_poisson":
+ current_model = "poisson"
+ for model_id, action in self._model_actions.items():
+ action.setChecked(model_id == current_model)
+ action.setEnabled(global_controls_enabled)
+ for statistic_id, action in self._statistic_actions.items():
+ action.setChecked(statistic_id == self._focused_statistic)
+ action.setEnabled(workspace_active and not is_tutorial)
+ self._show_definitions_action.setEnabled(True)
+ self._definitions_tutorial_action.setEnabled(not is_tutorial)
+
+ def _populate_sources(self) -> None:
+ self._source_cb.clear()
+ for source in self._point_sources:
+ label = str(getattr(source, "label", "") or "Point source")
+ source_type = str(getattr(source, "source_type", "") or "points")
+ self._source_cb.addItem(
+ f"{label} ({_point_count(source)} points, {source_type.replace('_', ' ')})",
+ label,
+ )
+ if not self._point_sources:
+ self._source_cb.addItem("No point source available", "")
+
+ def _sync_feature_sets_from_store(self) -> None:
+ """Resync the cached set list from the shared store, when one is present."""
+ if self._feature_set_store_ref is not None:
+ self._feature_sets = list(self._feature_set_store_ref.all())
+
+ def add_feature_sets(self, sets: Any, *, select_first: bool = True) -> None:
+ """Add imported/built feature sets to the store and refresh the list."""
+ added = list(sets or ())
+ if not added:
+ return
+ if self._feature_set_store_ref is not None:
+ for fs in added:
+ self._feature_set_store_ref.add(fs)
+ else:
+ self._feature_sets = list(self._feature_sets) + added
+ self._set_mode("real")
+ self._populate_feature_sets()
+ if select_first:
+ self.select_feature_set(added[0].set_id)
+
+ def import_points_from_disk(self) -> None:
+ """Import a CSV/JSON point table from disk as one or more feature sets."""
+ from PySide6.QtWidgets import QFileDialog, QMessageBox
+
+ from probeflow.measurements.point_table_io import (
+ load_point_table,
+ sniff_point_table,
+ )
+
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Import point table",
+ "",
+ "Point tables (*.csv *.json);;CSV files (*.csv);;JSON files (*.json);;All files (*)",
+ )
+ if not path:
+ return
+ try:
+ preview = sniff_point_table(path)
+ except Exception as exc: # noqa: BLE001 - report to the user
+ QMessageBox.warning(self, "Import failed", f"Could not read file:\n{exc}")
+ return
+
+ units = scan_range_m = image_shape = None
+ if preview.needs_calibration:
+ from probeflow.gui.dialogs.import_points import ImportPointsDialog
+
+ cal_dlg = ImportPointsDialog(preview, theme=self._theme, parent=self)
+ if cal_dlg.exec() != QDialog.Accepted:
+ return
+ units, scan_range_m, image_shape = cal_dlg.result_calibration()
+
+ try:
+ sets = load_point_table(
+ path, units=units, scan_range_m=scan_range_m, image_shape=image_shape
+ )
+ except Exception as exc: # noqa: BLE001 - report to the user
+ QMessageBox.warning(self, "Import failed", f"Could not load points:\n{exc}")
+ return
+ if not sets:
+ self._status_lbl.setText("No points found in file.")
+ return
+ self.add_feature_sets(sets)
+ total = sum(s.point_count for s in sets)
+ self._status_lbl.setText(
+ f"Imported {len(sets)} feature set(s), {total} points. Tick to analyse."
+ )
+
+ def save_feature_sets_to_disk(self) -> None:
+ """Save all current feature sets to a JSON file (round-trips on import)."""
+ from PySide6.QtWidgets import QFileDialog, QMessageBox
+
+ from probeflow.measurements.feature_sets import FeatureSetStore
+
+ self._sync_feature_sets_from_store()
+ if not self._feature_sets:
+ self._status_lbl.setText("No feature sets to save.")
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self, "Save feature sets", "probeflow_feature_sets.json", "JSON files (*.json)"
+ )
+ if not path:
+ return
+ store = self._feature_set_store_ref or FeatureSetStore(list(self._feature_sets))
+ try:
+ store.save(path)
+ except Exception as exc: # noqa: BLE001 - report to the user
+ QMessageBox.warning(self, "Save failed", str(exc))
+ return
+ self._status_lbl.setText(f"Saved {len(self._feature_sets)} feature set(s) to disk.")
+
+ def _export_base_label(self) -> str:
+ label = self._current_source_label() if hasattr(self, "_source_cb") else ""
+ return label or "particle_statistics"
+
+ def _export_results_csv(self) -> None:
+ """Export per-statistic curve CSVs + a verdicts CSV to a chosen folder."""
+ from PySide6.QtWidgets import QFileDialog, QMessageBox
+
+ if not _view_spec_has_result(self._last_view_spec):
+ self._status_lbl.setText("Run a comparison before exporting.")
+ return
+ out_dir = QFileDialog.getExistingDirectory(
+ self, "Export statistics CSVs to folder"
+ )
+ if not out_dir:
+ return
+ from probeflow.measurements.adstat_export import export_result_csvs
+
+ try:
+ written = export_result_csvs(
+ self._last_view_spec, out_dir, base=self._export_base_label()
+ )
+ except Exception as exc: # noqa: BLE001 - report to the user
+ QMessageBox.warning(self, "Export failed", str(exc))
+ return
+ if not written:
+ self._status_lbl.setText("No curve/verdict data to export for this result.")
+ return
+ self._status_lbl.setText(f"Exported {len(written)} CSV file(s) to {out_dir}.")
+
+ def _export_results_json(self) -> None:
+ """Export the entire result view spec to a single JSON file."""
+ from PySide6.QtWidgets import QFileDialog, QMessageBox
+
+ if not _view_spec_has_result(self._last_view_spec):
+ self._status_lbl.setText("Run a comparison before exporting.")
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Export result JSON",
+ "particle_statistics_result.json",
+ "JSON files (*.json)",
+ )
+ if not path:
+ return
+ from probeflow.measurements.adstat_export import export_result_json
+
+ try:
+ export_result_json(self._last_view_spec, path)
+ except Exception as exc: # noqa: BLE001 - report to the user
+ QMessageBox.warning(self, "Export failed", str(exc))
+ return
+ self._status_lbl.setText("Exported result JSON.")
+
+ def _populate_feature_sets(self) -> None:
+ if not hasattr(self, "_feature_sets_list"):
+ return
+ self._sync_feature_sets_from_store()
+ checked_ids = set(self._checked_feature_set_ids())
+ self._feature_sets_list.clear()
+ for fs in self._feature_sets:
+ label = f"{fs.name} · {fs.point_count} pts"
+ item = QListWidgetItem(label, self._feature_sets_list)
+ item.setData(Qt.UserRole, fs.set_id)
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
+ item.setCheckState(
+ Qt.Checked if fs.set_id in checked_ids else Qt.Unchecked
+ )
+ empty = not self._feature_sets
+ self._feature_sets_group.setVisible(self._active_mode not in {"generated", "sandbox"})
+ self._run_feature_sets_btn.setEnabled(not empty)
+ if empty:
+ placeholder = QListWidgetItem(
+ "No saved sets yet — use 'Send to Particle Statistics' in Feature Finder.",
+ self._feature_sets_list,
+ )
+ placeholder.setFlags(Qt.NoItemFlags)
+ if hasattr(self, "_feature_layer_set_cb"):
+ previous = str(self._feature_layer_set_cb.currentData() or "")
+ self._feature_layer_set_cb.blockSignals(True)
+ self._feature_layer_set_cb.clear()
+ self._feature_layer_set_cb.addItem("(none)", "")
+ for fs in self._feature_sets:
+ self._feature_layer_set_cb.addItem(f"{fs.name} · {fs.point_count} pts", fs.set_id)
+ _set_combo_value(self._feature_layer_set_cb, previous)
+ self._feature_layer_set_cb.blockSignals(False)
+ self._sync_feature_layer_controls()
+
+ def _on_real_model_changed(self) -> None:
+ self._sync_feature_layer_controls()
+ self._refresh_real_field()
+ self._sync_workflow_actions()
+
+ def _sync_feature_layer_controls(self) -> None:
+ """Enable the feature-layer picker only for the measured-feature model."""
+ if not hasattr(self, "_feature_layer_set_cb") or not hasattr(self, "_real_model_cb"):
+ return
+ is_feature = str(self._real_model_cb.currentData() or "") == "measured_feature_poisson"
+ self._feature_layer_set_cb.setEnabled(is_feature)
+
+ def _selected_feature_layer(self) -> Any | None:
+ if not hasattr(self, "_feature_layer_set_cb"):
+ return None
+ set_id = str(self._feature_layer_set_cb.currentData() or "")
+ if not set_id:
+ return None
+ for fs in self._feature_sets:
+ if fs.set_id == set_id:
+ return fs
+ return None
+
+ def _checked_feature_set_ids(self) -> list[str]:
+ if not hasattr(self, "_feature_sets_list"):
+ return []
+ ids: list[str] = []
+ for index in range(self._feature_sets_list.count()):
+ item = self._feature_sets_list.item(index)
+ if item.checkState() == Qt.Checked:
+ set_id = item.data(Qt.UserRole)
+ if set_id:
+ ids.append(str(set_id))
+ return ids
+
+ def _selected_feature_sets(self) -> list[Any]:
+ checked = set(self._checked_feature_set_ids())
+ return [fs for fs in self._feature_sets if fs.set_id in checked]
+
+ def select_feature_set(self, set_id: str) -> None:
+ """Check a saved set by id (used by the Feature Finder hand-off)."""
+ if not hasattr(self, "_feature_sets_list"):
+ return
+ for index in range(self._feature_sets_list.count()):
+ item = self._feature_sets_list.item(index)
+ if str(item.data(Qt.UserRole) or "") == str(set_id):
+ item.setCheckState(Qt.Checked)
+ elif item.flags() & Qt.ItemIsUserCheckable:
+ item.setCheckState(Qt.Unchecked)
+
+ def run_selected_feature_sets(self) -> None:
+ sets = self._selected_feature_sets()
+ if not sets:
+ self._status_lbl.setText("Tick one or more saved feature sets first.")
+ return
+ model = str(self._real_model_cb.currentData() or "poisson")
+ feature_layer = None
+ if model == "measured_feature_poisson":
+ if len(sets) != 1:
+ self._status_lbl.setText(
+ "Measured-feature Poisson runs on a single image — tick exactly one tested set."
+ )
+ return
+ feature_layer = self._selected_feature_layer()
+ if feature_layer is None:
+ self._status_lbl.setText(
+ "Choose an independent Feature layer set for the Measured-feature model."
+ )
+ return
+ if feature_layer.set_id == sets[0].set_id:
+ self._status_lbl.setText(
+ "The feature layer must be a different set from the tested particles."
+ )
+ return
+ self._set_mode("real")
+ self._generation += 1
+ generation = self._generation
+ self._set_controls_enabled(False)
+ if feature_layer is not None:
+ label = f"{sets[0].name} vs feature layer {feature_layer.name}"
+ else:
+ label = sets[0].name if len(sets) == 1 else f"Combined: {len(sets)} images"
+ self._status_lbl.setText(f"Computing {label}…")
+ request = AdStatStatisticsRequest(
+ point_source_label=label,
+ region_mode="full",
+ roi_or_mask=None,
+ models=(model,),
+ n_simulations=int(self._real_sim_spin.value()),
+ random_seed=int(self._real_seed_spin.value()),
+ )
+ worker = _ParticleFeatureSetWorker(
+ generation=generation,
+ feature_sets=sets,
+ request=request,
+ feature_layer=feature_layer,
+ )
+ worker.signals.finished.connect(self._on_feature_sets_worker_finished)
+ self._pool.start(worker)
+
+ def _on_feature_sets_worker_finished(self, generation: int, spec: Any, error: str) -> None:
+ if int(generation) != self._generation:
+ return
+ self._set_controls_enabled(True)
+ if error or spec is None:
+ message = error or "Combined comparison failed."
+ self._status_lbl.setText(message)
+ self._set_result_view_spec(
+ _empty_view_spec(message), source_label="Feature sets", data_mode="real"
+ )
+ return
+ sets = self._selected_feature_sets()
+ total = sum(fs.point_count for fs in sets)
+ label = (
+ sets[0].name if len(sets) == 1
+ else f"Combined: {len(sets)} images, {total} points"
+ )
+ self._set_result_view_spec(spec, source_label=label, data_mode="real")
+ self._status_lbl.setText(f"Comparison complete for {label}.")
+ self._raise_self()
+
+ def _populate_regions(self) -> None:
+ self._region_cb.clear()
+ if self._active_area_roi is not None:
+ self._region_cb.addItem("Active area ROI", "roi")
+ if self._active_mask is not None:
+ self._region_cb.addItem("Active mask", "mask")
+ self._region_cb.addItem("Full image", "full")
+
+ def _sync_generated_controls_from_state(self) -> None:
+ if self._sandbox_state is None:
+ for widget in self._generated_controls:
+ widget.setEnabled(False)
+ return
+ config = self._sandbox_state.config
+ self._updating_generated_controls = True
+ try:
+ _set_combo_value(self._pattern_cb, config.pattern)
+ _set_combo_value(self._generated_model_cb, self._sandbox_state.active_model)
+ self._n_spin.setValue(int(config.n))
+ self._width_spin.setValue(float(config.width_nm))
+ self._height_spin.setValue(float(config.height_nm))
+ self._seed_spin.setValue(int(config.seed))
+ self._sim_spin.setValue(int(config.n_simulations))
+ self._hard_core_radius_spin.setValue(float(config.hard_core_radius_nm))
+ model_radius = getattr(config, "model_hard_core_radius_nm", None)
+ if model_radius is None:
+ model_radius = config.hard_core_radius_nm
+ if (
+ hasattr(self, "_link_hard_core_radii_cb")
+ and self._link_hard_core_radii_cb.isChecked()
+ ):
+ model_radius = config.hard_core_radius_nm
+ self._model_hard_core_radius_spin.setValue(float(model_radius))
+ if hasattr(config, "ordered_island_lattice"):
+ _set_combo_value(
+ self._ordered_lattice_cb,
+ getattr(config, "ordered_island_lattice", "triangular"),
+ )
+ if hasattr(config, "ordered_island_background"):
+ _set_combo_value(
+ self._ordered_background_cb,
+ getattr(config, "ordered_island_background", "none"),
+ )
+ finally:
+ self._updating_generated_controls = False
+ self._sync_generated_model_radius_controls()
+ self._sync_ordered_island_controls()
+ self._refresh_sandbox_warning()
+
+ def _refresh_sandbox_warning(self) -> None:
+ if not hasattr(self, "_sandbox_warning_lbl"):
+ return
+ if self._sandbox_state is None:
+ self._sandbox_warning_lbl.setText("")
+ return
+ config = self._sandbox_state.config
+ area = max(float(config.width_nm) * float(config.height_nm), 1e-9)
+ model_radius = getattr(config, "model_hard_core_radius_nm", None)
+ if model_radius is None:
+ model_radius = config.hard_core_radius_nm
+ radius_for_warning = max(
+ float(config.hard_core_radius_nm), float(model_radius)
+ )
+ exclusion_area = (
+ int(config.n) * math.pi * (radius_for_warning / 2.0) ** 2
+ )
+ warnings = list(getattr(self._sandbox_state, "warnings", ()) or ())
+ if exclusion_area / area > 0.25:
+ warnings.append(
+ "Large N or hard-core radius can be slow because overlapping placements "
+ "are rejected."
+ )
+ self._sandbox_warning_lbl.setText("\n".join(warnings))
+
+ def _refresh_real_field(self) -> None:
+ if self._active_mode in {"landing", "generated", "sandbox"}:
+ return
+ source = self._selected_source()
+ width_nm, height_nm = _field_size_nm(self._scan, self._image_shape)
+ mask, region_label = self._selected_region_mask_and_label()
+ if source is None and not self._feature_sets:
+ status = _REAL_EMPTY_STATE_MESSAGE
+ points = np.empty((0, 2), dtype=float)
+ source_label = ""
+ elif source is None:
+ status = "Tick a saved feature set below and Run selected sets, or detect points with Feature Finder."
+ points = np.empty((0, 2), dtype=float)
+ source_label = ""
+ else:
+ points = _source_points_nm(source)
+ source_label = str(getattr(source, "label", "") or "Point source")
+ status = "Ready. Choose a point source and analysis region, then run comparison."
+ self._field.set_points(
+ points,
+ field_size_nm=(width_nm, height_nm),
+ mode="real",
+ source_label=source_label,
+ region_label=region_label,
+ model_label=str(self._real_model_cb.currentText() or "Homogeneous Poisson"),
+ status=status,
+ mask=mask,
+ )
+ self._update_info(status=status)
+ self._sync_layer_controls()
+ self._run_btn.setEnabled(self._scan is not None and source is not None)
+ self._sync_workflow_actions()
+
+ def _refresh_generated_field(self) -> None:
+ if self._sandbox_state is None:
+ message = self._sandbox_error or "Generated examples require the optional AdStat engine."
+ self._field.set_points(
+ np.empty((0, 2), dtype=float),
+ field_size_nm=(100.0, 100.0),
+ mode="generated",
+ source_label=self._sandbox_source_label(),
+ region_label="Synthetic field",
+ status=message,
+ )
+ self._update_info(status=message)
+ self._sync_layer_controls()
+ self._run_btn.setEnabled(False)
+ self._sync_workflow_actions()
+ return
+ config = self._sandbox_state.config
+ try:
+ preview = adstat_sandbox_preview(
+ config,
+ active_model=str(self._sandbox_state.active_model),
+ )
+ except Exception as exc: # noqa: BLE001 - show optional engine errors in the UI
+ message = f"Could not preview generated pattern: {exc}"
+ self._field.set_points(
+ np.empty((0, 2), dtype=float),
+ field_size_nm=(float(config.width_nm), float(config.height_nm)),
+ mode="generated",
+ source_label=self._sandbox_source_label(),
+ region_label="Synthetic field",
+ status=message,
+ )
+ self._update_info(status=message)
+ self._sync_layer_controls()
+ self._run_btn.setEnabled(False)
+ self._sync_workflow_actions()
+ return
+ status = self._sandbox_state.status_text()
+ self._field.set_points(
+ preview.xy_nm,
+ field_size_nm=(preview.width_nm, preview.height_nm),
+ mode="generated",
+ source_label=(
+ self._sandbox_source_label()
+ if self._active_mode == "sandbox"
+ else _PATTERN_LABELS.get(config.pattern, str(config.pattern))
+ ),
+ region_label="Synthetic field",
+ model_label=_MODEL_LABELS.get(str(self._sandbox_state.active_model), str(self._sandbox_state.active_model)),
+ status=status,
+ simulated_xy_nm=preview.simulated_xy_nm,
+ feature_xy_nm=preview.feature_xy_nm if config.pattern == "feature_biased" else None,
+ )
+ self._update_info(status=status)
+ self._refresh_sandbox_warning()
+ self._sync_layer_controls()
+ self._run_btn.setEnabled(True)
+ self._sync_workflow_actions()
+
+ def _stage_generated_from_controls(self) -> None:
+ if self._updating_generated_controls or self._sandbox_state is None:
+ return
+ try:
+ changes = {
+ "pattern": str(self._pattern_cb.currentData()),
+ "n": int(self._n_spin.value()),
+ "width_nm": float(self._width_spin.value()),
+ "height_nm": float(self._height_spin.value()),
+ "seed": int(self._seed_spin.value()),
+ "hard_core_radius_nm": float(self._hard_core_radius_spin.value()),
+ "n_simulations": int(self._sim_spin.value()),
+ }
+ if hasattr(self._sandbox_state.config, "model_hard_core_radius_nm"):
+ model_radius = self._linked_or_model_hard_core_radius()
+ changes["model_hard_core_radius_nm"] = model_radius
+ if hasattr(self._sandbox_state.config, "ordered_island_lattice"):
+ changes["ordered_island_lattice"] = str(
+ self._ordered_lattice_cb.currentData() or "triangular"
+ )
+ if hasattr(self._sandbox_state.config, "ordered_island_background"):
+ changes["ordered_island_background"] = str(
+ self._ordered_background_cb.currentData() or "none"
+ )
+ self._sandbox_state.stage(**changes)
+ except ValueError as exc:
+ self._status_lbl.setText(str(exc))
+ self._sync_generated_controls_from_state()
+ return
+ self._refresh_sandbox_warning()
+ self._sync_ordered_island_controls()
+ if str(self._mode_cb.currentData() or "real") in {"generated", "sandbox"}:
+ self._refresh_generated_field()
+
+ def _on_generated_model_changed(self) -> None:
+ if self._updating_generated_controls or self._sandbox_state is None:
+ return
+ self._sandbox_state.set_model(str(self._generated_model_cb.currentData()))
+ self._sync_generated_model_radius_controls()
+ self._sync_workflow_actions()
+ if str(self._mode_cb.currentData() or "real") in {"generated", "sandbox"}:
+ self._refresh_generated_field()
+
+ def _sync_generated_model_radius_controls(self) -> None:
+ if not hasattr(self, "_model_hard_core_radius_spin"):
+ return
+ is_hard_core = str(self._generated_model_cb.currentData() or "") == "hard_core_random"
+ linked = (
+ hasattr(self, "_link_hard_core_radii_cb")
+ and self._link_hard_core_radii_cb.isChecked()
+ )
+ if hasattr(self, "_link_hard_core_radii_cb"):
+ self._link_hard_core_radii_cb.setEnabled(
+ self._generated_model_cb.isEnabled() and is_hard_core
+ )
+ self._model_hard_core_radius_spin.setEnabled(
+ self._generated_model_cb.isEnabled() and is_hard_core and not linked
+ )
+ self._sync_workflow_actions()
+
+ def _linked_or_model_hard_core_radius(self) -> float:
+ data_radius = float(self._hard_core_radius_spin.value())
+ linked = (
+ hasattr(self, "_link_hard_core_radii_cb")
+ and self._link_hard_core_radii_cb.isChecked()
+ )
+ if linked:
+ if abs(float(self._model_hard_core_radius_spin.value()) - data_radius) > 1e-9:
+ blocked = self._model_hard_core_radius_spin.blockSignals(True)
+ try:
+ self._model_hard_core_radius_spin.setValue(data_radius)
+ finally:
+ self._model_hard_core_radius_spin.blockSignals(blocked)
+ return data_radius
+ return float(self._model_hard_core_radius_spin.value())
+
+ def _on_link_hard_core_radii_toggled(self, checked: bool) -> None:
+ if not hasattr(self, "_model_hard_core_radius_spin"):
+ return
+ if checked:
+ blocked = self._model_hard_core_radius_spin.blockSignals(True)
+ try:
+ self._model_hard_core_radius_spin.setValue(
+ float(self._hard_core_radius_spin.value())
+ )
+ finally:
+ self._model_hard_core_radius_spin.blockSignals(blocked)
+ self._sync_generated_model_radius_controls()
+ self._stage_generated_from_controls()
+
+ def _sync_ordered_island_controls(self) -> None:
+ if not hasattr(self, "_ordered_lattice_cb"):
+ return
+ is_ordered = str(self._pattern_cb.currentData() or "") == "ordered_islands"
+ for widget in (
+ self._ordered_lattice_lbl,
+ self._ordered_lattice_cb,
+ self._ordered_background_lbl,
+ self._ordered_background_cb,
+ ):
+ widget.setVisible(is_ordered)
+ enabled = self._pattern_cb.isEnabled() and is_ordered
+ self._ordered_lattice_cb.setEnabled(enabled)
+ self._ordered_background_cb.setEnabled(enabled)
+
+ def _run_comparison(self) -> None:
+ if self._active_mode in {"generated", "sandbox"}:
+ self._start_generated_worker("run")
+ elif self._active_mode == "real":
+ self._start_real_worker()
+
+ def _start_real_worker(self) -> None:
+ if str(self._real_model_cb.currentData() or "") == "measured_feature_poisson":
+ self._status_lbl.setText(
+ "Measured-feature Poisson uses saved sets: tick one tested set, pick a "
+ "Feature layer set, then 'Run selected sets'."
+ )
+ return
+ source = self._selected_source()
+ if self._scan is None or source is None:
+ self._refresh_real_field()
+ return
+ self._generation += 1
+ generation = self._generation
+ self._set_controls_enabled(False)
+ self._status_lbl.setText("computing...")
+ worker = _ParticleRealWorker(
+ generation=generation,
+ point_sources=self._point_sources,
+ scan=self._scan,
+ image_shape=self._image_shape,
+ request=self._request_from_controls(),
+ )
+ worker.signals.finished.connect(self._on_real_worker_finished)
+ self._pool.start(worker)
+
+ def _start_generated_worker(self, operation: str) -> None:
+ if self._sandbox_state is None:
+ self._tutorial_run_in_progress = False
+ self._refresh_generated_field()
+ return
+ self._sandbox_generation += 1
+ generation = self._sandbox_generation
+ self._set_controls_enabled(False)
+ self._status_lbl.setText("computing...")
+ worker = _ParticleSandboxWorker(self._sandbox_state, operation, generation)
+ worker.signals.finished.connect(self._on_generated_worker_finished)
+ self._pool.start(worker)
+
+ def new_generated_pattern(self) -> None:
+ self._start_generated_worker("new_pattern")
+
+ def reset_generated(self) -> None:
+ self._start_generated_worker("reset")
+
+ def _on_real_worker_finished(self, generation: int, context: Any) -> None:
+ if int(generation) != self._generation:
+ return
+ self._set_controls_enabled(True)
+ if not getattr(context, "ready", False):
+ message = str(getattr(context, "status_message", "") or "Particle Statistics analysis failed.")
+ self._status_lbl.setText(message)
+ self._set_result_view_spec(
+ _empty_view_spec(message),
+ source_label=getattr(context, "point_source_label", None) or self._current_source_label(),
+ data_mode="real",
+ )
+ return
+ label = getattr(context, "point_source_label", None) or self._current_source_label()
+ self._set_result_view_spec(context.view_spec, source_label=label, data_mode="real")
+ self._status_lbl.setText(f"Particle Statistics comparison complete for {label}.")
+ self._raise_self()
+
+ def _on_generated_worker_finished(self, generation: int, state: Any, error: str) -> None:
+ if int(generation) != self._sandbox_generation:
+ return
+ if self._active_mode not in {"generated", "sandbox"}:
+ # The user left the tutorial while this comparison was running; do not
+ # overwrite the real-data view with stale generated results.
+ self._set_controls_enabled(True)
+ return
+ self._set_controls_enabled(True)
+ was_tutorial_run = self._tutorial_run_in_progress
+ self._tutorial_run_in_progress = False
+ if error:
+ self._status_lbl.setText(str(error))
+ return
+ self._sandbox_state = state
+ self._sync_generated_controls_from_state()
+ spec = adstat_sandbox_view_spec(
+ self._sandbox_state, include_ordering=self._include_ordering_enabled()
+ )
+ self._set_result_view_spec(
+ spec,
+ source_label=self._sandbox_source_label(),
+ data_mode="sandbox",
+ )
+ self._refresh_generated_field()
+ step = self._current_tutorial_step_obj() if self._tutorial_active else None
+ tutorial = self._current_tutorial() if self._tutorial_active else None
+ if self._tutorial_active and step is not None:
+ self.focus_statistic(
+ step.focus_statistic or _DEFAULT_FOCUS_STATISTIC,
+ curve_mode=self._tutorial_step_curve_mode(step),
+ )
+ if (
+ was_tutorial_run
+ and step is not None
+ and tutorial is not None
+ and step.advance_after_run
+ and self._tutorial_step_index < len(tutorial.steps) - 1
+ ):
+ self._tutorial_step_index += 1
+ self._apply_tutorial_step(
+ self._current_tutorial_step_obj(),
+ stage_generated=False,
+ )
+ # An async comparison finishing rebuilds the result panels; re-assert z-order
+ # so the dialog does not slip behind the Browse/main window.
+ self._raise_self()
+
+ def _request_from_controls(self) -> AdStatStatisticsRequest:
+ return AdStatStatisticsRequest(
+ point_source_label=self._current_source_label(),
+ region_mode=str(self._region_cb.currentData() or "full"),
+ roi_or_mask=self._selected_region_object(),
+ models=(str(self._real_model_cb.currentData() or "poisson"),),
+ n_simulations=int(self._real_sim_spin.value()),
+ random_seed=int(self._real_seed_spin.value()),
+ include_ordering=self._include_ordering_enabled(),
+ )
+
+ def _include_ordering_enabled(self) -> bool:
+ cb = getattr(self, "_include_ordering_cb", None)
+ return bool(cb.isChecked()) if cb is not None else False
+
+ def _sync_ordering_card_state(self) -> None:
+ enabled = self._include_ordering_enabled()
+ for statistic_id, button in getattr(self, "_ordering_stat_buttons", {}).items():
+ button.setEnabled(enabled)
+ button.setToolTip(
+ _statistic_row_description(statistic_id)
+ if enabled
+ else "Enable 'Include local-order checks' to compute this statistic."
+ )
+ if not enabled and self._focused_statistic in ORDERING_STATISTICS:
+ self.focus_statistic(_DEFAULT_FOCUS_STATISTIC)
+
+ def _on_include_ordering_toggled(self, checked: bool) -> None:
+ self._sync_ordering_card_state()
+ # Re-run / re-render so ordering verdicts appear or disappear in place.
+ if self._active_mode in {"generated", "sandbox"}:
+ state = self._sandbox_state
+ if state is not None and getattr(state, "result", None) is not None:
+ spec = adstat_sandbox_view_spec(state, include_ordering=checked)
+ self._set_result_view_spec(
+ spec, source_label=self._sandbox_source_label(), data_mode="sandbox"
+ )
+ elif self._active_mode == "real" and _view_spec_has_result(self._last_view_spec):
+ self._start_real_worker()
+
+ def _set_ordering_for_step(self, step: ParticleTutorialStep) -> None:
+ """Enable ordering only when a tutorial step teaches an ordering statistic."""
+ cb = getattr(self, "_include_ordering_cb", None)
+ if cb is None:
+ return
+ want = str(getattr(step, "focus_statistic", "")) in ORDERING_STATISTICS
+ if cb.isChecked() == want:
+ return
+ cb.blockSignals(True)
+ cb.setChecked(want)
+ cb.blockSignals(False)
+ self._sync_ordering_card_state()
+ # Re-render an existing generated result so the ψ panels match the step.
+ state = self._sandbox_state
+ if (
+ self._active_mode in {"generated", "sandbox"}
+ and state is not None
+ and getattr(state, "result", None) is not None
+ ):
+ spec = adstat_sandbox_view_spec(state, include_ordering=want)
+ self._set_result_view_spec(
+ spec, source_label=self._sandbox_source_label(), data_mode="sandbox"
+ )
+
+ def _selected_source(self) -> Any | None:
+ label = self._current_source_label()
+ for source in self._point_sources:
+ if str(getattr(source, "label", "")) == label:
+ return source
+ return self._point_sources[0] if self._point_sources else None
+
+ def _current_source_label(self) -> str:
+ return str(self._source_cb.currentData() or "")
+
+ def _selected_region_object(self) -> Any:
+ mode = str(self._region_cb.currentData() or "full")
+ if mode == "roi":
+ return self._active_area_roi
+ if mode == "mask":
+ return self._active_mask
+ return None
+
+ def _selected_region_mask_and_label(self) -> tuple[np.ndarray | None, str]:
+ mode = str(self._region_cb.currentData() or "full")
+ if mode == "roi":
+ return _mask_from_roi(self._active_area_roi, self._image_shape), "Active area ROI"
+ if mode == "mask":
+ return self._active_mask, "Active mask"
+ return None, "Full image"
+
+ def _update_info(self, *, status: str) -> None:
+ model = self._field._model
+ # In a soft-intro card the Field panel would only be clutter; keep it to a
+ # one-line promise of what it will hold once an example runs.
+ if (
+ model.mode == "generated"
+ and hasattr(self, "_tutorial_cb")
+ and self._current_tutorial_step_obj().intro_card
+ ):
+ self._info_lbl.setText("Field summarises the model and its settings once an example runs.")
+ self._status_lbl.setText(status)
+ return
+ lines = [
+ f"Mode: {self._sandbox_source_label() if model.mode == 'generated' else 'Real scan points'}",
+ f"Points: {len(model.observed_xy_nm)}",
+ ]
+ if model.model_label:
+ lines.append(f"Model: {model.model_label}")
+ # The detail below is useful but not needed at a glance; keep it last.
+ lines.append(f"Field: {model.width_nm:g} x {model.height_nm:g} nm")
+ if model.source_label:
+ lines.append(f"Source: {model.source_label}")
+ if model.region_label and model.region_label != "Synthetic field":
+ lines.append(f"Region: {model.region_label}")
+ self._info_lbl.setText("\n".join(lines))
+ self._status_lbl.setText(status)
+
+ def _set_controls_enabled(self, enabled: bool) -> None:
+ for widget in (
+ self._mode_cb,
+ self._source_cb,
+ self._region_cb,
+ self._real_model_cb,
+ self._real_sim_spin,
+ self._real_seed_spin,
+ self._pattern_cb,
+ self._ordered_lattice_cb,
+ self._ordered_background_cb,
+ self._n_spin,
+ self._width_spin,
+ self._height_spin,
+ self._seed_spin,
+ self._generated_model_cb,
+ self._sim_spin,
+ self._hard_core_radius_spin,
+ self._link_hard_core_radii_cb,
+ self._model_hard_core_radius_spin,
+ self._new_pattern_btn,
+ self._reset_btn,
+ self._landing_btn,
+ self._run_btn,
+ self._refresh_sources_btn,
+ self._observed_layer_cb,
+ self._simulation_layer_cb,
+ self._feature_layer_cb,
+ self._region_layer_cb,
+ self._tutorial_cb,
+ self._load_tutorial_btn,
+ self._run_tutorial_btn,
+ self._prev_tutorial_btn,
+ self._next_tutorial_btn,
+ self._tutorial_detail_btn,
+ self._restart_tutorial_btn,
+ ):
+ widget.setEnabled(bool(enabled))
+ if enabled:
+ self._sync_layer_controls()
+ self._sync_generated_model_radius_controls()
+ self._sync_ordered_island_controls()
+ self._refresh_tutorial_text()
+ self._sync_workflow_actions()
+
+ def _sandbox_source_label(self) -> str:
+ return "Model simulations" if self._active_mode == "sandbox" else "Generated examples"
+
+ @staticmethod
+ def _populate_combo(combo: QComboBox, values: tuple[str, ...], labels: dict[str, str]) -> None:
+ combo.clear()
+ for value in values:
+ combo.addItem(labels.get(value, value.replace("_", " ").title()), value)
+
+
+class _FieldTransform:
+ def __init__(self, rect: QRectF, width_nm: float, height_nm: float):
+ self.rect = rect
+ self.width_nm = max(float(width_nm), 1e-9)
+ self.height_nm = max(float(height_nm), 1e-9)
+
+ def point(self, xy: Any) -> QPointF:
+ x = float(xy[0])
+ y = float(xy[1])
+ return QPointF(
+ self.rect.left() + (x / self.width_nm) * self.rect.width(),
+ self.rect.top() + (y / self.height_nm) * self.rect.height(),
+ )
+
+
+def _aspect_fit_rect(available: QRectF, width_nm: float, height_nm: float) -> QRectF:
+ """Return the largest centered rect that preserves physical field aspect."""
+
+ target_ratio = max(float(width_nm), 1e-9) / max(float(height_nm), 1e-9)
+ available_ratio = max(float(available.width()), 1.0) / max(float(available.height()), 1.0)
+ if target_ratio >= available_ratio:
+ width = float(available.width())
+ height = width / target_ratio
+ else:
+ height = float(available.height())
+ width = height * target_ratio
+ x = available.left() + (available.width() - width) / 2.0
+ y = available.top() + (available.height() - height) / 2.0
+ return QRectF(x, y, max(1.0, width), max(1.0, height))
+
+
+def _draw_marker_series(
+ painter: QPainter,
+ transform: _FieldTransform,
+ points: np.ndarray,
+ *,
+ marker: str,
+ color: str,
+ radius: float,
+ hollow: bool = False,
+) -> None:
+ for xy in np.asarray(points, dtype=float):
+ if not np.isfinite(xy).all():
+ continue
+ _draw_marker(painter, transform.point(xy), marker, QColor(color), radius, hollow=hollow)
+
+
+def _draw_marker(
+ painter: QPainter,
+ center: QPointF,
+ marker: str,
+ color: QColor,
+ radius: float,
+ *,
+ hollow: bool = False,
+) -> None:
+ painter.save()
+ painter.setPen(QPen(color, 1.5))
+ painter.setBrush(Qt.NoBrush if hollow else color)
+ if marker == "^":
+ polygon = QPolygonF(
+ [
+ QPointF(center.x(), center.y() - radius),
+ QPointF(center.x() - radius, center.y() + radius),
+ QPointF(center.x() + radius, center.y() + radius),
+ ]
+ )
+ painter.drawPolygon(polygon)
+ elif marker == "s":
+ painter.drawRect(QRectF(center.x() - radius, center.y() - radius, radius * 2, radius * 2))
+ elif marker == "x":
+ painter.drawLine(QPointF(center.x() - radius, center.y() - radius), QPointF(center.x() + radius, center.y() + radius))
+ painter.drawLine(QPointF(center.x() - radius, center.y() + radius), QPointF(center.x() + radius, center.y() - radius))
+ else:
+ painter.drawEllipse(center, radius, radius)
+ painter.restore()
+
+
+def _marker_style(mode: str) -> dict[str, str]:
+ if _normalise_field_mode(mode) == "generated":
+ return {"marker": "^", "color": "#f28e2b"}
+ return {"marker": "o", "color": "#2f7ed8"}
+
+
+def _bar_text_row(parent: QWidget, color: str) -> tuple[QFrame, QLabel]:
+ """A word-wrapped label with a coloured left importance bar (SEMITIP-style)."""
+ frame = QFrame(parent)
+ frame.setStyleSheet(
+ f"QFrame {{ border-left: 4px solid {color}; }}"
+ )
+ row = QHBoxLayout(frame)
+ row.setContentsMargins(8, 2, 0, 2)
+ label = QLabel("", frame)
+ label.setWordWrap(True)
+ label.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ row.addWidget(label, 1)
+ return frame, label
+
+
+def _field_marker_radius(plot_width_px: float, n_points: int) -> float:
+ """Scale field markers to point density so dense fields do not overlap.
+
+ Mean point spacing in pixels is ~ plot_width / sqrt(N); a fraction of that gives
+ a radius that shrinks as more points are packed in (≈3.6 px at N=120, ≈2.4 px at
+ N=500), clamped to a legible range.
+ """
+ n = max(int(n_points), 1)
+ spacing_px = float(plot_width_px) / math.sqrt(n)
+ return max(2.0, min(4.0, 0.13 * spacing_px))
+
+
+def _normalise_field_mode(mode: str) -> str:
+ return "generated" if str(mode).lower() in {"generated", "sandbox", "learn"} else "real"
+
+
+def _xy_array(value: Any) -> np.ndarray:
+ arr = _xy_array_or_none(value)
+ return np.empty((0, 2), dtype=float) if arr is None else arr
+
+
+def _xy_array_or_none(value: Any) -> np.ndarray | None:
+ if value is None:
+ return None
+ arr = np.asarray(value, dtype=float)
+ if arr.size == 0:
+ return np.empty((0, 2), dtype=float)
+ if arr.ndim != 2 or arr.shape[1] != 2:
+ return None
+ return arr
+
+
+def _mask_or_none(value: Any) -> np.ndarray | None:
+ if value is None:
+ return None
+ arr = np.asarray(value, dtype=bool)
+ if arr.ndim != 2 or not arr.any():
+ return None
+ return arr
+
+
+def _valid_mask(mask: Any, image_shape: tuple[int, int] | None) -> np.ndarray | None:
+ arr = _mask_or_none(mask)
+ if arr is None:
+ return None
+ if image_shape is not None and arr.shape != tuple(image_shape):
+ return None
+ return arr
+
+
+def _mask_from_roi(roi: Any, image_shape: tuple[int, int] | None) -> np.ndarray | None:
+ if roi is None or image_shape is None:
+ return None
+ to_mask = getattr(roi, "to_mask", None)
+ if not callable(to_mask):
+ return None
+ try:
+ return _valid_mask(to_mask(tuple(image_shape)), image_shape)
+ except Exception:
+ return None
+
+
+def _source_points_nm(source: Any) -> np.ndarray:
+ points_m = getattr(source, "points_m", None)
+ if points_m is None:
+ return np.empty((0, 2), dtype=float)
+ return _xy_array(points_m) * 1e9
+
+
+def _field_size_nm(scan: Any, image_shape: tuple[int, int] | None) -> tuple[float, float]:
+ scan_range = getattr(scan, "scan_range_m", None)
+ if scan_range is not None:
+ return float(scan_range[0]) * 1e9, float(scan_range[1]) * 1e9
+ if image_shape is not None:
+ return float(image_shape[1]), float(image_shape[0])
+ return 100.0, 100.0
+
+
+def _point_count(source: Any) -> int:
+ try:
+ return int(len(getattr(source, "points_px")))
+ except Exception:
+ return 0
+
+
+def _theme_qcolor(theme: dict, keys: tuple[str, ...], default: str) -> QColor:
+ for key in keys:
+ value = theme.get(key)
+ if value:
+ return QColor(str(value))
+ return QColor(default)
+
+
+def _set_combo_value(combo: QComboBox, value: str) -> None:
+ index = combo.findData(value)
+ if index >= 0:
+ combo.setCurrentIndex(index)
+
+
+def _panel_for_statistic(view_spec: Any, statistic_id: str) -> Any | None:
+ for panel in tuple(getattr(view_spec, "panels", ()) or ()):
+ if str(getattr(panel, "statistic", "")) == str(statistic_id):
+ return panel
+ return None
+
+
+def _view_spec_has_result(view_spec: Any) -> bool:
+ return bool(
+ tuple(getattr(view_spec, "panels", ()) or ())
+ or tuple(getattr(view_spec, "verdict_rows", ()) or ())
+ )
+
+
+def _statistic_row_description(statistic_id: str) -> str:
+ """The plain-language description shown beside a statistic's selector button."""
+ question = _guide_text(statistic_id, "focus_question")
+ if statistic_id == _DEFAULT_FOCUS_STATISTIC:
+ return f"Key plot — {question}"
+ return question
+
+
+def _display_statistic_title(statistic_id: str) -> str:
+ return _STATISTIC_TITLES.get(
+ str(statistic_id),
+ _guide_text(statistic_id, "title")
+ or _STATISTIC_LABELS.get(str(statistic_id), str(statistic_id)),
+ )
+
+
+def _plot_annotation_text(statistic_id: str) -> str:
+ return _STATISTIC_ANNOTATIONS.get(str(statistic_id), "")
+
+
+def _focus_read_text(statistic_id: str, curve_mode: str) -> str:
+ if statistic_id == _DEFAULT_FOCUS_STATISTIC and curve_mode == "observed_only":
+ return "Orange = g(r) measured from the test data. The model appears in the next step."
+ return _SHORT_STAT_READS.get(
+ statistic_id,
+ _guide_text(statistic_id, "how_to_read"),
+ )
+
+
+def _series_focus_read_text(panel: Any) -> str:
+ metadata = getattr(panel, "metadata", {}) or {}
+ if metadata.get("reference_curves"):
+ return (
+ "Blue = pooled mean; blue band = image-to-image spread; "
+ "orange = single-image reference."
+ )
+ return "Blue = pooled mean; blue band = image-to-image spread across images."
+
+
+def _single_curve_reference_from_spec(
+ view_spec: Any,
+ statistic_id: str,
+) -> dict[str, Any] | None:
+ panel = _panel_for_statistic(view_spec, statistic_id)
+ if panel is None:
+ return None
+ x = _finite_curve_array(getattr(panel, "x", None))
+ y = _finite_curve_array(getattr(panel, "observed", None))
+ if x is None or y is None or len(x) != len(y):
+ return None
+ return {
+ "x": x.copy(),
+ "y": y.copy(),
+ "label": "single image reference",
+ "color": "#ff9f1c",
+ }
+
+
+def _with_series_reference_curve(
+ view_spec: Any,
+ statistic_id: str,
+ reference_curve: dict[str, Any],
+) -> Any:
+ panels = []
+ changed = False
+ for panel in tuple(getattr(view_spec, "panels", ()) or ()):
+ if (
+ str(getattr(panel, "kind", "")) == "series_curve"
+ and str(getattr(panel, "statistic", "")) == str(statistic_id)
+ ):
+ metadata = dict(getattr(panel, "metadata", {}) or {})
+ curves = tuple(metadata.get("reference_curves", ()) or ())
+ metadata["reference_curves"] = (*curves, reference_curve)
+ panel = replace(panel, metadata=metadata)
+ changed = True
+ panels.append(panel)
+ if not changed:
+ return view_spec
+ return replace(view_spec, panels=tuple(panels))
+
+
+def _finite_curve_array(values: Any) -> np.ndarray | None:
+ if values is None:
+ return None
+ try:
+ array = np.asarray(values, dtype=float)
+ except (TypeError, ValueError):
+ return None
+ if array.ndim != 1 or len(array) == 0:
+ return None
+ if not np.all(np.isfinite(array)):
+ return None
+ return array
+
+
+def _guide_text(statistic_id: str, field: str) -> str:
+ guide = _statistic_guide(statistic_id)
+ if guide is not None:
+ value = getattr(guide, field, "")
+ if value:
+ return str(value)
+ fallback = _FALLBACK_STAT_GUIDES.get(str(statistic_id), {})
+ return str(fallback.get(field, ""))
+
+
+def _statistic_guide(statistic_id: str) -> Any | None:
+ try:
+ from adstat.education.howto import get_statistic_guide
+ except Exception:
+ return None
+ try:
+ return get_statistic_guide(statistic_id)
+ except Exception:
+ return None
+
+
+def _empty_view_spec(message: str) -> Any:
+ return type(
+ "EmptyParticleStatisticsSpec",
+ (),
+ {
+ "panels": (),
+ "verdict_rows": (),
+ "status_lines": (message,),
+ "explainer": None,
+ "metadata": {"has_result": False, "message": message},
+ },
+ )()
diff --git a/probeflow/gui/features/__init__.py b/probeflow/gui/features/__init__.py
index 9059c9d..4cd8767 100644
--- a/probeflow/gui/features/__init__.py
+++ b/probeflow/gui/features/__init__.py
@@ -1419,6 +1419,7 @@ class FeaturesSidebar(QWidget):
load_from_browse_requested = Signal()
run_requested = Signal(str) # mode
export_requested = Signal(str) # mode
+ send_to_particle_statistics_requested = Signal(str) # mode
crop_template_requested = Signal()
mask_paint_toggled = Signal(bool) # True = start painting
mask_clear_requested = Signal()
@@ -1933,6 +1934,18 @@ def _build_phase2(self) -> QWidget:
lambda: self.export_requested.emit(self._current_mode()))
lay.addWidget(self._export_btn)
+ self._send_stats_btn = QPushButton("Send to Particle Statistics…")
+ self._send_stats_btn.setFont(ui_font(9))
+ self._send_stats_btn.setFixedHeight(28)
+ self._send_stats_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ self._send_stats_btn.setToolTip(_tip(
+ "Send the current mode's particle/detection positions to Particle "
+ "Statistics as a feature set for spatial-statistics analysis. Run an "
+ "analysis first so there are positions to send."))
+ self._send_stats_btn.clicked.connect(
+ lambda: self.send_to_particle_statistics_requested.emit(self._current_mode()))
+ lay.addWidget(self._send_stats_btn)
+
lay.addStretch(1)
return page
diff --git a/probeflow/gui/features/controller.py b/probeflow/gui/features/controller.py
index 26714d3..748a078 100644
--- a/probeflow/gui/features/controller.py
+++ b/probeflow/gui/features/controller.py
@@ -25,7 +25,7 @@
import os as _os
_os.environ.setdefault("QT_API", "pyside6")
-from PySide6.QtCore import QObject, QThreadPool
+from PySide6.QtCore import QObject, QThreadPool, Signal
from probeflow.gui.features import FeaturesPanel, FeaturesSidebar, _FeaturesWorker
@@ -84,8 +84,15 @@ class FeatureCountingController(QObject):
instance is used (not recommended for preview — use a 1-thread pool).
parent_widget
Parent for file dialogs (``None`` centres dialogs on screen).
+ feature_set_store
+ Shared :class:`FeatureSetStore` that "Send to Particle Statistics" adds
+ the built set to. ``None`` disables that action.
"""
+ # Emitted after a feature set is sent to Particle Statistics:
+ # ``(scan_context, set_id)``. The host window/app opens the dialog.
+ open_particle_statistics_requested = Signal(object, str)
+
def __init__(
self,
panel: FeaturesPanel,
@@ -95,6 +102,7 @@ def __init__(
*,
preview_pool: QThreadPool | None = None,
parent_widget: QWidget | None = None,
+ feature_set_store: object | None = None,
) -> None:
super().__init__()
self._panel = panel
@@ -102,6 +110,7 @@ def __init__(
self._pool = pool
self._status_cb = status_cb
self._parent_widget = parent_widget
+ self._feature_set_store = feature_set_store
self._preview_pool = preview_pool or QThreadPool.globalInstance()
self._preview_generation = 0
@@ -120,6 +129,8 @@ def __init__(
sidebar.preview_requested.connect(self._on_preview)
sidebar.run_requested.connect(self._on_run)
sidebar.export_requested.connect(self._on_export)
+ sidebar.send_to_particle_statistics_requested.connect(
+ self._on_send_to_particle_statistics)
sidebar.classify_params_changed.connect(self._on_classify_params_changed)
sidebar.mode_changed.connect(self._on_mode_changed)
sidebar.mask_paint_toggled.connect(self._on_mask_paint_toggled)
@@ -512,6 +523,69 @@ def _on_export(self, mode: str) -> None:
except Exception as exc:
self._sidebar.set_status(f"Export failed: {exc}")
+ def _on_send_to_particle_statistics(self, mode: str) -> None:
+ """Build a feature set from particles/detections and open Particle Statistics."""
+ from types import SimpleNamespace
+
+ if self._feature_set_store is None:
+ self._sidebar.set_status("Particle Statistics is not available here.")
+ return
+ if mode == "particles":
+ items = self._panel.get_particles()
+ elif mode == "template":
+ items = self._panel.get_detections()
+ else:
+ self._sidebar.set_status(
+ "Send to Particle Statistics supports Particles or Template modes."
+ )
+ return
+ if not items:
+ self._sidebar.set_status("Nothing to send — run an analysis first.")
+ return
+
+ scan = self._panel.current_scan()
+ entry = self._panel.current_entry()
+ arr = self._panel.current_array()
+ if scan is not None:
+ scan_range_m = (float(scan.scan_range_m[0]), float(scan.scan_range_m[1]))
+ nx, ny = scan.dims
+ image_shape = (int(ny), int(nx))
+ elif arr is not None:
+ px_x_m, px_y_m = self._panel.current_pixel_sizes()
+ ny, nx = arr.shape[:2]
+ image_shape = (int(ny), int(nx))
+ scan_range_m = (nx * float(px_x_m), ny * float(px_y_m))
+ else:
+ self._sidebar.set_status("No scan loaded.")
+ return
+
+ scan_ctx = SimpleNamespace(
+ scan_range_m=scan_range_m,
+ dims=(image_shape[1], image_shape[0]),
+ source_path=getattr(entry, "path", None),
+ )
+ try:
+ from probeflow.measurements.point_table_io import feature_items_to_feature_set
+
+ label = f"{entry.stem if entry else 'features'} · {mode} (N={len(items)})"
+ feature_set = feature_items_to_feature_set(
+ items,
+ scan_range_m=scan_range_m,
+ image_shape=image_shape,
+ name=label,
+ source_type=f"feature_counting_{mode}",
+ image_label=(entry.stem if entry else ""),
+ metadata={"feature_counting_mode": mode},
+ )
+ except Exception as exc: # noqa: BLE001 - report conversion failures to the user
+ self._sidebar.set_status(f"Could not send features: {exc}")
+ return
+
+ set_id = self._feature_set_store.add(feature_set)
+ self.open_particle_statistics_requested.emit(scan_ctx, set_id)
+ self._status_cb(f"Sent {len(items)} points to Particle Statistics.")
+ self._sidebar.set_status(f"Sent {feature_set.point_count} points to Particle Statistics.")
+
# ── Zero-plane / histogram ────────────────────────────────────────────────
def _on_scan_loaded(self, arr) -> None:
diff --git a/probeflow/gui/features/window.py b/probeflow/gui/features/window.py
index e07372f..bdfd176 100644
--- a/probeflow/gui/features/window.py
+++ b/probeflow/gui/features/window.py
@@ -58,7 +58,10 @@ class FeatureCountingWindow(QMainWindow):
# ProbeFlowWindow listens and calls load_entry() with the data.
load_from_browse_needed = Signal()
- def __init__(self, parent=None, theme: dict | None = None):
+ # Emitted when "Send to Particle Statistics" builds a set: (scan_context, set_id).
+ open_particle_statistics_needed = Signal(object, str)
+
+ def __init__(self, parent=None, theme: dict | None = None, feature_set_store=None):
# Qt.Window ensures this is an independent top-level window with its own
# taskbar entry on Windows, not a child that hides behind the main window.
super().__init__(parent, Qt.Window)
@@ -96,6 +99,7 @@ def __init__(self, parent=None, theme: dict | None = None):
status_cb=self._status_bar.showMessage,
preview_pool=self._preview_pool,
parent_widget=self,
+ feature_set_store=feature_set_store,
)
# ── Remaining signals not owned by the controller ────────────────────
@@ -104,6 +108,9 @@ def __init__(self, parent=None, theme: dict | None = None):
self.load_from_browse_needed.emit)
# "← Browse" button hides this window (Browse is always in main window).
self._panel.go_to_browse_requested.connect(self.hide)
+ # Bridge the controller's "open Particle Statistics" request to the host app.
+ self._ctrl.open_particle_statistics_requested.connect(
+ self.open_particle_statistics_needed.emit)
# ── Layout ───────────────────────────────────────────────────────────
splitter = QSplitter(Qt.Horizontal)
diff --git a/probeflow/gui/viewer/image_viewer_processing_export_mixin.py b/probeflow/gui/viewer/image_viewer_processing_export_mixin.py
index 27be788..79f8fc7 100644
--- a/probeflow/gui/viewer/image_viewer_processing_export_mixin.py
+++ b/probeflow/gui/viewer/image_viewer_processing_export_mixin.py
@@ -989,7 +989,11 @@ def _close_modeless_children(self) -> None:
if not visible:
continue
try:
- dlg.close()
+ force_close = getattr(dlg, "force_close", None)
+ if callable(force_close):
+ force_close()
+ else:
+ dlg.close()
except Exception:
pass
diff --git a/probeflow/gui/viewer/image_viewer_toolbar_mixin.py b/probeflow/gui/viewer/image_viewer_toolbar_mixin.py
index 2a7ba66..9b83c78 100644
--- a/probeflow/gui/viewer/image_viewer_toolbar_mixin.py
+++ b/probeflow/gui/viewer/image_viewer_toolbar_mixin.py
@@ -280,12 +280,15 @@ def _sync_line_profile_visibility(self, kind: str | None = None) -> None:
def _pixel_size_xy_m(self) -> tuple[float, float]:
shape = self._current_array_shape()
- if shape is None or self._scan_range_m is None:
+ scan_range = getattr(self, "_display_scan_range_m", None)
+ if scan_range is None:
+ scan_range = getattr(self, "_scan_range_m", None)
+ if shape is None or scan_range is None:
return 1e-10, 1e-10
Ny, Nx = shape
try:
- w_m = float(self._scan_range_m[0])
- h_m = float(self._scan_range_m[1])
+ w_m = float(scan_range[0])
+ h_m = float(scan_range[1])
except (TypeError, ValueError, IndexError):
return 1e-10, 1e-10
px_x = w_m / Nx if Nx > 0 and w_m > 0 else 1e-10
diff --git a/probeflow/gui/viewer/image_viewer_tools_mixin.py b/probeflow/gui/viewer/image_viewer_tools_mixin.py
index 7bea309..8e87ca5 100644
--- a/probeflow/gui/viewer/image_viewer_tools_mixin.py
+++ b/probeflow/gui/viewer/image_viewer_tools_mixin.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from types import SimpleNamespace
import numpy as np
@@ -76,7 +77,9 @@ def _on_clear_lattice_grid(self) -> None:
self._status_lbl.setText("No lattice grid overlay to clear.")
def _on_open_lattice_grid(self):
- arr = self._display_arr if self._display_arr is not None else self._raw_arr
+ arr = getattr(self, "_display_arr", None)
+ if arr is None:
+ arr = getattr(self, "_raw_arr", None)
context = lattice_grid_launch_context(arr, scan_range_m=self._scan_range_m)
if not context.ready:
self._status_lbl.setText(str(context.status_message))
@@ -133,6 +136,7 @@ def _on_open_feature_finder(self):
)
if roi_ctx.roi is not None:
roi_mask = area_roi_mask(roi_ctx.roi, arr.shape[:2])
+ value_scale, value_unit, _ = self._channel_unit()
from probeflow.gui.dialogs.feature_finder import FeatureFinderDialog
dlg = FeatureFinderDialog(
arr,
@@ -140,6 +144,9 @@ def _on_open_feature_finder(self):
pixel_size_y_m=px_y_m,
roi_mask=roi_mask,
theme=self._t,
+ value_scale=value_scale,
+ value_unit=value_unit,
+ on_send_to_particle_statistics=self._on_send_features_to_particle_statistics,
parent=self,
)
self._feature_finder_dlg = dlg
@@ -457,6 +464,156 @@ def _add(result):
self._present_modal_tool(dlg)
self._status_lbl.setText("Pair correlation opened.")
+ def _on_open_particle_statistics(
+ self,
+ *,
+ initial_mode: str = "landing",
+ include_real_context: bool = True,
+ preserve_existing_mode: bool = True,
+ ) -> None:
+ from probeflow.gui.dialogs.particle_statistics import ParticleStatisticsDialog
+
+ context = self._particle_statistics_context(include_real_context=include_real_context)
+ existing = getattr(self, "_particle_statistics_dialog", None)
+ try:
+ if existing is not None:
+ existing.isVisible()
+ except RuntimeError:
+ existing = None
+ self._particle_statistics_dialog = None
+
+ if existing is None:
+ dlg = ParticleStatisticsDialog(
+ **context,
+ theme=self._t,
+ initial_mode=initial_mode,
+ parent=self,
+ context_refresh_fn=lambda: self._particle_statistics_context(include_real_context=True),
+ )
+ self._particle_statistics_dialog = dlg
+ self._track_modeless_child(dlg)
+ try:
+ dlg.destroyed.connect(lambda _obj=None: setattr(self, "_particle_statistics_dialog", None))
+ except Exception:
+ pass
+ else:
+ dlg = existing
+ if include_real_context:
+ dlg.refresh_probe_context(**context)
+ if not preserve_existing_mode:
+ dlg.set_current_mode(initial_mode)
+
+ dlg.show()
+ dlg.raise_()
+ dlg.activateWindow()
+ self._status_lbl.setText("Particle Statistics opened.")
+
+ def _feature_set_store(self):
+ """Lazily-created viewer-session store of named feature sets."""
+ store = getattr(self, "_feature_set_store_obj", None)
+ if store is None:
+ from probeflow.measurements.feature_sets import FeatureSetStore
+
+ store = FeatureSetStore()
+ self._feature_set_store_obj = store
+ return store
+
+ def _on_send_features_to_particle_statistics(self, result) -> None:
+ """Snapshot a Feature Finder result into the store and open Particle Statistics."""
+ arr = self._display_arr if self._display_arr is not None else self._raw_arr
+ points = list(getattr(result, "points", []) or [])
+ if arr is None or not points:
+ self._status_lbl.setText("No features to send.")
+ return
+ import numpy as np
+
+ from probeflow.measurements.feature_sets import FeatureSet
+
+ px_x_m, px_y_m = self._pixel_size_xy_m()
+ points_px = np.array([[float(p.x_px), float(p.y_px)] for p in points], dtype=float)
+ points_m = points_px * np.array([px_x_m, px_y_m], dtype=float)
+ ny, nx = arr.shape[:2]
+ image_label = self.windowTitle() or "image"
+ mode = str(getattr(result, "mode", "features") or "features")
+ feature_set = FeatureSet.from_points(
+ name=f"{image_label} · {mode} (N={len(points)})",
+ points_px=points_px,
+ points_m=points_m,
+ scan_range_m=(nx * px_x_m, ny * px_y_m),
+ image_shape=(ny, nx),
+ source_type="feature_finder",
+ image_label=image_label,
+ metadata={"detection_mode": mode},
+ )
+ set_id = self._feature_set_store().add(feature_set)
+ self._on_open_particle_statistics(
+ initial_mode="real",
+ include_real_context=True,
+ preserve_existing_mode=False,
+ )
+ dlg = getattr(self, "_particle_statistics_dialog", None)
+ if dlg is not None and hasattr(dlg, "select_feature_set"):
+ dlg.select_feature_set(set_id)
+
+ def _particle_statistics_context(self, *, include_real_context: bool) -> dict:
+ arr = self._display_arr if self._display_arr is not None else self._raw_arr
+ point_sources = self._point_source_records() if include_real_context else []
+ active_area_roi = self._active_image_roi() if include_real_context else None
+ active_mask = None
+ if include_real_context:
+ active_mask_getter = getattr(self, "_active_mask_array", None)
+ if callable(active_mask_getter):
+ active_mask = active_mask_getter()
+
+ return {
+ "point_sources": point_sources,
+ "scan": self._adstat_scan_context(arr) if include_real_context else None,
+ "active_area_roi": active_area_roi,
+ "active_mask": active_mask,
+ "image_shape": arr.shape[:2] if arr is not None and include_real_context else None,
+ "feature_sets": self._feature_set_store().all(),
+ "feature_set_store": self._feature_set_store(),
+ }
+
+ def _on_open_adstat_workbench(
+ self,
+ *,
+ initial_mode: str = "real",
+ include_real_context: bool = True,
+ ) -> None:
+ self._on_open_particle_statistics(
+ initial_mode=initial_mode,
+ include_real_context=include_real_context,
+ preserve_existing_mode=False,
+ )
+
+ def _on_open_adstat_statistics(self) -> None:
+ self._on_open_adstat_workbench(initial_mode="landing", include_real_context=True)
+
+ def _on_open_adstat_sandbox(self) -> None:
+ # Opens the free-play Model simulations (sandbox) mode, not the tutorial.
+ self._on_open_adstat_workbench(
+ initial_mode="sandbox",
+ include_real_context=False,
+ )
+
+ def _adstat_scan_context(self, arr):
+ if arr is None:
+ return None
+ scan_range = getattr(self, "_display_scan_range_m", None)
+ if scan_range is None:
+ scan_range = getattr(self, "_scan_range_m", None)
+ if scan_range is None:
+ return None
+ entries = getattr(self, "_entries", [])
+ entry = entries[self._idx] if entries else None
+ shape = arr.shape[:2]
+ return SimpleNamespace(
+ scan_range_m=(float(scan_range[0]), float(scan_range[1])),
+ dims=(int(shape[1]), int(shape[0])),
+ source_path=getattr(entry, "path", None),
+ )
+
def _on_open_feature_lattice(self) -> None:
item = getattr(self, "_lattice_grid_item", None)
grid = item.grid() if item is not None else None
diff --git a/probeflow/gui/viewer/shortcuts.py b/probeflow/gui/viewer/shortcuts.py
index 99458ef..ebb7770 100644
--- a/probeflow/gui/viewer/shortcuts.py
+++ b/probeflow/gui/viewer/shortcuts.py
@@ -122,6 +122,10 @@ class ViewerCommand:
ViewerCommand("measure.feature_maxima", "Feature maxima...", "Measurements", status_tip="Detect local maxima in the active area ROI (opens the controls).", aliases=("maxima", "peaks", "features")),
ViewerCommand("measure.point_fft", "Point mask / FFT...", "Measurements", status_tip="Build a point mask from detected features and inspect its FFT.", aliases=("point mask", "fft", "mask fft", "selective fft")),
ViewerCommand("measure.feature_finder", "Feature finder...", "Measurements", status_tip="Open the feature finder tool.", aliases=("maxima", "minima", "peaks")),
+ ViewerCommand("measure.particle_statistics", "Particle Statistics...", "Measurements", status_tip="Open point-pattern analysis and generated-particle learning modes.", aliases=("particle statistics", "point statistics", "spatial statistics", "cluster analysis", "adstat", "sandbox", "simulation")),
+ ViewerCommand("measure.adstat_workbench", "AdStat workbench...", "Measurements", status_tip="Open Particle Statistics.", finder_visible=False, aliases=("adstat", "particle statistics", "spatial statistics")),
+ ViewerCommand("measure.adstat_statistics", "AdStat statistics...", "Measurements", status_tip="Open AdStat workbench in real scan-point mode.", finder_visible=False, aliases=("adstat real data", "particle cluster", "null model", "erl")),
+ ViewerCommand("measure.adstat_sandbox", "Model simulations...", "Measurements", status_tip="Open Particle Statistics in the free-play model-simulations mode.", finder_visible=False, aliases=("model simulations", "adstat sandbox", "test data", "synthetic points", "simulation")),
ViewerCommand("measure.pair_correlation", "Pair correlation...", "Measurements", status_tip="Open pair correlation analysis.", aliases=("g(r)", "rdf", "radial distribution")),
ViewerCommand("measure.feature_lattice", "Feature-to-lattice comparison...", "Measurements", status_tip="Compare detected features to a lattice.", aliases=("lattice", "features")),
ViewerCommand("measure.lattice_grid", "Real-space lattice correction...", "Measurements", status_tip="Fit a real-space lattice grid to a known structure and preview undistortion.", aliases=("grid", "lattice")),
diff --git a/probeflow/gui/viewer/tool_launch.py b/probeflow/gui/viewer/tool_launch.py
index ad97070..698d685 100644
--- a/probeflow/gui/viewer/tool_launch.py
+++ b/probeflow/gui/viewer/tool_launch.py
@@ -7,6 +7,7 @@
import numpy as np
+from probeflow.analysis.adstat_adapter import compare_point_source_view_spec
from probeflow.gui.roi_context import (
PointSource,
active_area_roi_area_m2,
@@ -36,6 +37,32 @@ def ready(self) -> bool:
return self.status_message is None
+@dataclass(frozen=True)
+class AdStatWorkbenchLaunchContext:
+ """Inputs produced by the direct ProbeFlow-to-AdStat analysis path."""
+
+ view_spec: Any | None
+ point_source_label: str | None
+ status_message: str | None = None
+
+ @property
+ def ready(self) -> bool:
+ return self.status_message is None
+
+
+@dataclass(frozen=True)
+class AdStatStatisticsRequest:
+ """User-selected inputs for a real-data AdStat workbench run."""
+
+ point_source_label: str | None
+ region_mode: str
+ roi_or_mask: Any = None
+ models: tuple[str, ...] = ("poisson",)
+ n_simulations: int = 100
+ random_seed: int | None = 0
+ include_ordering: bool = False
+
+
@dataclass(frozen=True)
class FeatureLatticeLaunchContext:
"""Inputs needed to open the feature-to-lattice dialog."""
@@ -106,6 +133,82 @@ def pair_correlation_launch_context(
)
+def adstat_workbench_launch_context(
+ point_sources: list[PointSource],
+ *,
+ scan: Any = None,
+ active_area_roi: Any = None,
+ roi_or_mask: Any = None,
+ image_shape: tuple[int, int] | None = None,
+ point_source_label: str | None = None,
+ models: tuple[str, ...] = ("poisson",),
+ pair_bin_width_nm: float | None = None,
+ pair_max_radius_nm: float | None = None,
+ cluster_radius_nm: float | None = None,
+ n_simulations: int = 19,
+ random_seed: int | None = 0,
+ include_ordering: bool = False,
+ request: AdStatStatisticsRequest | None = None,
+) -> AdStatWorkbenchLaunchContext:
+ """Build a Qt-renderable AdStat workbench spec from current viewer state."""
+
+ if request is not None:
+ point_source_label = request.point_source_label
+ roi_or_mask = request.roi_or_mask
+ models = request.models
+ n_simulations = request.n_simulations
+ random_seed = request.random_seed
+ include_ordering = request.include_ordering
+ elif roi_or_mask is None:
+ roi_or_mask = active_area_roi
+
+ if scan is None:
+ return AdStatWorkbenchLaunchContext(
+ view_spec=None,
+ point_source_label=None,
+ status_message=IMAGE_REQUIRED_MESSAGE,
+ )
+ if not point_sources:
+ return AdStatWorkbenchLaunchContext(
+ view_spec=None,
+ point_source_label=None,
+ status_message=POINT_SOURCE_REQUIRED_MESSAGE,
+ )
+
+ source = _select_point_source(point_sources, point_source_label)
+ try:
+ view_spec = compare_point_source_view_spec(
+ source,
+ scan=scan,
+ roi_or_mask=roi_or_mask,
+ image_shape=image_shape,
+ scan_id=_scan_id(scan),
+ models=models,
+ pair_bin_width_nm=pair_bin_width_nm,
+ pair_max_radius_nm=pair_max_radius_nm,
+ cluster_radius_nm=cluster_radius_nm,
+ n_simulations=n_simulations,
+ random_seed=random_seed,
+ include_ordering=include_ordering,
+ )
+ except ImportError as exc:
+ return AdStatWorkbenchLaunchContext(
+ view_spec=None,
+ point_source_label=source.label,
+ status_message=str(exc),
+ )
+ except Exception as exc:
+ return AdStatWorkbenchLaunchContext(
+ view_spec=None,
+ point_source_label=source.label,
+ status_message=f"AdStat analysis failed: {exc}",
+ )
+ return AdStatWorkbenchLaunchContext(
+ view_spec=view_spec,
+ point_source_label=source.label,
+ )
+
+
def lattice_grid_launch_context(
image: np.ndarray | None,
*,
@@ -127,6 +230,27 @@ def lattice_grid_launch_context(
)
+def _select_point_source(
+ point_sources: list[PointSource],
+ label: str | None,
+) -> PointSource:
+ if label:
+ for source in point_sources:
+ if source.label == label:
+ return source
+ return point_sources[0]
+
+
+def _scan_id(scan: Any) -> str | None:
+ source_path = getattr(scan, "source_path", None)
+ if source_path is not None:
+ try:
+ return source_path.stem
+ except AttributeError:
+ return str(source_path)
+ return None
+
+
def feature_lattice_launch_context(
point_sources: list[PointSource],
*,
diff --git a/probeflow/gui/widgets/image_measurements_panel.py b/probeflow/gui/widgets/image_measurements_panel.py
index ef04b2e..ac4e40d 100644
--- a/probeflow/gui/widgets/image_measurements_panel.py
+++ b/probeflow/gui/widgets/image_measurements_panel.py
@@ -50,6 +50,10 @@ class ImageMeasurementsPanel(QWidget):
clearAngleRequested = Signal()
featureFinderRequested = Signal()
pairCorrelationRequested = Signal()
+ particleStatisticsRequested = Signal()
+ adstatWorkbenchRequested = Signal()
+ adstatStatisticsRequested = Signal()
+ adstatSandboxRequested = Signal()
featureToLatticeRequested = Signal()
latticeGridRequested = Signal()
fftViewerRequested = Signal()
@@ -84,6 +88,7 @@ class ImageMeasurementsPanel(QWidget):
("FFT viewer…", "fft_viewer", "dialog"),
("Lattice grid…", "lattice_grid", "dialog"),
("Feature finder…", "feature_finder", "dialog"),
+ ("Particle Statistics…", "particle_statistics", "dialog"),
]),
]
@@ -94,6 +99,10 @@ class ImageMeasurementsPanel(QWidget):
"fft_viewer": "fftViewerRequested",
"feature_finder": "featureFinderRequested",
"pair_correlation": "pairCorrelationRequested",
+ "particle_statistics": "particleStatisticsRequested",
+ "adstat_workbench": "adstatWorkbenchRequested",
+ "adstat_statistics": "adstatStatisticsRequested",
+ "adstat_sandbox": "adstatSandboxRequested",
"feature_to_lattice": "featureToLatticeRequested",
"lattice_grid": "latticeGridRequested",
}
@@ -478,6 +487,10 @@ def _collect_titles(self) -> dict[str, str]:
"lattice_grid": "Lattice grid",
"feature_finder": "Feature finder",
"pair_correlation": "Pair correlation",
+ "particle_statistics": "Particle Statistics",
+ "adstat_workbench": "AdStat workbench",
+ "adstat_statistics": "AdStat statistics",
+ "adstat_sandbox": "Model simulations",
"feature_to_lattice": "Feature-to-lattice",
})
for _group, tools in self._TOOL_GROUPS:
diff --git a/probeflow/measurements/adstat_export.py b/probeflow/measurements/adstat_export.py
new file mode 100644
index 0000000..8068d46
--- /dev/null
+++ b/probeflow/measurements/adstat_export.py
@@ -0,0 +1,175 @@
+"""GUI-free export of Particle Statistics (AdStat) results to simple formats.
+
+The Particle Statistics result is an AdStat ``ResultViewSpec``: a tuple of
+``PanelSpec`` panels (each carrying the plotted ``x`` / ``observed`` /
+``band_low`` / ``central`` / ``band_high`` arrays) plus verdict rows. These
+helpers turn that into plain CSV (one file per curve/table statistic, so the
+plots can be reproduced in any program) and a single JSON snapshot of the whole
+result for provenance.
+"""
+
+from __future__ import annotations
+
+import csv
+import io
+import json
+import re
+from collections.abc import Mapping
+from dataclasses import asdict, is_dataclass
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+# Curve columns to emit when present, in order: (panel attribute, CSV header).
+_CURVE_COLUMNS = (
+ ("observed", "observed"),
+ ("band_low", "model_low"),
+ ("central", "model_central"),
+ ("band_high", "model_high"),
+)
+
+
+def _field(obj: Any, name: str, default: Any = None) -> Any:
+ if isinstance(obj, Mapping):
+ return obj.get(name, default)
+ return getattr(obj, name, default)
+
+
+def _as_1d(values: Any) -> np.ndarray | None:
+ if values is None:
+ return None
+ arr = np.asarray(values, dtype=float)
+ if arr.ndim != 1 or arr.size == 0:
+ return None
+ return arr
+
+
+def panel_curve_csv_text(panel: Any) -> str | None:
+ """CSV text for a 1-D curve panel (g(r), nearest-neighbour, Ripley L, …).
+
+ Returns ``None`` for panels that are not simple x/y curves (heatmaps,
+ real-space scatter, etc.).
+ """
+
+ x = _as_1d(_field(panel, "x"))
+ if x is None:
+ return None
+ columns: list[tuple[str, np.ndarray]] = []
+ x_label = str(_field(panel, "x_label") or "x")
+ columns.append((x_label, x))
+ for attr, header in _CURVE_COLUMNS:
+ col = _as_1d(_field(panel, attr))
+ if col is not None and len(col) == len(x):
+ columns.append((header, col))
+ if len(columns) < 2: # x with no measured/model column is not worth a file
+ return None
+
+ out = io.StringIO()
+ writer = csv.writer(out)
+ writer.writerow([name for name, _ in columns])
+ for row_idx in range(len(x)):
+ writer.writerow([f"{col[row_idx]:.10g}" for _, col in columns])
+ return out.getvalue()
+
+
+def panel_table_csv_text(panel: Any) -> str | None:
+ """CSV text for a table-style panel (``table_columns`` / ``table_rows``)."""
+
+ cols = _field(panel, "table_columns")
+ rows = _field(panel, "table_rows")
+ if not cols or not rows:
+ return None
+ out = io.StringIO()
+ writer = csv.writer(out)
+ writer.writerow([str(c) for c in cols])
+ for row in rows:
+ writer.writerow([str(v) for v in row])
+ return out.getvalue()
+
+
+def verdict_rows_csv_text(spec: Any) -> str | None:
+ """CSV text for the verdict table, or ``None`` if there are no verdicts."""
+
+ rows = tuple(_field(spec, "verdict_rows", ()) or ())
+ if not rows:
+ return None
+ out = io.StringIO()
+ writer = csv.writer(out)
+ header = ("model", "statistic", "verdict", "alpha", "statistic_value", "rank", "n_simulations")
+ width = max(len(r) for r in rows)
+ writer.writerow(header[:width] if width <= len(header) else [f"col{i}" for i in range(width)])
+ for row in rows:
+ writer.writerow([str(v) for v in row])
+ return out.getvalue()
+
+
+def export_result_csvs(spec: Any, out_dir: str | Path, *, base: str = "particle_statistics") -> list[Path]:
+ """Write one CSV per curve/table statistic plus a verdicts CSV.
+
+ Returns the list of files written. Heatmap and real-space panels are skipped
+ here (they are preserved in the JSON export); curve panels reproduce the
+ plotted lines, including the model envelope.
+ """
+
+ out_dir = Path(out_dir)
+ out_dir.mkdir(parents=True, exist_ok=True)
+ slug = _slug(base)
+ written: list[Path] = []
+
+ for panel in tuple(_field(spec, "panels", ()) or ()):
+ statistic = _slug(str(_field(panel, "statistic", "") or _field(panel, "title", "panel")))
+ text = panel_curve_csv_text(panel) or panel_table_csv_text(panel)
+ if not text:
+ continue
+ path = out_dir / f"{slug}_{statistic}.csv"
+ path.write_text(text, encoding="utf-8")
+ written.append(path)
+
+ verdicts = verdict_rows_csv_text(spec)
+ if verdicts:
+ path = out_dir / f"{slug}_verdicts.csv"
+ path.write_text(verdicts, encoding="utf-8")
+ written.append(path)
+
+ return written
+
+
+def result_spec_to_plain(spec: Any) -> Any:
+ """Recursively convert a view spec into JSON-serialisable Python objects."""
+
+ return _plain(spec)
+
+
+def export_result_json(spec: Any, out_path: str | Path) -> Path:
+ """Write the whole result view spec to a single JSON file."""
+
+ out_path = Path(out_path)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(
+ json.dumps(result_spec_to_plain(spec), indent=2, sort_keys=True), encoding="utf-8"
+ )
+ return out_path
+
+
+def _plain(value: Any) -> Any:
+ if is_dataclass(value) and not isinstance(value, type):
+ return _plain(asdict(value))
+ if isinstance(value, np.ndarray):
+ return value.tolist()
+ if isinstance(value, np.generic):
+ return value.item()
+ if isinstance(value, Path):
+ return str(value)
+ if isinstance(value, Mapping):
+ return {str(k): _plain(v) for k, v in value.items()}
+ if isinstance(value, (tuple, list)):
+ return [_plain(v) for v in value]
+ if hasattr(value, "__dict__"):
+ return {str(k): _plain(v) for k, v in vars(value).items() if not str(k).startswith("_")}
+ return value
+
+
+def _slug(text: str) -> str:
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "_", str(text).strip()).strip("_")
+ return slug or "particle_statistics"
diff --git a/probeflow/measurements/feature_sets.py b/probeflow/measurements/feature_sets.py
new file mode 100644
index 0000000..34010ad
--- /dev/null
+++ b/probeflow/measurements/feature_sets.py
@@ -0,0 +1,215 @@
+"""Named, persistable feature-point sets for multi-image Particle Statistics.
+
+A :class:`FeatureSet` is a compact snapshot of one image's detected points
+(pixel + metre coordinates) plus the calibration needed to analyse them. Unlike
+point ROIs (one ROI per point), a set stores its points as arrays, so hundreds of
+points stay lightweight and many sets — e.g. one per image in a study — can
+coexist in a :class:`FeatureSetStore` and be pooled in Particle Statistics.
+
+The store is deliberately decoupled from AdStat: conversion to an
+``AdStatPointSetRecord`` happens lazily via the adapter's ``point_set_record``.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+import uuid
+from dataclasses import dataclass, field, replace
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+
+
+def _xy(values: Any) -> np.ndarray:
+ arr = np.asarray(values, dtype=float)
+ if arr.size == 0:
+ return arr.reshape(0, 2)
+ if arr.ndim != 2 or arr.shape[1] != 2:
+ raise ValueError("feature points must have shape (N, 2)")
+ return arr
+
+
+@dataclass(frozen=True)
+class FeatureSet:
+ """One named set of detected points with its image calibration."""
+
+ name: str
+ points_px: np.ndarray
+ points_m: np.ndarray
+ scan_range_m: tuple[float, float]
+ image_shape: tuple[int, int]
+ source_type: str = "feature_finder"
+ image_label: str = ""
+ metadata: dict[str, Any] = field(default_factory=dict)
+ set_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
+ created: float = field(default_factory=time.time)
+
+ @property
+ def point_count(self) -> int:
+ return int(len(self.points_m))
+
+ # ---- construction ------------------------------------------------------
+ @classmethod
+ def from_points(
+ cls,
+ *,
+ name: str,
+ points_px: Any,
+ points_m: Any,
+ scan_range_m: tuple[float, float],
+ image_shape: tuple[int, int],
+ source_type: str = "feature_finder",
+ image_label: str = "",
+ metadata: dict[str, Any] | None = None,
+ ) -> "FeatureSet":
+ return cls(
+ name=str(name),
+ points_px=_xy(points_px),
+ points_m=_xy(points_m),
+ scan_range_m=(float(scan_range_m[0]), float(scan_range_m[1])),
+ image_shape=(int(image_shape[0]), int(image_shape[1])),
+ source_type=str(source_type),
+ image_label=str(image_label),
+ metadata=dict(metadata or {}),
+ )
+
+ # ---- AdStat conversion -------------------------------------------------
+ def to_point_set_record(self, *, mask: Any = None) -> Any:
+ """Build an ``AdStatPointSetRecord`` (table + region + calibration)."""
+
+ from probeflow.analysis.adstat_adapter import point_set_record
+
+ ny, nx = self.image_shape
+ scan = SimpleNamespace(scan_range_m=self.scan_range_m, dims=(nx, ny))
+ point_source = SimpleNamespace(
+ label=self.name,
+ source_type=self.source_type,
+ points_px=self.points_px,
+ points_m=self.points_m,
+ metadata=dict(self.metadata),
+ )
+ return point_set_record(
+ dataset_id=self.set_id,
+ scan=scan,
+ point_source=point_source,
+ roi_or_mask=mask,
+ image_shape=self.image_shape,
+ )
+
+ def to_feature_layer(self) -> dict[str, Any]:
+ """Represent this set as an independent points feature layer for AdStat.
+
+ Marked ``measured_independently`` so AdStat accepts it for the
+ measured-feature model; the caller is responsible for choosing a set that
+ is genuinely independent of the tested particles (a different feature).
+ """
+ return {
+ "kind": "points",
+ "name": self.name,
+ "xy_nm": self.points_m * 1e9,
+ "feature_type": "feature",
+ "source": self.image_label or self.name,
+ "provenance": {
+ "measured_independently": True,
+ "derived_from_particles": False,
+ "source": self.image_label or self.name,
+ },
+ }
+
+ # ---- persistence -------------------------------------------------------
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "set_id": self.set_id,
+ "name": self.name,
+ "points_px": self.points_px.tolist(),
+ "points_m": self.points_m.tolist(),
+ "scan_range_m": list(self.scan_range_m),
+ "image_shape": list(self.image_shape),
+ "source_type": self.source_type,
+ "image_label": self.image_label,
+ "metadata": dict(self.metadata),
+ "created": self.created,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "FeatureSet":
+ return cls(
+ name=str(data.get("name", "Feature set")),
+ points_px=_xy(data.get("points_px", [])),
+ points_m=_xy(data.get("points_m", [])),
+ scan_range_m=tuple(float(v) for v in data.get("scan_range_m", (1.0, 1.0)))[:2],
+ image_shape=tuple(int(v) for v in data.get("image_shape", (1, 1)))[:2],
+ source_type=str(data.get("source_type", "feature_finder")),
+ image_label=str(data.get("image_label", "")),
+ metadata=dict(data.get("metadata", {}) or {}),
+ set_id=str(data.get("set_id") or uuid.uuid4().hex[:12]),
+ created=float(data.get("created", time.time())),
+ )
+
+
+class FeatureSetStore:
+ """An ordered, named collection of :class:`FeatureSet` records.
+
+ Lives at the application/viewer level so sets survive switching images; can be
+ saved/loaded so they survive sessions.
+ """
+
+ def __init__(self, sets: list[FeatureSet] | None = None):
+ self._sets: list[FeatureSet] = list(sets or [])
+
+ def __len__(self) -> int:
+ return len(self._sets)
+
+ def all(self) -> tuple[FeatureSet, ...]:
+ return tuple(self._sets)
+
+ def get(self, set_id: str) -> FeatureSet | None:
+ for fs in self._sets:
+ if fs.set_id == set_id:
+ return fs
+ return None
+
+ def add(self, feature_set: FeatureSet) -> str:
+ self._sets.append(feature_set)
+ return feature_set.set_id
+
+ def remove(self, set_id: str) -> bool:
+ before = len(self._sets)
+ self._sets = [fs for fs in self._sets if fs.set_id != set_id]
+ return len(self._sets) != before
+
+ def rename(self, set_id: str, name: str) -> bool:
+ for index, fs in enumerate(self._sets):
+ if fs.set_id == set_id:
+ self._sets[index] = replace(fs, name=str(name))
+ return True
+ return False
+
+ def clear(self) -> None:
+ self._sets = []
+
+ # ---- persistence -------------------------------------------------------
+ def to_dict(self) -> dict[str, Any]:
+ return {"version": 1, "feature_sets": [fs.to_dict() for fs in self._sets]}
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "FeatureSetStore":
+ items = data.get("feature_sets", []) if isinstance(data, dict) else []
+ return cls([FeatureSet.from_dict(item) for item in items])
+
+ def save(self, path: str | Path) -> None:
+ Path(path).write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
+
+ @classmethod
+ def load(cls, path: str | Path) -> "FeatureSetStore":
+ p = Path(path)
+ if not p.exists():
+ return cls()
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return cls()
+ return cls.from_dict(data)
diff --git a/probeflow/measurements/point_table_io.py b/probeflow/measurements/point_table_io.py
new file mode 100644
index 0000000..cb200f0
--- /dev/null
+++ b/probeflow/measurements/point_table_io.py
@@ -0,0 +1,540 @@
+"""GUI-free readers for point-position tables (CSV / JSON) for Particle Statistics.
+
+ProbeFlow can analyse externally produced point collections by importing a file
+from disk. This module sniffs a file to understand its shape, then loads it into
+one or more :class:`~probeflow.measurements.feature_sets.FeatureSet` objects that
+the Particle Statistics UI already knows how to analyse and pool.
+
+Supported inputs:
+
+* **Generic CSV** — two position columns (with or without a leading
+ particle-number / id column), delimiter and header auto-detected, units
+ inferred from header names (``x_px`` / ``x_nm`` / ``x_m`` / ``x_phys`` + unit
+ columns) or supplied by the caller.
+* **ProbeFlow CSV exports** — Feature Finder
+ (``index,x_px,y_px,x_nm,y_nm,value``) and the measurements point export
+ (``point_id,x_px,y_px,x_phys,y_phys,...,x_unit,...``).
+* **ProbeFlow JSON** — Feature Counting ``write_json`` particle/detection files
+ (``meta`` + ``items``, with embedded calibration) and a saved
+ :class:`FeatureSetStore` (``{"version", "feature_sets": [...]}``).
+
+The CSV path has no third-party dependencies (numpy + stdlib only). Feature
+Counting JSON is routed through the AdStat adapter's
+``feature_counting_to_particle_table`` when available, falling back to a direct
+field read if AdStat is not installed.
+"""
+
+from __future__ import annotations
+
+import csv
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+
+from probeflow.measurements.feature_sets import FeatureSet, FeatureSetStore
+
+
+# metres per unit; ``px`` is special (needs a pixel size, handled separately)
+_UNIT_SCALE_M: dict[str, float] = {"m": 1.0, "nm": 1e-9, "um": 1e-6, "µm": 1e-6}
+ACCEPTED_UNITS = ("px", "nm", "um", "m")
+
+# Header-name hints, normalised (lower-cased, stripped).
+_PX_NAMES = {"x_px": "y_px", "xpx": "ypx", "x_pixel": "y_pixel", "col": "row", "px_x": "px_y"}
+_NM_NAMES = {"x_nm": "y_nm", "xnm": "ynm"}
+_M_NAMES = {"x_m": "y_m", "xm": "ym", "centroid_x_m": "centroid_y_m", "x_meter": "y_meter"}
+_UM_NAMES = {"x_um": "y_um", "xum": "yum", "x_µm": "y_µm"}
+_PHYS_NAMES = {"x_phys": "y_phys", "x_physical": "y_physical"}
+_BARE_NAMES = {"x": "y", "x_pos": "y_pos", "posx": "posy", "pos_x": "pos_y", "xpos": "ypos"}
+
+_ID_HEADERS = {"index", "id", "point_id", "particle", "particle_id", "n", "#", "no", "num"}
+
+
+@dataclass(frozen=True)
+class PointTablePreview:
+ """What ``sniff_point_table`` learned about a file before a full load."""
+
+ path: str
+ kind: str # generic_csv | probeflow_csv | probeflow_json | feature_set_store_json
+ n_points: int
+ n_sets: int = 1
+ delimiter: str | None = None
+ has_header: bool = False
+ has_id_column: bool = False
+ columns: tuple[str, ...] = ()
+ units: str = "unknown" # px | nm | um | m | unknown
+ scan_range_m: tuple[float, float] | None = None # embedded calibration if present
+ image_shape: tuple[int, int] | None = None
+ bbox_raw: tuple[float, float, float, float] | None = None # xmin,ymin,xmax,ymax in `units`
+ needs_calibration: bool = True
+ notes: tuple[str, ...] = field(default_factory=tuple)
+
+
+# --------------------------------------------------------------------------- #
+# Public API
+# --------------------------------------------------------------------------- #
+def sniff_point_table(path: str | Path) -> PointTablePreview:
+ """Inspect a file and report its detected format without fully loading it."""
+
+ p = Path(path)
+ if p.suffix.lower() == ".json":
+ return _sniff_json(p)
+ return _sniff_csv(p)
+
+
+def load_point_table(
+ path: str | Path,
+ *,
+ units: str | None = None,
+ scan_range_m: tuple[float, float] | None = None,
+ image_shape: tuple[int, int] | None = None,
+ name: str | None = None,
+) -> list[FeatureSet]:
+ """Load a point file into one or more :class:`FeatureSet` objects.
+
+ ``units`` / ``scan_range_m`` / ``image_shape`` override the values inferred by
+ :func:`sniff_point_table` and are required for CSV files whose units cannot be
+ inferred. ProbeFlow JSON files carry their own calibration and ignore these
+ unless explicitly overridden.
+ """
+
+ p = Path(path)
+ preview = sniff_point_table(p)
+ label = name or p.stem
+
+ if preview.kind == "feature_set_store_json":
+ return list(FeatureSetStore.from_dict(json.loads(p.read_text(encoding="utf-8"))).all())
+
+ if preview.kind == "probeflow_json":
+ return [_load_probeflow_json(p, preview, scan_range_m, image_shape, label)]
+
+ # CSV (generic or ProbeFlow)
+ resolved_units = units or preview.units
+ if resolved_units not in ACCEPTED_UNITS:
+ raise ValueError(
+ "could not infer position units; specify units as one of "
+ f"{ACCEPTED_UNITS}"
+ )
+ if preview.bbox_raw is None or preview.n_points == 0:
+ raise ValueError("no point rows found in file")
+ if scan_range_m is None:
+ scan_range_m = default_scan_range_m(preview.bbox_raw, resolved_units)
+ if image_shape is None:
+ image_shape = default_image_shape(scan_range_m)
+ xy_raw = _read_csv_points(p, preview)
+ fs = _build_feature_set(
+ xy_raw,
+ units=resolved_units,
+ scan_range_m=scan_range_m,
+ image_shape=image_shape,
+ name=label,
+ source_type="imported_csv",
+ metadata={"import_source": str(p), "import_units": resolved_units},
+ )
+ return [fs]
+
+
+# --------------------------------------------------------------------------- #
+# Calibration defaults (used by the import dialog and by load defaults)
+# --------------------------------------------------------------------------- #
+def default_image_shape(scan_range_m: tuple[float, float], *, max_side: int = 1024) -> tuple[int, int]:
+ """Synthetic (ny, nx) pixel dims for an image-less import, keeping aspect."""
+
+ w, h = float(scan_range_m[0]), float(scan_range_m[1])
+ if w <= 0.0 or h <= 0.0:
+ return (max_side, max_side)
+ if w >= h:
+ nx = max_side
+ ny = max(1, round(max_side * h / w))
+ else:
+ ny = max_side
+ nx = max(1, round(max_side * w / h))
+ return (int(ny), int(nx))
+
+
+def default_scan_range_m(
+ bbox_raw: tuple[float, float, float, float],
+ units: str,
+ *,
+ margin: float = 0.05,
+ px_fallback_nm: float = 1.0,
+) -> tuple[float, float]:
+ """Default physical field size that contains all points (origin at 0,0).
+
+ For pixel units with no pixel size, assumes ``px_fallback_nm`` per pixel; the
+ import dialog lets the user override.
+ """
+
+ _, _, xmax, ymax = bbox_raw
+ scale = _UNIT_SCALE_M.get(units, px_fallback_nm * 1e-9) if units != "px" else px_fallback_nm * 1e-9
+ w = max(float(xmax), 0.0) * scale * (1.0 + margin)
+ h = max(float(ymax), 0.0) * scale * (1.0 + margin)
+ # Guard against degenerate (single point / zero extent) fields.
+ w = w if w > 0.0 else scale
+ h = h if h > 0.0 else scale
+ return (w, h)
+
+
+# --------------------------------------------------------------------------- #
+# CSV internals
+# --------------------------------------------------------------------------- #
+def _sniff_csv(p: Path) -> PointTablePreview:
+ text = p.read_text(encoding="utf-8", errors="replace")
+ sample = text[:4096]
+ delimiter = ","
+ has_header = False
+ try:
+ dialect = csv.Sniffer().sniff(sample, delimiters=",;\t ")
+ delimiter = dialect.delimiter
+ except csv.Error:
+ delimiter = "\t" if "\t" in sample and "," not in sample else ","
+ rows = [r for r in csv.reader(text.splitlines(), delimiter=delimiter) if r]
+ if not rows:
+ return PointTablePreview(path=str(p), kind="generic_csv", n_points=0, notes=("empty file",))
+
+ first = rows[0]
+ has_header = not _row_is_numeric(first)
+ header = [c.strip() for c in first] if has_header else []
+ data_rows = rows[1:] if has_header else rows
+
+ x_col, y_col, units, x_unit_col = _classify_columns(header)
+ has_id = _detect_id_column(header, data_rows)
+ if x_col is None or y_col is None:
+ # Fall back to positional: first two numeric columns (after an id column).
+ start = 1 if has_id else 0
+ x_col, y_col = start, start + 1
+ units = units if units in ACCEPTED_UNITS else "unknown"
+
+ # Units from an explicit unit column (measurements export).
+ if units == "phys" and x_unit_col is not None:
+ units = _normalise_unit(_first_value(data_rows, x_unit_col))
+ if units == "phys":
+ units = "unknown"
+
+ xy = _rows_to_xy(data_rows, x_col, y_col)
+ n = len(xy)
+ bbox = _bbox(xy) if n else None
+ kind = "probeflow_csv" if (has_header and _is_probeflow_csv(header)) else "generic_csv"
+ notes = []
+ if units == "unknown":
+ notes.append("Units could not be inferred from headers; choose units on import.")
+ return PointTablePreview(
+ path=str(p),
+ kind=kind,
+ n_points=n,
+ delimiter=delimiter,
+ has_header=has_header,
+ has_id_column=has_id,
+ columns=tuple(header),
+ units=units,
+ bbox_raw=bbox,
+ needs_calibration=True,
+ notes=tuple(notes),
+ )
+
+
+def _read_csv_points(p: Path, preview: PointTablePreview) -> np.ndarray:
+ text = p.read_text(encoding="utf-8", errors="replace")
+ delimiter = preview.delimiter or ","
+ rows = [r for r in csv.reader(text.splitlines(), delimiter=delimiter) if r]
+ data_rows = rows[1:] if preview.has_header else rows
+ header = [c.strip() for c in rows[0]] if preview.has_header else []
+ x_col, y_col, _units, _unit_col = _classify_columns(header)
+ if x_col is None or y_col is None:
+ start = 1 if preview.has_id_column else 0
+ x_col, y_col = start, start + 1
+ return _rows_to_xy(data_rows, x_col, y_col)
+
+
+def _classify_columns(
+ header: list[str],
+) -> tuple[int | None, int | None, str, int | None]:
+ """Return (x_col, y_col, units, x_unit_col) from header names."""
+
+ if not header:
+ return None, None, "unknown", None
+ lut = {name.strip().lower(): idx for idx, name in enumerate(header)}
+ x_unit_col = lut.get("x_unit")
+ # Priority: nm > m > um > phys(+unit) > px > bare.
+ for names, unit in (
+ (_NM_NAMES, "nm"),
+ (_M_NAMES, "m"),
+ (_UM_NAMES, "um"),
+ (_PHYS_NAMES, "phys"),
+ (_PX_NAMES, "px"),
+ (_BARE_NAMES, "bare"),
+ ):
+ for xname, yname in names.items():
+ if xname in lut and yname in lut:
+ resolved = "unknown" if unit == "bare" else unit
+ return lut[xname], lut[yname], resolved, x_unit_col
+ return None, None, "unknown", x_unit_col
+
+
+def _detect_id_column(header: list[str], data_rows: list[list[str]]) -> bool:
+ if header and header[0].strip().lower() in _ID_HEADERS:
+ return True
+ if header: # named first column that isn't an id header
+ return False
+ # Headerless: treat a leading 0/1-based integer sequence as an id column.
+ if len(data_rows) < 2:
+ return False
+ try:
+ first_col = [int(float(r[0])) for r in data_rows if r and r[0].strip() != ""]
+ except (ValueError, IndexError):
+ return False
+ if len(first_col) < 2:
+ return False
+ seq0 = first_col == list(range(len(first_col)))
+ seq1 = first_col == list(range(1, len(first_col) + 1))
+ return seq0 or seq1
+
+
+def _is_probeflow_csv(header: list[str]) -> bool:
+ lower = {c.strip().lower() for c in header}
+ return {"x_px", "y_px", "x_nm", "y_nm"} <= lower or {"x_phys", "y_phys", "x_unit"} <= lower
+
+
+# --------------------------------------------------------------------------- #
+# JSON internals
+# --------------------------------------------------------------------------- #
+def _sniff_json(p: Path) -> PointTablePreview:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ if isinstance(data, dict) and "feature_sets" in data:
+ sets = data.get("feature_sets") or []
+ total = sum(len(s.get("points_m", []) or []) for s in sets)
+ return PointTablePreview(
+ path=str(p),
+ kind="feature_set_store_json",
+ n_points=int(total),
+ n_sets=len(sets),
+ needs_calibration=False,
+ notes=("Saved ProbeFlow feature-set file; calibration is embedded.",),
+ )
+ if isinstance(data, dict) and "items" in data:
+ meta = data.get("meta") or {}
+ items = data.get("items") or []
+ scan_range = meta.get("scan_range_m")
+ pixels = meta.get("pixels")
+ scan_range_m = (
+ (float(scan_range[0]), float(scan_range[1])) if scan_range else None
+ )
+ image_shape = (int(pixels[1]), int(pixels[0])) if pixels else None # (ny, nx)
+ return PointTablePreview(
+ path=str(p),
+ kind="probeflow_json",
+ n_points=len(items),
+ columns=(str(meta.get("kind", "items")),),
+ units="m",
+ scan_range_m=scan_range_m,
+ image_shape=image_shape,
+ needs_calibration=scan_range_m is None,
+ notes=("ProbeFlow analysis JSON; calibration read from its meta block.",),
+ )
+ raise ValueError(f"unrecognised JSON structure in {p.name}")
+
+
+def _load_probeflow_json(
+ p: Path,
+ preview: PointTablePreview,
+ scan_range_m: tuple[float, float] | None,
+ image_shape: tuple[int, int] | None,
+ label: str,
+) -> FeatureSet:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ items = list(data.get("items") or [])
+ scan_range_m = scan_range_m or preview.scan_range_m
+ image_shape = image_shape or preview.image_shape
+ if scan_range_m is None:
+ # Derive from the metre coordinates if the meta block lacked a field size.
+ xy = _items_xy_m(items)
+ bbox = _bbox(xy)
+ scan_range_m = default_scan_range_m(bbox, "m")
+ if image_shape is None:
+ image_shape = default_image_shape(scan_range_m)
+
+ points_px, points_m = _items_to_px_m(items, scan_range_m, image_shape)
+ return FeatureSet.from_points(
+ name=label,
+ points_px=points_px,
+ points_m=points_m,
+ scan_range_m=scan_range_m,
+ image_shape=image_shape,
+ source_type="imported_json",
+ metadata={"import_source": str(p)},
+ )
+
+
+def feature_items_to_feature_set(
+ items: Any,
+ *,
+ scan_range_m: tuple[float, float],
+ image_shape: tuple[int, int],
+ name: str,
+ source_type: str = "feature_counting",
+ image_label: str = "",
+ metadata: dict[str, Any] | None = None,
+) -> FeatureSet:
+ """Build a :class:`FeatureSet` from Feature Counting particles/detections.
+
+ Accepts dataclass items (with ``to_dict``) or plain dicts. Positions are
+ converted via the AdStat adapter's ``feature_counting_to_particle_table``
+ (see :func:`_items_to_px_m`), so the live Feature Counting → Particle
+ Statistics path exercises that converter rather than leaving it stranded.
+ """
+
+ dicts = [it.to_dict() if hasattr(it, "to_dict") else dict(it) for it in items]
+ points_px, points_m = _items_to_px_m(dicts, scan_range_m, image_shape)
+ return FeatureSet.from_points(
+ name=name,
+ points_px=points_px,
+ points_m=points_m,
+ scan_range_m=scan_range_m,
+ image_shape=image_shape,
+ source_type=source_type,
+ image_label=image_label,
+ metadata=dict(metadata or {}),
+ )
+
+
+def _items_to_px_m(
+ items: list[dict],
+ scan_range_m: tuple[float, float],
+ image_shape: tuple[int, int],
+) -> tuple[np.ndarray, np.ndarray]:
+ """Convert Feature Counting items to (points_px, points_m).
+
+ Routes through the AdStat adapter's ``feature_counting_to_particle_table`` so
+ that previously stranded code is exercised; falls back to a direct field read
+ when AdStat is not installed.
+ """
+
+ ny, nx = image_shape
+ scan = SimpleNamespace(scan_range_m=scan_range_m, dims=(nx, ny))
+ try:
+ from probeflow.analysis.adstat_adapter import (
+ feature_counting_to_particle_table,
+ scan_calibration_to_adstat,
+ )
+
+ calibration = scan_calibration_to_adstat(scan)
+ table = feature_counting_to_particle_table(items, calibration=calibration)
+ points_m = np.asarray(table.xy_nm, dtype=float) * 1e-9
+ points_px = np.asarray(
+ [[float(par.x_px), float(par.y_px)] for par in table.particles],
+ dtype=float,
+ )
+ return points_px, points_m
+ except Exception:
+ # Direct fallback (no AdStat): read metre coordinates and synthesise px.
+ points_m = _items_xy_m(items)
+ px_x_m = scan_range_m[0] / nx
+ px_y_m = scan_range_m[1] / ny
+ points_px = points_m / np.array([px_x_m, px_y_m], dtype=float)
+ return points_px, points_m
+
+
+def _items_xy_m(items: list[dict]) -> np.ndarray:
+ out = []
+ for it in items:
+ if it.get("centroid_x_m") is not None:
+ out.append([float(it["centroid_x_m"]), float(it["centroid_y_m"])])
+ elif it.get("x_m") is not None:
+ out.append([float(it["x_m"]), float(it["y_m"])])
+ elif it.get("x_nm") is not None:
+ out.append([float(it["x_nm"]) * 1e-9, float(it["y_nm"]) * 1e-9])
+ return np.asarray(out, dtype=float).reshape(-1, 2)
+
+
+# --------------------------------------------------------------------------- #
+# Shared helpers
+# --------------------------------------------------------------------------- #
+def _build_feature_set(
+ xy_raw: np.ndarray,
+ *,
+ units: str,
+ scan_range_m: tuple[float, float],
+ image_shape: tuple[int, int],
+ name: str,
+ source_type: str,
+ metadata: dict[str, Any],
+) -> FeatureSet:
+ xy = np.asarray(xy_raw, dtype=float).reshape(-1, 2)
+ ny, nx = image_shape
+ px_x_m = scan_range_m[0] / nx
+ px_y_m = scan_range_m[1] / ny
+ if units == "px":
+ points_px = xy
+ points_m = xy * np.array([px_x_m, px_y_m], dtype=float)
+ else:
+ scale = _UNIT_SCALE_M[units]
+ points_m = xy * scale
+ points_px = points_m / np.array([px_x_m, px_y_m], dtype=float)
+ return FeatureSet.from_points(
+ name=name,
+ points_px=points_px,
+ points_m=points_m,
+ scan_range_m=scan_range_m,
+ image_shape=image_shape,
+ source_type=source_type,
+ metadata=metadata,
+ )
+
+
+def _rows_to_xy(data_rows: list[list[str]], x_col: int, y_col: int) -> np.ndarray:
+ out = []
+ for r in data_rows:
+ if len(r) <= max(x_col, y_col):
+ continue
+ try:
+ out.append([float(r[x_col]), float(r[y_col])])
+ except ValueError:
+ continue
+ return np.asarray(out, dtype=float).reshape(-1, 2)
+
+
+def _row_is_numeric(row: list[str]) -> bool:
+ numeric = 0
+ for cell in row:
+ cell = cell.strip()
+ if cell == "":
+ continue
+ try:
+ float(cell)
+ numeric += 1
+ except ValueError:
+ return False
+ return numeric > 0
+
+
+def _bbox(xy: np.ndarray) -> tuple[float, float, float, float]:
+ xy = np.asarray(xy, dtype=float).reshape(-1, 2)
+ return (
+ float(xy[:, 0].min()),
+ float(xy[:, 1].min()),
+ float(xy[:, 0].max()),
+ float(xy[:, 1].max()),
+ )
+
+
+def _first_value(data_rows: list[list[str]], col: int) -> str:
+ for r in data_rows:
+ if len(r) > col and r[col].strip():
+ return r[col].strip()
+ return ""
+
+
+def _normalise_unit(raw: str) -> str:
+ u = raw.strip().lower()
+ if u in {"nm", "nanometer", "nanometre"}:
+ return "nm"
+ if u in {"um", "µm", "micron", "micrometer", "micrometre"}:
+ return "um"
+ if u in {"m", "meter", "metre"}:
+ return "m"
+ if u in {"px", "pixel", "pixels"}:
+ return "px"
+ return "phys"
diff --git a/pyproject.toml b/pyproject.toml
index 9c18a98..1a83dbd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,9 +21,10 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest", "vulture"]
+adstat = ["adstat>=0.2,<0.3"]
features = ["opencv-python", "scikit-learn"]
gwyddion = ["gwyfile"]
-all = ["opencv-python", "scikit-learn", "gwyfile", "pytest"]
+all = ["adstat>=0.2,<0.3", "opencv-python", "scikit-learn", "gwyfile", "pytest"]
[project.scripts]
probeflow = "probeflow.cli:main"
diff --git a/scripts/adstat_demo.py b/scripts/adstat_demo.py
new file mode 100644
index 0000000..daa6128
--- /dev/null
+++ b/scripts/adstat_demo.py
@@ -0,0 +1,277 @@
+#!/usr/bin/env python3
+"""Generate a reproducible ProbeFlow-to-AdStat teaching run.
+
+This script uses the same direct adapter path as the ProbeFlow image viewer:
+synthetic ProbeFlow point source -> AdStat ParticleTable/region -> analysis
+summary/comparison -> ResultViewSpec. It is intentionally small and filesystem
+friendly so a user can inspect the generated CSV, JSON, and preview image.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import os
+from collections.abc import Mapping, Sequence
+from dataclasses import asdict, is_dataclass
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+
+from probeflow.analysis.adstat_adapter import compare_point_source_view_spec
+from probeflow.gui.roi_context import PointSource
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="Run a reproducible synthetic ProbeFlow-to-AdStat demo.",
+ )
+ parser.add_argument(
+ "--output-dir",
+ type=Path,
+ default=Path("/tmp/probeflow_adstat_demo"),
+ help="Directory for generated CSV, JSON, and PNG artifacts.",
+ )
+ parser.add_argument("--seed", type=int, default=20260620)
+ parser.add_argument("--points", type=int, default=42)
+ parser.add_argument("--width-nm", type=float, default=80.0)
+ parser.add_argument("--height-nm", type=float, default=60.0)
+ parser.add_argument("--width-px", type=int, default=320)
+ parser.add_argument("--height-px", type=int, default=240)
+ parser.add_argument("--n-simulations", type=int, default=19)
+ args = parser.parse_args(argv)
+
+ args.output_dir.mkdir(parents=True, exist_ok=True)
+ os.environ.setdefault("MPLCONFIGDIR", str(args.output_dir / ".matplotlib"))
+ source, scan = synthetic_probe_flow_point_source(
+ seed=args.seed,
+ n_points=args.points,
+ width_nm=args.width_nm,
+ height_nm=args.height_nm,
+ width_px=args.width_px,
+ height_px=args.height_px,
+ )
+
+ try:
+ spec = compare_point_source_view_spec(
+ source,
+ scan=scan,
+ image_shape=(args.height_px, args.width_px),
+ scan_id="synthetic_adstat_demo",
+ pair_bin_width_nm=2.0,
+ pair_max_radius_nm=min(args.width_nm, args.height_nm) / 3.0,
+ cluster_radius_nm=5.0,
+ n_simulations=args.n_simulations,
+ random_seed=args.seed,
+ )
+ except ImportError as exc:
+ print(str(exc))
+ print(
+ "Install the optional AdStat dependency with 'pip install \"probeflow[adstat]\"' "
+ "or put an AdStat checkout on PYTHONPATH."
+ )
+ return 2
+
+ points_csv = args.output_dir / "synthetic_points.csv"
+ result_json = args.output_dir / "adstat_result_view_spec.json"
+ preview_png = args.output_dir / "synthetic_points_preview.png"
+
+ write_points_csv(points_csv, source)
+ write_view_spec_json(result_json, spec, args=args)
+ write_preview_png(preview_png, source, width_nm=args.width_nm, height_nm=args.height_nm)
+ print_demo_summary(
+ spec,
+ output_dir=args.output_dir,
+ points_csv=points_csv,
+ result_json=result_json,
+ preview_png=preview_png,
+ )
+ return 0
+
+
+def synthetic_probe_flow_point_source(
+ *,
+ seed: int,
+ n_points: int,
+ width_nm: float,
+ height_nm: float,
+ width_px: int,
+ height_px: int,
+) -> tuple[PointSource, SimpleNamespace]:
+ """Return clustered-but-random ProbeFlow-shaped points and scan metadata."""
+
+ if n_points < 4:
+ raise ValueError("--points must be at least 4")
+ rng = np.random.default_rng(seed)
+ n_clustered = max(4, int(round(n_points * 0.72)))
+ n_background = n_points - n_clustered
+ centers = np.array(
+ [
+ [0.30 * width_nm, 0.35 * height_nm],
+ [0.68 * width_nm, 0.62 * height_nm],
+ [0.46 * width_nm, 0.76 * height_nm],
+ ],
+ dtype=float,
+ )
+ assignments = rng.integers(0, len(centers), size=n_clustered)
+ clustered = centers[assignments] + rng.normal(
+ loc=0.0,
+ scale=[0.055 * width_nm, 0.06 * height_nm],
+ size=(n_clustered, 2),
+ )
+ background = rng.uniform(
+ [0.06 * width_nm, 0.06 * height_nm],
+ [0.94 * width_nm, 0.94 * height_nm],
+ size=(n_background, 2),
+ )
+ xy_nm = np.vstack((clustered, background))
+ xy_nm[:, 0] = np.clip(xy_nm[:, 0], 0.5, width_nm - 0.5)
+ xy_nm[:, 1] = np.clip(xy_nm[:, 1], 0.5, height_nm - 0.5)
+ rng.shuffle(xy_nm)
+
+ pixel_size_x_nm = width_nm / float(width_px)
+ pixel_size_y_nm = height_nm / float(height_px)
+ points_px = xy_nm / np.array([pixel_size_x_nm, pixel_size_y_nm])
+ points_m = xy_nm * 1e-9
+ source = PointSource(
+ label="Synthetic clustered adsorbates",
+ source_type="synthetic_demo",
+ points_px=points_px,
+ points_m=points_m,
+ metadata={
+ "seed": seed,
+ "generator": "scripts/adstat_demo.py",
+ "point_count": int(n_points),
+ "pattern": "clustered background mixture",
+ },
+ )
+ scan = SimpleNamespace(
+ scan_range_m=(width_nm * 1e-9, height_nm * 1e-9),
+ dims=(int(width_px), int(height_px)),
+ source_path=Path("synthetic_adstat_demo.sxm"),
+ )
+ return source, scan
+
+
+def write_points_csv(path: Path, source: PointSource) -> None:
+ points_nm = np.asarray(source.points_m, dtype=float) * 1e9
+ with path.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.writer(handle)
+ writer.writerow(["id", "x_nm", "y_nm", "x_px", "y_px", "source"])
+ for index, (xy_nm, xy_px) in enumerate(zip(points_nm, source.points_px)):
+ writer.writerow(
+ [
+ f"p{index:03d}",
+ f"{xy_nm[0]:.6g}",
+ f"{xy_nm[1]:.6g}",
+ f"{xy_px[0]:.6g}",
+ f"{xy_px[1]:.6g}",
+ source.label,
+ ]
+ )
+
+
+def write_view_spec_json(path: Path, spec: Any, *, args: argparse.Namespace) -> None:
+ payload = {
+ "demo": {
+ "script": "scripts/adstat_demo.py",
+ "seed": args.seed,
+ "points": args.points,
+ "scan_nm": {"width": args.width_nm, "height": args.height_nm},
+ "scan_px": {"width": args.width_px, "height": args.height_px},
+ "n_simulations": args.n_simulations,
+ },
+ "result_view_spec": _plain(spec),
+ }
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
+
+
+def write_preview_png(path: Path, source: PointSource, *, width_nm: float, height_nm: float) -> None:
+ try:
+ import matplotlib.pyplot as plt
+ except Exception as exc: # pragma: no cover - matplotlib is a runtime dependency
+ print(f"Skipping preview PNG; matplotlib could not be imported: {exc}")
+ return
+
+ points_nm = np.asarray(source.points_m, dtype=float) * 1e9
+ fig, ax = plt.subplots(figsize=(6.4, 4.8), constrained_layout=True)
+ ax.scatter(points_nm[:, 0], points_nm[:, 1], s=34, c="#2f7ed8", edgecolors="white")
+ ax.set_title("Synthetic ProbeFlow point collection")
+ ax.set_xlabel("x (nm)")
+ ax.set_ylabel("y (nm)")
+ ax.set_xlim(0.0, width_nm)
+ ax.set_ylim(height_nm, 0.0)
+ ax.set_aspect("equal", adjustable="box")
+ ax.grid(True, alpha=0.2)
+ fig.savefig(path, dpi=150)
+ plt.close(fig)
+
+
+def print_demo_summary(
+ spec: Any,
+ *,
+ output_dir: Path,
+ points_csv: Path,
+ result_json: Path,
+ preview_png: Path,
+) -> None:
+ panels = tuple(_field(spec, "panels", ()) or ())
+ verdict_rows = tuple(_field(spec, "verdict_rows", ()) or ())
+ status_lines = tuple(_field(spec, "status_lines", ()) or ())
+ print("ProbeFlow AdStat synthetic demo complete")
+ print(f"Output directory: {output_dir}")
+ print(f"Points CSV: {points_csv}")
+ print(f"Result view-spec JSON: {result_json}")
+ print(f"Preview PNG: {preview_png}")
+ print("")
+ print("Rendered panel contract:")
+ for panel in panels:
+ print(
+ f" - {str(_field(panel, 'kind', 'panel'))}: "
+ f"{str(_field(panel, 'title', _field(panel, 'statistic', 'untitled')))}"
+ )
+ if verdict_rows:
+ print("")
+ print("Verdict rows:")
+ for row in verdict_rows:
+ print(" - " + " | ".join(str(item) for item in row))
+ if status_lines:
+ print("")
+ print("Diagnostics:")
+ for line in status_lines:
+ print(f" - {line}")
+
+
+def _plain(value: Any) -> Any:
+ if is_dataclass(value):
+ return _plain(asdict(value))
+ if isinstance(value, np.ndarray):
+ return value.tolist()
+ if isinstance(value, np.generic):
+ return value.item()
+ if isinstance(value, Path):
+ return str(value)
+ if isinstance(value, Mapping):
+ return {str(key): _plain(item) for key, item in value.items()}
+ if isinstance(value, tuple | list):
+ return [_plain(item) for item in value]
+ if hasattr(value, "__dict__"):
+ return {
+ str(key): _plain(item)
+ for key, item in vars(value).items()
+ if not str(key).startswith("_")
+ }
+ return value
+
+
+def _field(obj: Any, name: str, default: Any = None) -> Any:
+ if isinstance(obj, Mapping):
+ return obj.get(name, default)
+ return getattr(obj, name, default)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/conftest.py b/tests/conftest.py
index 2281e6a..98e411f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -39,6 +39,8 @@
MIXED_QT_FIXTURE_MODULES = {
"test_feature_lattice.py",
"test_pair_correlation.py",
+ "test_adstat_results_dialog.py",
+ "test_adstat_workbench_dialog.py",
"test_fft_viewer_utils.py",
"test_definitions_dialog.py",
"test_lattice_grid.py",
@@ -48,6 +50,7 @@
# so the fixturename-based gating in MIXED_QT_FIXTURE_MODULES cannot see them.
MIXED_QT_TESTS = {
("test_lattice_grid.py", "test_export_png_creates_file"),
+ ("test_layout_compatibility.py", "test_gui_entrypoint_import_when_qt_available"),
}
diff --git a/tests/test_adstat_adapter.py b/tests/test_adstat_adapter.py
new file mode 100644
index 0000000..e530d45
--- /dev/null
+++ b/tests/test_adstat_adapter.py
@@ -0,0 +1,534 @@
+"""Tests for the ProbeFlow-to-AdStat adapter seam."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+pytest.importorskip("adstat")
+
+from probeflow.analysis.adstat_adapter import ( # noqa: E402
+ adstat_sandbox_context,
+ adstat_sandbox_preview,
+ adstat_sandbox_state,
+ adstat_sandbox_view_spec,
+ compare_particle_collection_view_spec,
+ compare_point_set_records_view_spec,
+ compare_point_source_view_spec,
+ feature_counting_to_particle_table,
+ feature_layers_to_adstat,
+ point_set_record,
+ point_source_to_particle_table,
+ roi_to_region,
+ scan_calibration_to_adstat,
+)
+from probeflow.analysis.features import Detection, Particle as FeatureParticle # noqa: E402
+from probeflow.analysis.pair_correlation import compute_pair_correlation # noqa: E402
+from probeflow.core.roi import ROI # noqa: E402
+from probeflow.gui.roi_context import PointSource # noqa: E402
+from probeflow.gui.viewer.tool_launch import adstat_workbench_launch_context # noqa: E402
+
+
+class _Scan:
+ scan_range_m = (6e-9, 10e-9)
+ dims = (12, 8)
+
+
+class _SquareScan:
+ scan_range_m = (20e-9, 20e-9)
+ dims = (10, 10)
+
+
+class _CoarseAnisotropicScan:
+ scan_range_m = (40e-9, 80e-9)
+ dims = (20, 20)
+
+
+def _point_source() -> PointSource:
+ points_px = np.array(
+ [[1.0, 1.0], [4.0, 1.0], [9.0, 5.0], [2.0, 5.0]],
+ dtype=float,
+ )
+ return PointSource(
+ label="Feature result",
+ source_type="feature_finder",
+ points_px=points_px,
+ points_m=points_px * np.array([0.5e-9, 1.25e-9]),
+ metadata={
+ "detection_mode": "minima",
+ "threshold_mode": "below",
+ "point_count": 4,
+ },
+ )
+
+
+def _feature_counting_items():
+ return [
+ FeatureParticle(
+ index=10,
+ centroid_x_m=2.0e-9,
+ centroid_y_m=2.5e-9,
+ area_m2=2.0e-18,
+ area_nm2=2.0,
+ bbox_m=(1.0e-9, 2.0e-9, 3.0e-9, 4.0e-9),
+ bbox_px=(2, 1, 6, 3),
+ mean_height=1.0,
+ max_height=2.0,
+ min_height=0.5,
+ n_pixels=4,
+ orientation_deg=30.0,
+ sharpness=12.0,
+ ),
+ Detection(
+ index=11,
+ x_m=3.0e-9,
+ y_m=5.0e-9,
+ x_px=6,
+ y_px=4,
+ correlation=0.82,
+ local_height=1.5,
+ ),
+ ]
+
+
+def test_scan_calibration_preserves_anisotropic_probe_flow_pixels() -> None:
+ calibration = scan_calibration_to_adstat(_Scan())
+
+ assert calibration.pixel_size_x_nm == pytest.approx(0.5)
+ assert calibration.pixel_size_y_nm == pytest.approx(1.25)
+ assert calibration.width_px == 12
+ assert calibration.height_px == 8
+
+
+def test_point_source_to_particle_table_converts_feature_finder_points() -> None:
+ table = point_source_to_particle_table(_point_source(), scan_id="scan-a")
+
+ assert len(table) == 4
+ np.testing.assert_allclose(
+ table.xy_nm,
+ [[0.5, 1.25], [2.0, 1.25], [4.5, 6.25], [1.0, 6.25]],
+ )
+ assert [particle.x_px for particle in table.particles] == [1.0, 4.0, 9.0, 2.0]
+ assert table.metadata["scan_id"] == "scan-a"
+ assert table.metadata["probeflow_point_source_type"] == "feature_finder"
+ assert table.metadata["detection_mode"] == "minima"
+
+
+def test_feature_counting_particles_and_template_detections_convert_to_table() -> None:
+ calibration = scan_calibration_to_adstat(_Scan())
+
+ table = feature_counting_to_particle_table(
+ _feature_counting_items(),
+ scan_id="scan-a",
+ calibration=calibration,
+ )
+
+ assert len(table) == 2
+ np.testing.assert_allclose(table.xy_nm, [[2.0, 2.5], [3.0, 5.0]])
+ assert table.particles[0].x_px == pytest.approx(4.0)
+ assert table.particles[0].y_px == pytest.approx(2.0)
+ assert table.particles[0].area_nm2 == pytest.approx(2.0)
+ assert table.particles[0].orientation_deg == pytest.approx(30.0)
+ assert table.particles[1].x_px == pytest.approx(6.0)
+ assert table.particles[1].confidence == pytest.approx(0.82)
+ assert table.metadata["probeflow_point_source_type"] == "detections,particles"
+
+
+def test_roi_to_region_uses_area_roi_mask_or_full_image_fallback() -> None:
+ roi = ROI.new("rectangle", {"x": 1, "y": 1, "width": 2, "height": 3})
+ masked = roi_to_region(roi, scan=_Scan(), image_shape=(8, 12))
+ full = roi_to_region(None, scan=_Scan(), image_shape=(8, 12))
+
+ assert masked.boundary_condition == "irregular_mask"
+ assert masked.area_nm2 == pytest.approx(6 * 0.5 * 1.25)
+ assert full.boundary_condition == "finite_hard_boundary"
+ assert full.area_nm2 == pytest.approx(60.0)
+
+
+def test_feature_layers_to_adstat_converts_independent_points_and_lines() -> None:
+ calibration = scan_calibration_to_adstat(_Scan())
+ layers = [
+ {
+ "name": "defects",
+ "kind": "points",
+ "feature_type": "defect",
+ "provenance": {
+ "source": "manual_defect_marks",
+ "measured_independently": True,
+ "derived_from_particles": False,
+ },
+ "points_px": [
+ {"id": "d1", "x_px": 3.0, "y_px": 2.0},
+ {"id": "d2", "x_px": 10.0, "y_px": 5.0},
+ ],
+ },
+ {
+ "name": "steps",
+ "kind": "lines",
+ "feature_type": "step",
+ "provenance": {
+ "source": "manual_step_trace",
+ "measured_independently": True,
+ "derived_from_particles": False,
+ },
+ "segments_px": [
+ {"id": "s1", "x1_px": 0.0, "y1_px": 3.0, "x2_px": 11.0, "y2_px": 3.0},
+ ],
+ },
+ ]
+
+ converted = feature_layers_to_adstat(layers, calibration=calibration)
+
+ assert [layer.name for layer in converted] == ["defects", "steps"]
+ np.testing.assert_allclose(converted[0].xy_nm, [[1.5, 2.5], [5.0, 6.25]])
+ np.testing.assert_allclose(
+ converted[1].segments_nm,
+ [[[0.0, 3.75], [5.5, 3.75]]],
+ )
+
+
+def test_feature_layers_to_adstat_rejects_particle_derived_layers() -> None:
+ calibration = scan_calibration_to_adstat(_Scan())
+ layer = {
+ "name": "bad",
+ "kind": "points",
+ "feature_type": "derived",
+ "provenance": {
+ "source": "centroids",
+ "measured_independently": True,
+ "derived_from_particles": True,
+ },
+ "points_px": [{"x_px": 1.0, "y_px": 2.0}],
+ }
+
+ with pytest.raises(ValueError, match="independent"):
+ feature_layers_to_adstat([layer], calibration=calibration)
+
+
+def test_point_set_record_keeps_series_metadata_and_region() -> None:
+ record = point_set_record(
+ dataset_id="cov_0p1_img01",
+ scan=_Scan(),
+ point_source=_point_source(),
+ roi_or_mask=np.ones((8, 12), dtype=bool),
+ image_shape=(8, 12),
+ series_value=0.1,
+ series_unit="ML",
+ series_label="0.1 ML",
+ )
+
+ assert record.dataset_id == "cov_0p1_img01"
+ assert record.series_value == pytest.approx(0.1)
+ assert record.region.boundary_condition == "irregular_mask"
+ assert len(record.table) == 4
+
+
+def test_sandbox_preview_overlay_is_independent_from_generated_points() -> None:
+ context = adstat_sandbox_context()
+ config = context.SandboxConfig(pattern="random", n=40, n_simulations=6, seed=3)
+
+ preview = adstat_sandbox_preview(config, active_model="homogeneous_poisson")
+
+ assert preview.simulated_xy_nm is not None
+ assert preview.xy_nm.shape == preview.simulated_xy_nm.shape
+ assert not np.array_equal(preview.xy_nm, preview.simulated_xy_nm)
+
+
+def test_compare_point_source_view_spec_returns_qt_renderable_panels() -> None:
+ spec = compare_point_source_view_spec(
+ _point_source(),
+ scan=_Scan(),
+ roi_or_mask=None,
+ image_shape=(8, 12),
+ scan_id="scan-a",
+ pair_bin_width_nm=1.0,
+ pair_max_radius_nm=4.0,
+ cluster_radius_nm=2.0,
+ n_simulations=4,
+ random_seed=17,
+ )
+
+ panel_kinds = [panel.kind for panel in spec.panels]
+ assert panel_kinds[0] == "realspace"
+ assert "curve" in panel_kinds
+ assert spec.verdict_rows
+ assert spec.metadata["active_model"] == "homogeneous_poisson"
+
+
+def test_compare_point_source_view_spec_derives_scales_when_unset() -> None:
+ # The viewer does not ask the user for nm scales, so it passes None for every
+ # radius/bin. AdStat 0.2 rejects an all-None configuration; the adapter must
+ # derive scales from the region and still produce the core statistic panels.
+ # Local-order statistics are opt-in and excluded by default.
+ spec = compare_point_source_view_spec(
+ _point_source(),
+ scan=_Scan(),
+ image_shape=(8, 12),
+ n_simulations=6,
+ random_seed=0,
+ )
+
+ statistics = {
+ panel.statistic
+ for panel in spec.panels
+ if getattr(panel, "statistic", None) and panel.statistic != "realspace"
+ }
+ assert {
+ "pair_correlation_g_r",
+ "nearest_neighbor_distribution",
+ "ripley_l_function",
+ "cluster_size_counts",
+ } <= statistics
+ # ψ4/ψ6/g(r,θ) are opt-in: not present unless include_ordering=True.
+ assert statistics.isdisjoint(
+ {"pair_correlation_g_r_theta", "bond_order_psi6", "bond_order_psi4"}
+ )
+ assert spec.verdict_rows
+
+
+def test_compare_point_source_view_spec_excludes_ordering_by_default() -> None:
+ spec = compare_point_source_view_spec(
+ _point_source(),
+ scan=_Scan(),
+ image_shape=(8, 12),
+ n_simulations=4,
+ random_seed=0,
+ )
+ panels = {panel.statistic for panel in spec.panels}
+ assert {"pair_correlation_g_r_theta", "bond_order_psi6", "bond_order_psi4"}.isdisjoint(
+ panels
+ )
+ assert all(
+ not (len(row) > 1 and str(row[1]) in {"bond_order_psi6", "bond_order_psi4"})
+ for row in (spec.verdict_rows or ())
+ )
+
+
+def test_compare_point_source_view_spec_includes_ordering_when_opted_in() -> None:
+ spec = compare_point_source_view_spec(
+ _point_source(),
+ scan=_Scan(),
+ image_shape=(8, 12),
+ n_simulations=4,
+ random_seed=0,
+ include_ordering=True,
+ )
+ panels = {panel.statistic: panel for panel in spec.panels}
+
+ assert panels["pair_correlation_g_r_theta"].kind == "heatmap"
+ assert panels["bond_order_psi6"].metadata["neighbor_rule"] == "fixed_radius"
+ assert panels["bond_order_psi4"].metadata["neighbor_radius_nm"] >= 1.25
+
+
+def test_compare_point_source_auto_bins_clamp_to_square_pixel_resolution() -> None:
+ spec = compare_point_source_view_spec(
+ _point_source(),
+ scan=_SquareScan(),
+ image_shape=(10, 10),
+ n_simulations=4,
+ random_seed=0,
+ )
+ pair_panel = next(
+ panel for panel in spec.panels if panel.statistic == "pair_correlation_g_r"
+ )
+
+ assert spec.metadata["pixel_resolution_floor_nm"] == pytest.approx(2.0)
+ assert spec.metadata["bin_width_resolution_limited"] is True
+ assert any("Pixel size is 2" in line for line in spec.status_lines)
+ assert np.diff(pair_panel.x).min() == pytest.approx(2.0)
+
+
+def test_compare_point_source_auto_bins_use_larger_non_square_pixel() -> None:
+ spec = compare_point_source_view_spec(
+ _point_source(),
+ scan=_CoarseAnisotropicScan(),
+ image_shape=(20, 20),
+ n_simulations=4,
+ random_seed=0,
+ )
+ pair_panel = next(
+ panel for panel in spec.panels if panel.statistic == "pair_correlation_g_r"
+ )
+
+ assert spec.metadata["pixel_resolution_floor_nm"] == pytest.approx(4.0)
+ assert spec.metadata["bin_width_resolution_limited"] is True
+ assert any("Pixel size is 4" in line for line in spec.status_lines)
+ assert np.diff(pair_panel.x).min() == pytest.approx(4.0)
+
+
+def test_compare_point_source_explicit_small_bins_warn_but_are_preserved() -> None:
+ spec = compare_point_source_view_spec(
+ _point_source(),
+ scan=_CoarseAnisotropicScan(),
+ image_shape=(20, 20),
+ pair_bin_width_nm=0.5,
+ pair_max_radius_nm=5.0,
+ nn_bin_width_nm=0.5,
+ nn_max_distance_nm=5.0,
+ n_simulations=4,
+ random_seed=0,
+ )
+ pair_panel = next(
+ panel for panel in spec.panels if panel.statistic == "pair_correlation_g_r"
+ )
+
+ assert spec.metadata["bin_width_resolution_limited"] is False
+ assert not any("automatic distance bins" in line for line in spec.status_lines)
+ assert any("explicit pair bin width 0.5 nm" in line for line in spec.status_lines)
+ assert any(
+ "explicit nearest-neighbor bin width 0.5 nm" in line
+ for line in spec.status_lines
+ )
+ assert np.diff(pair_panel.x).min() == pytest.approx(0.5)
+
+
+def test_compare_point_set_records_use_max_pixel_resolution_floor() -> None:
+ fine = point_set_record(
+ dataset_id="fine",
+ scan=_SquareScan(),
+ point_source=_point_source(),
+ image_shape=(10, 10),
+ )
+ coarse = point_set_record(
+ dataset_id="coarse",
+ scan=_CoarseAnisotropicScan(),
+ point_source=_point_source(),
+ image_shape=(20, 20),
+ )
+
+ spec = compare_point_set_records_view_spec(
+ [fine, coarse],
+ n_simulations=4,
+ random_seed=0,
+ )
+
+ assert spec.metadata["pixel_resolution_floor_nm"] == pytest.approx(4.0)
+ assert spec.metadata["bin_width_resolution_limited"] is True
+ assert any("Pixel size is 4" in line for line in spec.status_lines)
+ assert not any(
+ panel.statistic
+ in {"pair_correlation_g_r_theta", "bond_order_psi6", "bond_order_psi4"}
+ for panel in spec.panels
+ )
+
+
+def test_compare_point_source_view_spec_supports_hard_core_model() -> None:
+ # Hard-core needs a hard_core_radius_nm; the adapter must derive one so the
+ # lesson's hard-core model transfers to real data without manual tuning.
+ spec = compare_point_source_view_spec(
+ _point_source(),
+ scan=_Scan(),
+ image_shape=(8, 12),
+ models=("hard_core_random",),
+ n_simulations=6,
+ random_seed=7,
+ )
+
+ assert spec.metadata["active_model"] == "hard_core_random"
+ assert {
+ comparison.ensemble.base_seed
+ for comparison in spec.metadata["comparison_results"]
+ } == {7}
+ assert spec.verdict_rows
+
+
+def test_compare_particle_collection_view_spec_accepts_feature_counting_records() -> None:
+ spec = compare_particle_collection_view_spec(
+ scan=_Scan(),
+ feature_counting_items=_feature_counting_items(),
+ image_shape=(8, 12),
+ scan_id="feature-counting-scan",
+ feature_layers=[
+ {
+ "name": "manual defects",
+ "kind": "points",
+ "feature_type": "defect",
+ "provenance": {
+ "source": "manual marks",
+ "measured_independently": True,
+ "derived_from_particles": False,
+ },
+ "points_px": [{"x_px": 1.0, "y_px": 2.0}],
+ }
+ ],
+ pair_bin_width_nm=1.0,
+ pair_max_radius_nm=4.0,
+ n_simulations=2,
+ random_seed=17,
+ )
+
+ realspace = spec.panels[0]
+ assert realspace.kind == "realspace"
+ assert realspace.metadata["particle_count"] == 2
+ np.testing.assert_allclose(realspace.metadata["feature_xy_nm"], [[0.5, 2.5]])
+ assert spec.verdict_rows
+
+
+def test_adstat_workbench_launch_context_uses_existing_point_source_path() -> None:
+ context = adstat_workbench_launch_context(
+ [_point_source()],
+ scan=_Scan(),
+ image_shape=(8, 12),
+ point_source_label="Feature result",
+ pair_bin_width_nm=1.0,
+ pair_max_radius_nm=4.0,
+ cluster_radius_nm=2.0,
+ n_simulations=4,
+ random_seed=17,
+ )
+
+ assert context.ready
+ assert context.point_source_label == "Feature result"
+ assert context.view_spec is not None
+ assert context.view_spec.panels[0].kind == "realspace"
+
+
+def test_pair_correlation_migration_documents_intentional_engine_difference() -> None:
+ source = _point_source()
+ old = compute_pair_correlation(
+ source.points_m,
+ roi_area_m2=60e-18,
+ r_max_m=4e-9,
+ bin_width_m=1e-9,
+ )
+
+ spec = compare_point_source_view_spec(
+ source,
+ scan=_Scan(),
+ image_shape=(8, 12),
+ pair_bin_width_nm=1.0,
+ pair_max_radius_nm=4.0,
+ n_simulations=4,
+ random_seed=17,
+ )
+ pair_panel = next(
+ panel for panel in spec.panels if panel.statistic == "pair_correlation_g_r"
+ )
+
+ assert old.edge_correction == "square_window_translational"
+ assert pair_panel.reference_line == 1.0
+ assert pair_panel.metadata["n_simulations"] == 4
+ assert pair_panel.band_low is not None
+ assert pair_panel.band_high is not None
+
+
+def test_adstat_sandbox_state_and_view_spec_are_probe_flow_renderable() -> None:
+ context = adstat_sandbox_context()
+ state = adstat_sandbox_state(
+ context.SandboxConfig(n=8, n_simulations=2, seed=3)
+ )
+
+ ready = adstat_sandbox_view_spec(state)
+ assert ready.panels == ()
+ assert ready.metadata["has_result"] is False
+
+ state.run()
+ spec = adstat_sandbox_view_spec(state)
+
+ assert spec.panels[0].kind == "realspace"
+ assert spec.panels[0].metadata["data_mode"] == "sandbox"
+ assert spec.panels[0].metadata["particle_count"] == 8
+ assert spec.panels[0].metadata["simulated"] is not None
+ assert spec.panels[0].metadata["feature_xy_nm"] is not None
+ assert spec.verdict_rows
+ assert spec.metadata["has_result"] is True
diff --git a/tests/test_adstat_demo_script.py b/tests/test_adstat_demo_script.py
new file mode 100644
index 0000000..a019cba
--- /dev/null
+++ b/tests/test_adstat_demo_script.py
@@ -0,0 +1,51 @@
+"""Tests for the user-facing AdStat synthetic demo script."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+pytest.importorskip("adstat")
+
+
+def _load_demo_module():
+ script = Path(__file__).resolve().parents[1] / "scripts" / "adstat_demo.py"
+ spec = importlib.util.spec_from_file_location("probeflow_adstat_demo_script", script)
+ module = importlib.util.module_from_spec(spec)
+ assert spec is not None
+ assert spec.loader is not None
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_adstat_demo_script_generates_teaching_artifacts(tmp_path) -> None:
+ module = _load_demo_module()
+
+ exit_code = module.main([
+ "--output-dir",
+ str(tmp_path),
+ "--points",
+ "12",
+ "--n-simulations",
+ "4",
+ "--seed",
+ "123",
+ ])
+
+ assert exit_code == 0
+ points_csv = tmp_path / "synthetic_points.csv"
+ result_json = tmp_path / "adstat_result_view_spec.json"
+ preview_png = tmp_path / "synthetic_points_preview.png"
+ assert points_csv.exists()
+ assert result_json.exists()
+ assert preview_png.exists()
+ assert len(points_csv.read_text(encoding="utf-8").splitlines()) == 13
+
+ payload = json.loads(result_json.read_text(encoding="utf-8"))
+ assert payload["demo"]["seed"] == 123
+ panels = payload["result_view_spec"]["panels"]
+ assert panels[0]["kind"] == "realspace"
+ assert payload["result_view_spec"]["verdict_rows"]
diff --git a/tests/test_adstat_export.py b/tests/test_adstat_export.py
new file mode 100644
index 0000000..1c6dc18
--- /dev/null
+++ b/tests/test_adstat_export.py
@@ -0,0 +1,93 @@
+"""Tests for exporting Particle Statistics results to CSV / JSON."""
+
+from __future__ import annotations
+
+import csv
+import json
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+
+pytest.importorskip("adstat")
+
+from probeflow.analysis.adstat_adapter import compare_point_source_view_spec # noqa: E402
+from probeflow.measurements.adstat_export import ( # noqa: E402
+ export_result_csvs,
+ export_result_json,
+ panel_curve_csv_text,
+ verdict_rows_csv_text,
+)
+
+
+def _spec():
+ rng = np.random.default_rng(0)
+ pts_nm = rng.uniform(5.0, 95.0, size=(40, 2))
+ points_m = pts_nm * 1e-9
+ pixel_nm = 100.0 / 256.0
+ points_px = pts_nm / pixel_nm
+ source = SimpleNamespace(
+ label="export test",
+ source_type="synthetic",
+ points_px=points_px,
+ points_m=points_m,
+ metadata={},
+ )
+ scan = SimpleNamespace(scan_range_m=(100e-9, 100e-9), dims=(256, 256))
+ return compare_point_source_view_spec(
+ source, scan=scan, image_shape=(256, 256), n_simulations=5, random_seed=0
+ )
+
+
+def test_export_csvs_writes_curve_and_verdict_files(tmp_path):
+ spec = _spec()
+ written = export_result_csvs(spec, tmp_path, base="run A")
+ names = {p.name for p in written}
+ # Curve statistics each get a file; the verdict table gets one too.
+ assert any("pair_correlation_g_r" in n for n in names)
+ assert any("nearest_neighbor_distribution" in n for n in names)
+ assert any(n.endswith("_verdicts.csv") for n in names)
+ # base label is slugged (space -> underscore)
+ assert all(n.startswith("run_A_") for n in names)
+
+
+def test_curve_csv_has_distance_and_observed_columns(tmp_path):
+ spec = _spec()
+ gr = next(p for p in spec.panels if getattr(p, "statistic", "") == "pair_correlation_g_r")
+ text = panel_curve_csv_text(gr)
+ assert text is not None
+ rows = list(csv.reader(text.splitlines()))
+ header = rows[0]
+ assert header[0] # x label present (distance axis)
+ assert "observed" in header
+ assert "model_low" in header and "model_high" in header
+ assert len(rows) > 2 # header + several data rows
+ # data rows are numeric
+ float(rows[1][0])
+ float(rows[1][1])
+
+
+def test_verdict_csv_round_trips(tmp_path):
+ spec = _spec()
+ text = verdict_rows_csv_text(spec)
+ assert text is not None
+ rows = list(csv.reader(text.splitlines()))
+ assert rows[0][0] == "model"
+ assert len(rows) > 1
+
+
+def test_export_json_snapshot(tmp_path):
+ spec = _spec()
+ out = export_result_json(spec, tmp_path / "result.json")
+ payload = json.loads(out.read_text(encoding="utf-8"))
+ assert "panels" in payload
+ assert "verdict_rows" in payload
+ assert isinstance(payload["panels"], list) and payload["panels"]
+
+
+def test_curve_csv_skips_non_curve_panels():
+ # A real-space scatter panel (2-D points, no 1-D x curve) is not a curve.
+ panel = SimpleNamespace(
+ statistic="real_space", x_label="x (nm)", x=None, observed=np.zeros((5, 2))
+ )
+ assert panel_curve_csv_text(panel) is None
diff --git a/tests/test_adstat_results_dialog.py b/tests/test_adstat_results_dialog.py
new file mode 100644
index 0000000..5e975c1
--- /dev/null
+++ b/tests/test_adstat_results_dialog.py
@@ -0,0 +1,433 @@
+"""Tests for the AdStat view-spec Qt renderer."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+from PySide6.QtWidgets import QApplication, QLabel, QTabWidget, QTableWidget
+
+from probeflow.gui.dialogs.adstat_results import (
+ AdStatPlotWidget,
+ AdStatResultsDialog,
+ AdStatResultView,
+ _CURVE_BAND_COLOR,
+ _CURVE_MODEL_COLOR,
+ _CURVE_OBSERVED_COLOR,
+ _curve_legend_entries,
+ _empty_panel_message,
+ _plot_title,
+ _populated_x_range,
+ _realspace_marker_style,
+ _series_reference_curves,
+ _series_curve_label,
+ _ticks,
+)
+
+
+def test_ticks_are_regularly_spaced_nice_numbers():
+ ticks = _ticks(0.474, 1.84)
+ assert len(ticks) >= 3
+ steps = np.diff(np.asarray(ticks))
+ # All intervals are equal (regular grid)...
+ assert np.allclose(steps, steps[0])
+ # ...and the step is a "nice" 1/2/2.5/5 x 10**k value.
+ assert steps[0] == pytest.approx(0.5)
+
+
+def test_ticks_anchor_places_reference_on_a_gridline():
+ ticks = _ticks(0.47, 1.84, anchor=1.0)
+ assert any(abs(t - 1.0) < 1e-9 for t in ticks)
+
+
+def test_populated_x_range_trims_empty_tail():
+ # Cluster-size-style data: non-zero only at small x, zero out to 120.
+ x = np.arange(0.0, 121.0)
+ observed = np.zeros_like(x)
+ observed[:3] = [100.0, 12.0, 3.0]
+ fallback = (float(x.min()), float(x.max()))
+ x_min, x_max = _populated_x_range(x, (observed, None, None), fallback)
+ assert x_min <= 0.0
+ # Trimmed to just past the last non-zero sample (size 2), not out to 120.
+ assert 2.0 <= x_max <= 8.0
+
+
+def test_series_curve_label_names_single_pooled_group():
+ # A single pooled group carries a "0 " (value 0, unitless) coverage label.
+ assert _series_curve_label("0 ") == "pooled mean"
+ assert _series_curve_label("0") == "pooled mean"
+ assert _series_curve_label("") == "pooled mean"
+ # A real coverage label is preserved.
+ assert _series_curve_label("0.5 ML") == "0.5 ML"
+
+
+def test_series_reference_curves_parse_tutorial_single_image_reference():
+ panel = SimpleNamespace(
+ metadata={
+ "reference_curves": (
+ {
+ "x": [0.5, 1.5, 2.5],
+ "y": [1.2, 0.9, 1.0],
+ "label": "single image reference",
+ "color": "#ff9f1c",
+ },
+ )
+ }
+ )
+
+ curves = _series_reference_curves(panel)
+
+ assert len(curves) == 1
+ assert curves[0]["label"] == "single image reference"
+ assert curves[0]["color"] == "#ff9f1c"
+ np.testing.assert_allclose(curves[0]["y"], [1.2, 0.9, 1.0])
+
+
+def test_populated_x_range_falls_back_when_all_zero():
+ x = np.arange(0.0, 10.0)
+ zeros = np.zeros_like(x)
+ fallback = (0.0, 9.0)
+ assert _populated_x_range(x, (zeros,), fallback) == fallback
+
+
+@pytest.fixture
+def qapp():
+ app = QApplication.instance() or QApplication([])
+ yield app
+
+
+def _synthetic_view_spec():
+ return SimpleNamespace(
+ panels=(
+ SimpleNamespace(
+ statistic="realspace",
+ title="real space",
+ kind="realspace",
+ x_label="x (nm)",
+ y_label="y (nm)",
+ reference_line=None,
+ observed=np.array([[0.0, 0.0], [1.0, 2.0], [3.0, 1.0]]),
+ caption_lines=("particle_count: 3",),
+ metadata={},
+ ),
+ SimpleNamespace(
+ statistic="pair_correlation_g_r",
+ title="pair correlation",
+ kind="curve",
+ x_label="r (nm)",
+ y_label="g(r)",
+ reference_line=1.0,
+ x=np.array([0.5, 1.5, 2.5]),
+ observed=np.array([0.0, 1.2, 0.8]),
+ band_low=np.array([0.4, 0.5, 0.6]),
+ band_high=np.array([1.6, 1.5, 1.4]),
+ central=np.array([1.0, 1.0, 1.0]),
+ coordinate_values={"r_nm": np.array([0.5, 1.5, 2.5])},
+ caption_lines=("pair_correlation_g_r / homogeneous_poisson",),
+ metadata={"n_simulations": 4},
+ ),
+ SimpleNamespace(
+ statistic="diagnostics",
+ title="diagnostics",
+ kind="table",
+ x_label="",
+ y_label="",
+ reference_line=None,
+ table_columns=("severity", "code", "message"),
+ table_rows=(("WARN", "SMALL_N", "few particles"),),
+ caption_lines=(),
+ metadata={},
+ ),
+ ),
+ verdict_rows=(
+ ("homogeneous_poisson", "pair_correlation_g_r", "consistent_with_null", "0.5", "0.1", "0/3", "4"),
+ ),
+ status_lines=("small-N result is underpowered",),
+ explainer=SimpleNamespace(
+ friendly_name="random placement",
+ plain_summary="Places particles randomly in the analysis region.",
+ useful_for="Baseline comparison.",
+ cautions=("Does not prove absence of interactions.",),
+ ),
+ )
+
+
+def test_adstat_results_dialog_renders_summary_plots_and_tables(qapp):
+ dlg = AdStatResultsDialog(_synthetic_view_spec(), source_label="Feature result")
+
+ tabs = dlg.findChild(QTabWidget)
+ tables = dlg.findChildren(QTableWidget)
+
+ assert dlg.windowTitle() == "Particle Statistics results"
+ assert dlg.tab_count == 5
+ assert dlg.tab_titles == ("Summary", "Technical details", "real space", "pair correlation", "diagnostics")
+ assert tabs.tabText(0) == "Summary"
+ assert tabs.tabText(1) == "Technical details"
+ assert len(tables) >= 2
+ assert tables[0].rowCount() == 1
+ assert sorted(plot.panel_kind for plot in dlg.findChildren(AdStatPlotWidget)) == ["curve", "realspace"]
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_adstat_results_dialog_tolerates_partial_panel_contract(qapp):
+ spec = SimpleNamespace(
+ panels=(
+ SimpleNamespace(
+ title="bad realspace",
+ kind="realspace",
+ x_label="x",
+ y_label="y",
+ reference_line="not numeric",
+ observed=np.array([1.0, 2.0, 3.0]),
+ caption_lines=(),
+ metadata={},
+ ),
+ SimpleNamespace(
+ title="diagnostics",
+ kind="table",
+ table_columns=("severity",),
+ table_rows=(("WARN", "SMALL_N"),),
+ caption_lines=(),
+ metadata={},
+ ),
+ ),
+ verdict_rows=(),
+ status_lines=(),
+ explainer=None,
+ )
+
+ dlg = AdStatResultsDialog(spec, source_label="Partial contract")
+
+ assert dlg.tab_titles == ("Summary", "bad realspace", "diagnostics")
+ tables = dlg.findChildren(QTableWidget)
+ assert tables[-1].horizontalHeaderItem(1).text() == "col 2"
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_adstat_result_view_keeps_real_and_sandbox_modes_visually_distinct(qapp):
+ real = AdStatResultView(_synthetic_view_spec(), source_label="Feature result")
+ sandbox = AdStatResultView(
+ _synthetic_view_spec(),
+ source_label="Sandbox",
+ data_mode="sandbox",
+ )
+
+ assert real.data_mode == "real"
+ assert real.banner_text == ""
+ assert sandbox.data_mode == "sandbox"
+ assert sandbox.banner_text == "TEST MODE - GENERATED DATA"
+ assert _realspace_marker_style("real")["marker"] == "o"
+ assert _realspace_marker_style("real")["color"] == "#2f7ed8"
+ assert _realspace_marker_style("sandbox")["marker"] == "^"
+ assert _realspace_marker_style("sandbox")["color"] == "#f28e2b"
+
+ real.close()
+ sandbox.close()
+ real.deleteLater()
+ sandbox.deleteLater()
+
+
+def test_adstat_result_view_show_panels_toggles_plot_tabs(qapp):
+ full = AdStatResultView(_synthetic_view_spec(), source_label="Full")
+ slim = AdStatResultView(
+ _synthetic_view_spec(),
+ source_label="Slim",
+ show_panels=False,
+ )
+
+ # Default keeps the per-panel plot/table tabs (used by the standalone dialog).
+ assert "pair correlation" in full.tab_titles
+ assert "real space" in full.tab_titles
+ # Embedded view shows only the verdict summary + technical details.
+ assert slim.tab_titles == ("Summary", "Technical details")
+
+ full.close()
+ slim.close()
+ full.deleteLater()
+ slim.deleteLater()
+
+
+def test_adstat_result_view_updates_tabs_when_spec_changes(qapp):
+ view = AdStatResultView(_synthetic_view_spec(), source_label="Feature result")
+ assert view.tab_count == 5
+
+ view.set_view_spec(
+ SimpleNamespace(
+ panels=(),
+ verdict_rows=(),
+ status_lines=("ready",),
+ explainer=None,
+ )
+ )
+
+ assert view.tab_titles == ("Summary",)
+
+ view.close()
+ view.deleteLater()
+
+
+def test_adstat_result_view_unparents_old_pages_when_spec_changes(qapp):
+ view = AdStatResultView(_synthetic_view_spec(), source_label="Feature result")
+ tabs = view.findChild(QTabWidget)
+ old_pages = [tabs.widget(index) for index in range(tabs.count())]
+
+ view.set_view_spec(
+ SimpleNamespace(
+ panels=(),
+ verdict_rows=(),
+ status_lines=("ready",),
+ explainer=None,
+ )
+ )
+
+ assert view.tab_titles == ("Summary",)
+ assert all(page.parent() is None for page in old_pages)
+
+ view.close()
+ view.deleteLater()
+
+
+def test_adstat_plot_widget_paints_with_qt_renderer(qapp):
+ plot = AdStatPlotWidget(_synthetic_view_spec().panels[1])
+ plot.resize(480, 360)
+
+ pixmap = plot.grab()
+
+ assert not pixmap.isNull()
+ assert pixmap.width() == 480
+ assert pixmap.height() == 360
+
+ plot.close()
+ plot.deleteLater()
+
+
+def test_adstat_plot_widget_supports_observed_only_curve_mode(qapp):
+ plot = AdStatPlotWidget(_synthetic_view_spec().panels[1], curve_mode="observed_only")
+
+ assert plot.curve_mode == "observed_only"
+
+ plot.close()
+ plot.deleteLater()
+
+
+def test_adstat_plot_widget_supports_curve_visibility_flags(qapp):
+ panel = _synthetic_view_spec().panels[1]
+ comparison = AdStatPlotWidget(panel)
+ model_only = AdStatPlotWidget(panel, show_observed_curve=False)
+ observed_only = AdStatPlotWidget(panel, show_model_curves=False)
+ none_visible = AdStatPlotWidget(
+ panel,
+ show_observed_curve=False,
+ show_model_curves=False,
+ )
+
+ assert comparison.show_observed_curve is True
+ assert comparison.show_model_curves is True
+ assert model_only.show_observed_curve is False
+ assert model_only.show_model_curves is True
+ assert observed_only.show_observed_curve is True
+ assert observed_only.show_model_curves is False
+ assert none_visible.show_observed_curve is False
+ assert none_visible.show_model_curves is False
+ assert _empty_panel_message("curve", no_visible_curves=True) == (
+ "No selected plot layers visible"
+ )
+
+ for plot in (comparison, model_only, observed_only, none_visible):
+ plot.close()
+ plot.deleteLater()
+
+
+def test_adstat_curve_plot_labels_data_vs_model(qapp):
+ panel = _synthetic_view_spec().panels[1]
+ legend = _curve_legend_entries(has_band=True, has_central=True)
+
+ assert _plot_title(panel) == "Pair correlation g(r)"
+ assert [entry[0] for entry in legend] == [
+ "model envelope",
+ "model median",
+ "observed data",
+ ]
+ assert _CURVE_OBSERVED_COLOR == "#ff9f1c"
+ assert _CURVE_MODEL_COLOR == "#7cc7ff"
+ assert _CURVE_BAND_COLOR == "#5ea3ff"
+ assert [entry[0] for entry in _curve_legend_entries(
+ has_band=True,
+ has_central=True,
+ has_observed=False,
+ )] == ["model envelope", "model median"]
+ assert [entry[0] for entry in _curve_legend_entries(
+ has_band=False,
+ has_central=False,
+ has_observed=True,
+ )] == ["observed data"]
+
+
+def test_adstat_result_view_groups_models_with_human_labels(qapp):
+ view = AdStatResultView(_synthetic_view_spec(), source_label="Feature result")
+ tabs = view.findChild(QTabWidget)
+
+ summary_labels = "\n".join(
+ label.text() for label in tabs.widget(0).findChildren(QLabel)
+ )
+ technical_tables = tabs.widget(1).findChildren(QTableWidget)
+ technical_cells = "\n".join(
+ technical_tables[0].item(row, column).text()
+ for row in range(technical_tables[0].rowCount())
+ for column in range(technical_tables[0].columnCount())
+ if technical_tables[0].item(row, column) is not None
+ )
+
+ assert "Random placement" in summary_labels
+ assert "Pair correlation" in summary_labels
+ assert "homogeneous_poisson" not in summary_labels
+ assert "homogeneous_poisson" in technical_cells
+
+ view.close()
+ view.deleteLater()
+
+
+def test_adstat_generated_view_spec_opens_in_results_dialog(qapp):
+ pytest.importorskip("adstat")
+
+ from probeflow.analysis.adstat_adapter import compare_point_source_view_spec
+ from probeflow.gui.roi_context import PointSource
+
+ points_px = np.array(
+ [[1.0, 1.0], [4.0, 1.0], [9.0, 5.0], [2.0, 5.0]],
+ dtype=float,
+ )
+ source = PointSource(
+ label="Feature result",
+ source_type="feature_finder",
+ points_px=points_px,
+ points_m=points_px * np.array([0.5e-9, 1.25e-9]),
+ metadata={"detection_mode": "maxima"},
+ )
+ scan = SimpleNamespace(scan_range_m=(6e-9, 10e-9), dims=(12, 8))
+
+ spec = compare_point_source_view_spec(
+ source,
+ scan=scan,
+ image_shape=(8, 12),
+ pair_bin_width_nm=1.0,
+ pair_max_radius_nm=4.0,
+ n_simulations=4,
+ random_seed=17,
+ )
+ dlg = AdStatResultsDialog(spec, source_label="Feature result")
+ tabs = dlg.findChild(QTabWidget)
+ tab_titles = [tabs.tabText(index) for index in range(tabs.count())]
+
+ assert dlg.tab_count >= 2
+ assert tab_titles[0] == "Summary"
+ assert "Technical details" in tab_titles
+ assert "real space" in tab_titles
+
+ dlg.close()
+ dlg.deleteLater()
diff --git a/tests/test_adstat_validation.py b/tests/test_adstat_validation.py
new file mode 100644
index 0000000..debe445
--- /dev/null
+++ b/tests/test_adstat_validation.py
@@ -0,0 +1,181 @@
+"""Known-answer validation for Particle Statistics verdicts.
+
+These tests run synthetic patterns with a *known* spatial structure through the
+ProbeFlow→AdStat adapter and assert the verdict matches the ground truth:
+
+- random points are consistent with the homogeneous-Poisson null;
+- clustered points reject it on pair correlation / Ripley L;
+- strongly spaced points reject it on nearest-neighbour distance;
+- a triangular lattice rejects it on ψ6, a square lattice on ψ4 (ordering on);
+- random points stay consistent on ψ even when ordering is enabled.
+
+They double as the maturity check the docs reference: if a case does not behave
+as expected, it is marked xfail with a note rather than silently shipped.
+"""
+
+from __future__ import annotations
+
+import math
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+
+pytest.importorskip("adstat")
+
+from probeflow.analysis.adstat_adapter import ( # noqa: E402
+ ORDERING_STATISTICS,
+ compare_point_source_view_spec,
+)
+
+_FIELD_NM = 100.0
+_PX = 256
+
+
+def _source(xy_nm: np.ndarray) -> SimpleNamespace:
+ xy_nm = np.asarray(xy_nm, dtype=float).reshape(-1, 2)
+ pixel_nm = _FIELD_NM / _PX
+ return SimpleNamespace(
+ label="validation",
+ source_type="synthetic",
+ points_px=xy_nm / pixel_nm,
+ points_m=xy_nm * 1e-9,
+ metadata={},
+ )
+
+
+def _scan() -> SimpleNamespace:
+ return SimpleNamespace(scan_range_m=(_FIELD_NM * 1e-9, _FIELD_NM * 1e-9), dims=(_PX, _PX))
+
+
+def _spec(xy_nm: np.ndarray, *, include_ordering: bool = False, n_simulations: int = 99):
+ return compare_point_source_view_spec(
+ _source(xy_nm),
+ scan=_scan(),
+ image_shape=(_PX, _PX),
+ n_simulations=n_simulations,
+ random_seed=0,
+ include_ordering=include_ordering,
+ )
+
+
+def _verdict(spec, statistic: str) -> str | None:
+ for row in getattr(spec, "verdict_rows", ()) or ():
+ if len(row) > 2 and str(row[1]) == statistic:
+ return str(row[2])
+ return None
+
+
+def _is_inconsistent(verdict: str | None) -> bool:
+ return verdict is not None and "inconsistent" in verdict
+
+
+def _is_consistent(verdict: str | None) -> bool:
+ return verdict is not None and verdict.endswith("consistent_with_null")
+
+
+# --------------------------------------------------------------------------- #
+# Pattern generators (all inside a margin so points stay within the field)
+# --------------------------------------------------------------------------- #
+def _random(n: int, seed: int = 1) -> np.ndarray:
+ rng = np.random.default_rng(seed)
+ return rng.uniform(3.0, _FIELD_NM - 3.0, size=(n, 2))
+
+
+def _clustered(seed: int = 2) -> np.ndarray:
+ rng = np.random.default_rng(seed)
+ centers = np.array([[30.0, 35.0], [68.0, 62.0], [46.0, 78.0]])
+ assign = rng.integers(0, len(centers), size=120)
+ clustered = centers[assign] + rng.normal(0.0, 4.0, size=(120, 2))
+ return np.clip(clustered, 2.0, _FIELD_NM - 2.0)
+
+
+def _square_lattice(a: float = 8.0, jitter: float = 0.0, seed: int = 3) -> np.ndarray:
+ rng = np.random.default_rng(seed)
+ coords = np.arange(a, _FIELD_NM - a + 1e-9, a)
+ xs, ys = np.meshgrid(coords, coords)
+ pts = np.column_stack([xs.ravel(), ys.ravel()])
+ if jitter > 0.0:
+ pts = pts + rng.normal(0.0, jitter, size=pts.shape)
+ return pts
+
+
+def _triangular_lattice(a: float = 8.0) -> np.ndarray:
+ dy = a * math.sqrt(3.0) / 2.0
+ pts = []
+ row = 0
+ y = a
+ while y < _FIELD_NM - a:
+ x_off = (a / 2.0) if (row % 2) else 0.0
+ x = a + x_off
+ while x < _FIELD_NM - a:
+ pts.append((x, y))
+ x += a
+ y += dy
+ row += 1
+ return np.asarray(pts, dtype=float)
+
+
+# --------------------------------------------------------------------------- #
+# Core null model (always on)
+# --------------------------------------------------------------------------- #
+def test_random_is_consistent_with_poisson():
+ spec = _spec(_random(140))
+ for stat in (
+ "pair_correlation_g_r",
+ "nearest_neighbor_distribution",
+ "ripley_l_function",
+ "cluster_size_counts",
+ ):
+ assert _is_consistent(_verdict(spec, stat)), f"{stat}: {_verdict(spec, stat)}"
+
+
+def test_clustered_rejects_poisson_on_pair_correlation():
+ spec = _spec(_clustered())
+ gr = _verdict(spec, "pair_correlation_g_r")
+ ripley = _verdict(spec, "ripley_l_function")
+ assert _is_inconsistent(gr) or _is_inconsistent(ripley), (gr, ripley)
+
+
+def test_spaced_points_reject_poisson_on_nearest_neighbour():
+ # A lightly jittered square lattice has strong minimum spacing.
+ spec = _spec(_square_lattice(a=8.0, jitter=1.0))
+ nn = _verdict(spec, "nearest_neighbor_distribution")
+ assert _is_inconsistent(nn), nn
+
+
+# --------------------------------------------------------------------------- #
+# Opt-in ordering statistics
+# --------------------------------------------------------------------------- #
+def test_default_run_has_no_ordering_panels():
+ spec = _spec(_random(120), include_ordering=False)
+ panel_stats = {str(getattr(p, "statistic", "")) for p in spec.panels}
+ assert panel_stats.isdisjoint(ORDERING_STATISTICS)
+ assert all(
+ not (len(r) > 1 and str(r[1]) in ORDERING_STATISTICS)
+ for r in (spec.verdict_rows or ())
+ )
+
+
+def test_ordering_on_adds_psi_panels():
+ spec = _spec(_random(120), include_ordering=True)
+ panel_stats = {str(getattr(p, "statistic", "")) for p in spec.panels}
+ assert {"bond_order_psi6", "bond_order_psi4"} <= panel_stats
+
+
+def test_random_psi_is_consistent():
+ spec = _spec(_random(140), include_ordering=True)
+ assert _is_consistent(_verdict(spec, "bond_order_psi6"))
+ assert _is_consistent(_verdict(spec, "bond_order_psi4"))
+
+
+def test_triangular_lattice_rejects_on_psi6():
+ spec = _spec(_triangular_lattice(a=8.0), include_ordering=True)
+ psi6 = _verdict(spec, "bond_order_psi6")
+ assert _is_inconsistent(psi6), f"ψ6 should reject a triangular lattice, got {psi6}"
+
+
+def test_square_lattice_rejects_on_psi4():
+ spec = _spec(_square_lattice(a=8.0), include_ordering=True)
+ psi4 = _verdict(spec, "bond_order_psi4")
+ assert _is_inconsistent(psi4), f"ψ4 should reject a square lattice, got {psi4}"
diff --git a/tests/test_adstat_workbench_dialog.py b/tests/test_adstat_workbench_dialog.py
new file mode 100644
index 0000000..ce6a166
--- /dev/null
+++ b/tests/test_adstat_workbench_dialog.py
@@ -0,0 +1,1614 @@
+"""Tests for the ProbeFlow Particle Statistics tool."""
+
+from __future__ import annotations
+
+import re
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+from PySide6.QtCore import QRectF
+from PySide6.QtWidgets import QApplication, QLabel, QPushButton
+
+from probeflow.gui.dialogs.adstat_workbench import AdStatWorkbenchDialog
+from probeflow.gui.dialogs.adstat_results import AdStatPlotWidget
+from probeflow.gui.dialogs.particle_statistics import (
+ FocusedStatisticPanel,
+ ParticleFieldView,
+ ParticleStatisticsDialog,
+ _TUTORIALS,
+ _aspect_fit_rect,
+ _panel_for_statistic,
+ _series_focus_read_text,
+ _single_curve_reference_from_spec,
+ _with_series_reference_curve,
+)
+
+
+LESSON_ORDER = [
+ "welcome",
+ "point_pattern",
+ "model_baseline_observed",
+ "model_baseline_model",
+ "model_baseline_overlay",
+ "image_to_statistic",
+ "simulation_envelope",
+ "verdict",
+ "homogeneous_poisson",
+ "clustered",
+ "hard_core_meaning",
+ "hard_core_parameters",
+ "hard_core_statistic",
+ "ordered_cluster_vs_order",
+ "ordered_radial_spacing",
+ "ordered_directional_pairs",
+ "ordered_bond_order",
+ "ordered_square_order",
+ "ordered_mixed",
+ "feature_biased",
+ "other_statistics",
+ "pooling_single",
+ "pooling_two",
+ "model_simulations_sandbox",
+ "real_workflow",
+ "final_caution",
+]
+
+
+def _plain_words(text: str) -> list[str]:
+ plain = re.sub(r"<[^>]+>", " ", text)
+ return re.findall(r"[A-Za-z0-9']+", plain)
+
+
+def test_tutorial_structure_is_concept_first_and_guarded():
+ keys = [ex.key for ex in _TUTORIALS]
+ assert keys == LESSON_ORDER
+
+ for tutorial in _TUTORIALS:
+ for step in tutorial.steps:
+ label = f"{tutorial.key}/{step.title}"
+ assert step.title, label
+ assert step.question, label
+ assert step.look_for, label
+ assert len(_plain_words(" ".join((step.title, step.question, step.look_for)))) <= 60
+ assert len(step.visible_controls or step.controls) <= 1, label
+ assert step.visible_panel in {"field", "plot", "results", "controls"}, label
+
+ feature = next(ex for ex in _TUTORIALS if ex.key == "feature_biased")
+ feature_text = " ".join(
+ f"{step.question} {step.look_for} {step.caution} {step.more_detail}"
+ for step in feature.steps
+ ).lower()
+ assert "independent" in feature_text
+ assert "circular" in feature_text
+
+ pooling = next(ex for ex in _TUTORIALS if ex.key == "pooling_two")
+ pooling_text = " ".join(f"{step.question} {step.caution}" for step in pooling.steps).lower()
+ assert "independent" in pooling_text
+ assert "same experimental condition" in pooling_text
+
+ verdict = next(ex for ex in _TUTORIALS if ex.key == "verdict")
+ verdict_text = " ".join(f"{step.question} {step.look_for}" for step in verdict.steps).lower()
+ assert "consistent with this model" in verdict_text
+ assert "prove" not in verdict_text
+
+
+def test_tutorial_model_baseline_stages_layers_and_language():
+ observed = next(ex for ex in _TUTORIALS if ex.key == "model_baseline_observed").steps[0]
+ model = next(ex for ex in _TUTORIALS if ex.key == "model_baseline_model").steps[0]
+ overlay = next(ex for ex in _TUTORIALS if ex.key == "model_baseline_overlay").steps[0]
+
+ assert observed.show_observed is True
+ assert observed.show_simulated is False
+ assert model.show_observed is False
+ assert model.show_simulated is True
+ assert overlay.show_observed is True
+ assert overlay.show_simulated is True
+ overlay_text = f"{overlay.question} {overlay.look_for} {overlay.more_detail}".lower()
+ assert "not the final test" in overlay_text
+ assert "statistical curve" in overlay_text
+ assert all(not step.show_technical_details for step in (observed, model, overlay))
+
+
+def test_tutorial_hard_core_lessons_have_physical_radius_guardrails():
+ meaning = next(ex for ex in _TUTORIALS if ex.key == "hard_core_meaning").steps[0]
+ parameters = next(ex for ex in _TUTORIALS if ex.key == "hard_core_parameters").steps[0]
+ statistic = next(ex for ex in _TUTORIALS if ex.key == "hard_core_statistic").steps[0]
+
+ assert meaning.model == "hard_core_random"
+ assert meaning.show_observed is False
+ assert meaning.show_simulated is True
+ assert "model_hard_core_radius" in meaning.visible_controls
+ meaning_text = f"{meaning.question} {meaning.look_for}".lower()
+ assert "minimum allowed separation" in meaning_text
+ assert "overlap" in meaning_text
+ assert parameters.model_hard_core_radius_nm
+ assert meaning.model_hard_core_radius_nm
+ assert parameters.model_hard_core_radius_nm > meaning.model_hard_core_radius_nm
+ assert "slow" in parameters.caution.lower()
+ assert statistic.focus_statistic == "nearest_neighbor_distribution"
+
+
+def test_tutorial_ordered_islands_teaches_local_order_sequence():
+ ordered = [
+ next(ex for ex in _TUTORIALS if ex.key == key).steps[0]
+ for key in (
+ "ordered_cluster_vs_order",
+ "ordered_radial_spacing",
+ "ordered_directional_pairs",
+ "ordered_bond_order",
+ "ordered_square_order",
+ "ordered_mixed",
+ )
+ ]
+
+ assert all(step.pattern == "ordered_islands" for step in ordered)
+ assert ordered[0].visible_controls == ("ordered_lattice",)
+ assert ordered[-1].visible_controls == ("ordered_background",)
+ text = " ".join(
+ f"{step.question} {step.look_for} {step.caution}"
+ for step in ordered
+ ).lower()
+ assert "clustering" in text
+ assert "direction" in text
+ assert "ψ6" in text
+ assert "ψ4" in text
+ assert "square" in text
+ assert "does not prove" in text
+ assert ordered[1].focus_statistic == "pair_correlation_g_r"
+ assert ordered[2].focus_statistic == "pair_correlation_g_r_theta"
+ assert ordered[3].focus_statistic == "bond_order_psi6"
+ assert ordered[4].focus_statistic == "bond_order_psi4"
+
+
+def test_tutorial_pooling_uses_one_then_two_images():
+ single = next(ex for ex in _TUTORIALS if ex.key == "pooling_single").steps[0]
+ pooled = next(ex for ex in _TUTORIALS if ex.key == "pooling_two").steps[0]
+
+ assert single.pool_images == 0
+ assert pooled.pool_images == 2
+ assert single.direct_labels == ("single image",)
+ assert pooled.direct_labels == ("pooled: 2 images",)
+ assert "independent" in pooled.question.lower()
+ assert "same experimental condition" in pooled.caution.lower()
+ assert "blue" in pooled.look_for.lower()
+ assert "orange" in pooled.look_for.lower()
+ assert "not a model envelope" in pooled.more_detail.lower()
+
+
+def test_particle_statistics_landing_page_shows_three_workflows(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="landing")
+
+ assert dlg.current_mode == "landing"
+ assert not dlg._landing_panel.isHidden()
+ assert dlg._workspace_panel.isHidden()
+ assert dlg._mode_cb.isHidden()
+ assert dlg._run_btn.isHidden()
+ assert dlg._start_tutorial_btn.isHidden()
+
+ cards = dlg._landing_panel.findChildren(QPushButton)
+ labels = {button.text() for button in cards}
+ assert labels == {"Choose point source", "Open simulations", "Start tutorial"}
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_landing_cards_enter_workflows(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="landing")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.findChild(QPushButton, "particleStatisticsLandingAnalyzeButton").click()
+ assert dlg.current_mode == "real"
+ assert dlg._landing_panel.isHidden()
+ assert not dlg._workspace_panel.isHidden()
+ assert not dlg._real_data_group.isHidden()
+
+ dlg.set_current_mode("landing")
+ dlg.findChild(QPushButton, "particleStatisticsLandingSimulationsButton").click()
+ assert dlg.current_mode == "model_simulations"
+ assert not dlg._generated_data_group.isHidden()
+ assert dlg._generated_data_group.title() == "Generated pattern"
+
+ dlg.set_current_mode("landing")
+ dlg.findChild(QPushButton, "particleStatisticsLandingTutorialButton").click()
+ assert dlg.current_mode == "learn"
+ assert not dlg._tutorial_panel.isHidden()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_can_return_to_landing_page(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="sandbox")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+ raises = []
+ dlg._raise_self = lambda: raises.append(dlg.current_mode)
+
+ assert dlg.current_mode == "model_simulations"
+ assert not dlg._landing_btn.isHidden()
+
+ dlg._landing_btn.click()
+ assert dlg.current_mode == "landing"
+ assert not dlg._landing_panel.isHidden()
+ assert dlg._workspace_panel.isHidden()
+ assert raises[-1] == "landing"
+
+ dlg.findChild(QPushButton, "particleStatisticsLandingTutorialButton").click()
+ assert dlg.current_mode == "learn"
+ raises.clear()
+ dlg._show_workflows_action.trigger()
+ assert dlg.current_mode == "landing"
+ assert raises[-1] == "landing"
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_window_layout_actions_use_config(qapp, monkeypatch):
+ import probeflow.gui.dialogs.particle_statistics as particle_module
+
+ saved = {}
+
+ monkeypatch.setattr(particle_module, "load_config", lambda: {"layout": {}})
+ monkeypatch.setattr(particle_module, "save_config", lambda cfg: saved.update(cfg))
+
+ dlg = ParticleStatisticsDialog(initial_mode="landing")
+
+ assert dlg.minimumWidth() >= 1300
+ assert dlg.minimumHeight() >= 850
+
+ dlg._use_wide_layout_action.trigger()
+ dlg._reset_window_size_action.trigger()
+ assert "layout" in saved
+ assert "particle_statistics" not in saved["layout"]
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_raise_does_not_touch_owner_window(qapp):
+ raised: list[str] = []
+
+ class _Owner:
+ def window(self):
+ return self
+
+ def isVisible(self):
+ return True
+
+ def raise_(self):
+ raised.append("owner")
+
+ dlg = ParticleStatisticsDialog(initial_mode="real")
+ dlg.raise_ = lambda: raised.append("self") # type: ignore[method-assign]
+ dlg.activateWindow = lambda: None # type: ignore[method-assign]
+ dlg.isVisible = lambda: True # type: ignore[method-assign]
+ dlg.parent = lambda: _Owner() # type: ignore[method-assign]
+
+ dlg._raise_now()
+
+ assert raised == ["self"] # never the owner window
+
+ dlg.isVisible = lambda: False # type: ignore[method-assign]
+ dlg.close()
+ dlg.deleteLater()
+
+
+@pytest.fixture
+def qapp():
+ app = QApplication.instance() or QApplication([])
+ yield app
+
+
+def test_particle_statistics_tutorial_controls_resolve_and_have_metadata(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ registry = dlg._tutorial_control_widgets()
+ used = {
+ key
+ for tutorial in _TUTORIALS
+ for step in tutorial.steps
+ for key in (step.visible_controls or step.controls)
+ }
+
+ assert used
+ assert {key for key in used if key not in registry or not registry[key]} == set()
+
+ for tutorial in _TUTORIALS:
+ for step in tutorial.steps:
+ label = f"{tutorial.key}/{step.title}"
+ assert step.question, label
+ assert step.look_for, label
+ if step.visible_panel == "results":
+ assert step.statistic_label or step.focus_statistic == "model_summary", label
+ if step.visible_controls:
+ assert step.primary_action, label
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_tutorial_highlights_controls_and_clears(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("feature_biased")
+
+ assert "#2fb344" in dlg._feature_layer_cb.styleSheet()
+ assert "#2fb344" in dlg._next_tutorial_btn.styleSheet()
+
+ dlg.next_tutorial_step()
+
+ assert "#e0b020" in dlg._feature_layer_cb.styleSheet()
+ assert "#2fb344" in dlg._observed_layer_cb.styleSheet()
+
+ dlg.exit_tutorial()
+
+ assert dlg._feature_layer_cb.styleSheet() == ""
+ assert dlg._observed_layer_cb.styleSheet() == ""
+ assert dlg._tutorial_panel.isHidden()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_hard_core_tutorial_highlights_radius(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("hard_core_meaning")
+
+ assert dlg._generated_model_cb.currentData() == "hard_core_random"
+ assert dlg._field.layer_visibility["observed"] is False
+ assert dlg._field.layer_visibility["simulated"] is True
+ assert dlg._hard_core_radius_spin.value() == pytest.approx(1.5)
+ assert dlg._model_hard_core_radius_spin.value() == pytest.approx(1.5)
+ assert "#2fb344" in dlg._model_hard_core_radius_spin.styleSheet()
+ assert "minimum allowed separation" in dlg._tutorial_step_lbl.text().lower()
+
+ dlg.next_tutorial_step()
+
+ assert dlg.current_tutorial_key == "hard_core_parameters"
+ assert dlg._hard_core_radius_spin.value() == pytest.approx(3.0)
+ assert dlg._model_hard_core_radius_spin.value() == pytest.approx(3.0)
+ assert "slow" in dlg._tutorial_careful_lbl.text().lower()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_tutorial_real_steps_keep_drawer_visible(qapp):
+ dlg = ParticleStatisticsDialog(
+ point_sources=[_point_source()],
+ scan=SimpleNamespace(scan_range_m=(8e-9, 6e-9), dims=(8, 6)),
+ image_shape=(6, 8),
+ initial_mode="learn",
+ )
+
+ dlg.load_tutorial_example("real_workflow")
+
+ assert dlg.current_mode == "learn"
+ assert dlg._mode_cb.currentData() == "real"
+ assert not dlg._tutorial_panel.isHidden()
+ assert not dlg._feature_sets_group.isHidden()
+ assert dlg._generated_data_group.isHidden()
+ assert "#2fb344" in dlg._feature_sets_list.styleSheet()
+
+ dlg.next_tutorial_step()
+
+ assert dlg.current_tutorial_key == "final_caution"
+ assert dlg._mode_cb.currentData() == "real"
+ assert not dlg._tutorial_panel.isHidden()
+ assert dlg._result_view.technical_details_visible is False
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_real_workflow_keeps_generated_examples_separate(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("real_workflow")
+
+ assert dlg.current_mode == "learn"
+ assert dlg._mode_cb.currentData() == "real"
+ assert not dlg._tutorial_panel.isHidden()
+ assert dlg._generated_banner.isHidden()
+ assert "#2fb344" in dlg._feature_sets_list.styleSheet()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_setup_columns_restore_after_tutorial_exit(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("hard_core_meaning")
+
+ assert not dlg._setup_model_column.isHidden()
+ assert dlg._setup_data_column.isHidden()
+ assert dlg._setup_statistic_column.isHidden()
+
+ dlg.exit_tutorial()
+
+ assert [dlg._tabs.tabText(i) for i in range(dlg._tabs.count())] == [
+ "Setup",
+ "Results",
+ ]
+ assert not dlg._tabs.tabBar().isHidden()
+ assert not dlg._setup_data_column.isHidden()
+ assert not dlg._setup_model_column.isHidden()
+ assert not dlg._setup_statistic_column.isHidden()
+ assert not dlg._real_data_group.isHidden()
+ assert not dlg._real_model_group.isHidden()
+ assert dlg._generated_data_group.isHidden()
+ assert dlg._generated_model_group.isHidden()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_top_menus_sync_with_controls(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="sandbox")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ menu_bar = dlg.layout().menuBar()
+ assert [action.text() for action in menu_bar.actions()] == [
+ "Workflow",
+ "Data",
+ "Model",
+ "Statistic",
+ "Export",
+ "View",
+ "Definitions",
+ ]
+
+ assert dlg._show_observed_action.isChecked()
+ dlg._show_observed_action.trigger()
+ assert not dlg._observed_layer_cb.isChecked()
+ assert not dlg._show_observed_action.isChecked()
+
+ assert dlg._show_model_action.isChecked()
+ dlg._show_model_action.trigger()
+ assert not dlg._simulation_layer_cb.isChecked()
+ assert not dlg._show_model_action.isChecked()
+
+ dlg._model_actions["hard_core_random"].trigger()
+ assert dlg._generated_model_cb.currentData() == "hard_core_random"
+ assert dlg._model_actions["hard_core_random"].isChecked()
+
+ assert dlg._link_hard_core_radii_action.isChecked()
+ dlg._link_hard_core_radii_action.trigger()
+ assert not dlg._link_hard_core_radii_cb.isChecked()
+ assert not dlg._link_hard_core_radii_action.isChecked()
+
+ dlg._statistic_actions["nearest_neighbor_distribution"].trigger()
+ assert dlg.focused_statistic == "nearest_neighbor_distribution"
+ assert dlg._statistic_actions["nearest_neighbor_distribution"].isChecked()
+
+ dlg._show_definitions_action.trigger()
+ # The Definitions menu opens the shared ProbeFlow Definitions document at the
+ # Particle Statistics tab (not an isolated in-dialog tab).
+ assert dlg._definitions_dialog.current_reference_tab() == "particle_statistics"
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_model_simulations_mode_is_available(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="sandbox")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ assert dlg.current_mode == "model_simulations"
+ assert dlg._mode_cb.currentData() == "sandbox"
+ assert [dlg._tabs.tabText(i) for i in range(dlg._tabs.count())] == [
+ "Setup",
+ "Results",
+ ]
+ assert not dlg._setup_data_column.isHidden()
+ assert not dlg._setup_model_column.isHidden()
+ assert not dlg._setup_statistic_column.isHidden()
+ assert not dlg._generated_data_group.isHidden()
+ assert dlg._generated_data_group.title() == "Generated pattern"
+ assert not dlg._generated_model_group.isHidden()
+ assert dlg._generated_model_group.title() == "Comparison model"
+ assert not dlg._hard_core_radius_spin.isHidden()
+ assert not dlg._model_hard_core_radius_spin.isHidden()
+ assert dlg._generated_data_group.layout().rowCount() <= 8
+ reference_text = " ".join(
+ label.text() for label in dlg._model_reference_cards.findChildren(QLabel)
+ )
+ assert "Generated pattern" in reference_text
+ assert "Comparison model" in reference_text
+ assert "Measured-feature Poisson" not in reference_text
+ assert len(_plain_words(reference_text)) <= 32
+ assert dlg._tutorial_panel.isHidden()
+ assert dlg._generated_banner.text() == "Model simulations"
+
+ _set_index = dlg._pattern_cb.findData("no_overlap")
+ dlg._pattern_cb.setCurrentIndex(_set_index)
+ dlg._generated_model_cb.setCurrentIndex(
+ dlg._generated_model_cb.findData("hard_core_random")
+ )
+ dlg._n_spin.setValue(100)
+ dlg._seed_spin.setValue(12)
+ assert dlg._link_hard_core_radii_cb.isChecked()
+ dlg._hard_core_radius_spin.setValue(3.0)
+ assert dlg._model_hard_core_radius_spin.value() == pytest.approx(3.0)
+ dlg._link_hard_core_radii_cb.setChecked(False)
+ assert not dlg._link_hard_core_radii_cb.isChecked()
+ dlg._model_hard_core_radius_spin.setValue(2.0)
+
+ assert dlg._sandbox_state.config.pattern == "no_overlap"
+ assert dlg._sandbox_state.active_model == "hard_core_random"
+ assert dlg._sandbox_state.config.seed == 12
+ assert dlg._sandbox_state.config.hard_core_radius_nm == pytest.approx(3.0)
+ assert dlg._sandbox_state.config.model_hard_core_radius_nm == pytest.approx(2.0)
+ assert dlg.field_point_count == 100
+
+ first_points = dlg._field._model.observed_xy_nm.copy()
+ dlg._model_hard_core_radius_spin.setValue(4.0)
+ assert dlg._sandbox_state.config.model_hard_core_radius_nm == pytest.approx(4.0)
+ np.testing.assert_allclose(first_points, dlg._field._model.observed_xy_nm)
+
+ dlg._hard_core_radius_spin.setValue(4.0)
+ assert dlg._sandbox_state.config.hard_core_radius_nm == pytest.approx(4.0)
+ assert not np.array_equal(first_points, dlg._field._model.observed_xy_nm)
+
+ dlg._n_spin.setValue(200)
+ dlg._hard_core_radius_spin.setValue(5.0)
+ assert "slow" in dlg._sandbox_warning_lbl.text().lower()
+
+ dlg._statistic_buttons["nearest_neighbor_distribution"].click()
+ assert dlg.focused_statistic == "nearest_neighbor_distribution"
+
+ dlg._pattern_cb.setCurrentIndex(dlg._pattern_cb.findData("ordered_islands"))
+ assert not dlg._ordered_lattice_cb.isHidden()
+ assert not dlg._ordered_background_cb.isHidden()
+ dlg._ordered_lattice_cb.setCurrentIndex(dlg._ordered_lattice_cb.findData("square"))
+ dlg._ordered_background_cb.setCurrentIndex(
+ dlg._ordered_background_cb.findData("clustered")
+ )
+ assert dlg._sandbox_state.config.ordered_island_lattice == "square"
+ assert dlg._sandbox_state.config.ordered_island_background == "clustered"
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_generated_hard_core_radius_link(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="sandbox")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg._generated_model_cb.setCurrentIndex(
+ dlg._generated_model_cb.findData("hard_core_random")
+ )
+
+ assert dlg._link_hard_core_radii_cb.isChecked()
+ assert not dlg._model_hard_core_radius_spin.isEnabled()
+
+ dlg._hard_core_radius_spin.setValue(3.5)
+ assert dlg._model_hard_core_radius_spin.value() == pytest.approx(3.5)
+ assert dlg._sandbox_state.config.model_hard_core_radius_nm == pytest.approx(3.5)
+
+ dlg._link_hard_core_radii_cb.setChecked(False)
+ assert dlg._model_hard_core_radius_spin.isEnabled()
+ dlg._model_hard_core_radius_spin.setValue(2.0)
+ dlg._hard_core_radius_spin.setValue(4.0)
+ assert dlg._sandbox_state.config.hard_core_radius_nm == pytest.approx(4.0)
+ assert dlg._sandbox_state.config.model_hard_core_radius_nm == pytest.approx(2.0)
+
+ dlg._link_hard_core_radii_cb.setChecked(True)
+ assert not dlg._model_hard_core_radius_spin.isEnabled()
+ assert dlg._model_hard_core_radius_spin.value() == pytest.approx(4.0)
+ assert dlg._sandbox_state.config.model_hard_core_radius_nm == pytest.approx(4.0)
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def _point_source(label: str = "Feature maxima"):
+ points_px = np.array([[1.0, 2.0], [4.0, 5.0], [8.0, 3.0]], dtype=float)
+ return SimpleNamespace(
+ label=label,
+ source_type="feature_maxima",
+ points_px=points_px,
+ points_m=points_px * 1e-9,
+ metadata={"selection_scope": "full_image"},
+ )
+
+
+def _curve_panel():
+ return SimpleNamespace(
+ statistic="pair_correlation_g_r",
+ title="pair correlation",
+ kind="curve",
+ x_label="r (nm)",
+ y_label="g(r)",
+ reference_line=1.0,
+ x=np.array([0.5, 1.5, 2.5]),
+ observed=np.array([0.0, 1.2, 0.8]),
+ band_low=np.array([0.4, 0.5, 0.6]),
+ band_high=np.array([1.6, 1.5, 1.4]),
+ central=np.array([1.0, 1.0, 1.0]),
+ coordinate_values={"r_nm": np.array([0.5, 1.5, 2.5])},
+ caption_lines=(),
+ metadata={"n_simulations": 4},
+ )
+
+
+def test_particle_statistics_opens_real_mode_with_field_and_request(qapp):
+ active_roi = object()
+ active_mask = np.ones((6, 8), dtype=bool)
+ scan = SimpleNamespace(scan_range_m=(8e-9, 6e-9), dims=(8, 6))
+ dlg = ParticleStatisticsDialog(
+ point_sources=[_point_source()],
+ scan=scan,
+ active_area_roi=active_roi,
+ active_mask=active_mask,
+ image_shape=(6, 8),
+ theme={"text.color": "#111111"},
+ initial_mode="real",
+ )
+
+ request = dlg._request_from_controls()
+
+ assert dlg.windowTitle() == "Particle Statistics"
+ assert dlg.current_mode == "real"
+ assert dlg.field_point_count == 3
+ assert dlg._field.data_mode == "real"
+ assert dlg._field.marker_style["marker"] == "o"
+ assert request.point_source_label == "Feature maxima"
+ assert request.region_mode == "roi"
+ assert request.roi_or_mask is active_roi
+ assert request.models == ("poisson",)
+ assert request.n_simulations == 100
+ assert request.random_seed == 0
+ assert dlg._result_view.banner_text == ""
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_real_mode_exposes_models_and_sim_controls(qapp):
+ scan = SimpleNamespace(scan_range_m=(8e-9, 6e-9), dims=(8, 6))
+ dlg = ParticleStatisticsDialog(
+ point_sources=[_point_source()],
+ scan=scan,
+ image_shape=(6, 8),
+ initial_mode="real",
+ )
+
+ models = {dlg._real_model_cb.itemData(i) for i in range(dlg._real_model_cb.count())}
+ assert {"poisson", "hard_core_random"} <= models
+
+ dlg._real_sim_spin.setValue(40)
+ dlg._real_seed_spin.setValue(7)
+ index = dlg._real_model_cb.findData("hard_core_random")
+ dlg._real_model_cb.setCurrentIndex(index)
+
+ request = dlg._request_from_controls()
+ assert request.models == ("hard_core_random",)
+ assert request.n_simulations == 40
+ assert request.random_seed == 7
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def _feature_set(name, n, seed):
+ from probeflow.measurements.feature_sets import FeatureSet
+
+ rng = np.random.default_rng(seed)
+ xy_nm = rng.uniform(0.0, 100.0, size=(n, 2))
+ return FeatureSet.from_points(
+ name=name,
+ points_px=xy_nm,
+ points_m=xy_nm * 1e-9,
+ scan_range_m=(100e-9, 100e-9),
+ image_shape=(256, 256),
+ image_label=name,
+ )
+
+
+def test_particle_statistics_lists_and_selects_feature_sets(qapp):
+ sets = [_feature_set("A", 120, 1), _feature_set("B", 110, 2)]
+ dlg = ParticleStatisticsDialog(
+ point_sources=[_point_source()],
+ scan=SimpleNamespace(scan_range_m=(8e-9, 6e-9), dims=(8, 6)),
+ image_shape=(6, 8),
+ feature_sets=sets,
+ initial_mode="real",
+ )
+
+ assert dlg._feature_sets_list.count() == 2
+ dlg.select_feature_set(sets[1].set_id)
+ selected = dlg._selected_feature_sets()
+ assert [fs.name for fs in selected] == ["B"]
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_runs_combined_feature_sets(qapp):
+ if not pytest.importorskip("adstat"):
+ pytest.skip("AdStat not installed")
+ sets = [_feature_set("A", 120, 1), _feature_set("B", 110, 2)]
+ dlg = ParticleStatisticsDialog(feature_sets=sets, initial_mode="real")
+
+ # Drive the worker synchronously rather than via the thread pool.
+ from probeflow.gui.dialogs.particle_statistics import _ParticleFeatureSetWorker
+ from probeflow.gui.viewer.tool_launch import AdStatStatisticsRequest
+
+ for index in range(dlg._feature_sets_list.count()):
+ dlg._feature_sets_list.item(index).setCheckState(__import__("PySide6").QtCore.Qt.Checked)
+ request = AdStatStatisticsRequest(
+ point_source_label="Combined",
+ region_mode="full",
+ models=("poisson",),
+ n_simulations=6,
+ random_seed=0,
+ )
+ worker = _ParticleFeatureSetWorker(
+ generation=dlg._generation, feature_sets=dlg._selected_feature_sets(), request=request
+ )
+ captured = {}
+ worker.signals.finished.connect(
+ lambda gen, spec, err: captured.update(spec=spec, err=err)
+ )
+ worker.work()
+
+ assert captured["err"] == ""
+ dlg._on_feature_sets_worker_finished(dlg._generation, captured["spec"], "")
+ assert "Combined" in dlg._status_lbl.text()
+ assert dlg._result_view.tab_count >= 1
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_pooling_b_adds_tutorial_reference_curve(qapp):
+ pytest.importorskip("adstat")
+ from probeflow.analysis.adstat_adapter import (
+ compare_point_set_record_view_spec,
+ compare_point_set_records_view_spec,
+ )
+
+ sets = [_feature_set("A", 120, 11), _feature_set("B", 120, 12)]
+ single = compare_point_set_record_view_spec(
+ sets[0].to_point_set_record(),
+ n_simulations=4,
+ random_seed=0,
+ )
+ reference = _single_curve_reference_from_spec(single, "pair_correlation_g_r")
+ pooled = compare_point_set_records_view_spec(
+ [feature_set.to_point_set_record() for feature_set in sets],
+ n_simulations=4,
+ random_seed=0,
+ )
+ normal_panel = _panel_for_statistic(pooled, "pair_correlation_g_r")
+
+ assert reference is not None
+ assert normal_panel is not None
+ assert not (normal_panel.metadata or {}).get("reference_curves")
+
+ tutorial_spec = _with_series_reference_curve(
+ pooled,
+ "pair_correlation_g_r",
+ reference,
+ )
+ tutorial_panel = _panel_for_statistic(tutorial_spec, "pair_correlation_g_r")
+
+ assert tutorial_panel is not None
+ assert tutorial_panel.metadata["reference_curves"][0]["label"] == (
+ "single image reference"
+ )
+ assert "single-image reference" in _series_focus_read_text(tutorial_panel)
+ assert "model simulations" not in _series_focus_read_text(tutorial_panel)
+
+
+def test_particle_statistics_exposes_measured_feature_model(qapp):
+ sets = [_feature_set("particles", 120, 1), _feature_set("edges", 15, 2)]
+ dlg = ParticleStatisticsDialog(feature_sets=sets, initial_mode="real")
+
+ models = {dlg._real_model_cb.itemData(i) for i in range(dlg._real_model_cb.count())}
+ assert "measured_feature_poisson" in models
+
+ # The feature-layer picker is enabled only for the measured-feature model.
+ index = dlg._real_model_cb.findData("measured_feature_poisson")
+ dlg._real_model_cb.setCurrentIndex(index)
+ assert dlg._feature_layer_set_cb.isEnabled()
+ dlg._real_model_cb.setCurrentIndex(dlg._real_model_cb.findData("poisson"))
+ assert not dlg._feature_layer_set_cb.isEnabled()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_measured_feature_validation_and_run(qapp):
+ if not pytest.importorskip("adstat"):
+ pytest.skip("AdStat not installed")
+ from probeflow.gui.dialogs.particle_statistics import _ParticleFeatureSetWorker
+ from probeflow.gui.viewer.tool_launch import AdStatStatisticsRequest
+
+ particles = _feature_set("particles", 120, 1)
+ edges = _feature_set("edges", 18, 2)
+ dlg = ParticleStatisticsDialog(feature_sets=[particles, edges], initial_mode="real")
+ dlg._real_model_cb.setCurrentIndex(dlg._real_model_cb.findData("measured_feature_poisson"))
+
+ # No feature layer chosen yet → guarded with a helpful message, no crash.
+ dlg.select_feature_set(particles.set_id)
+ dlg.run_selected_feature_sets()
+ assert "Feature layer" in dlg._status_lbl.text()
+
+ # Choose the edges set as the feature layer, then drive the worker synchronously.
+ dlg._feature_layer_set_cb.setCurrentIndex(dlg._feature_layer_set_cb.findData(edges.set_id))
+ assert dlg._selected_feature_layer().set_id == edges.set_id
+ request = AdStatStatisticsRequest(
+ point_source_label="particles vs edges",
+ region_mode="full",
+ models=("measured_feature_poisson",),
+ n_simulations=6,
+ random_seed=0,
+ )
+ worker = _ParticleFeatureSetWorker(
+ generation=dlg._generation,
+ feature_sets=[particles],
+ request=request,
+ feature_layer=edges,
+ )
+ captured = {}
+ worker.signals.finished.connect(lambda gen, spec, err: captured.update(spec=spec, err=err))
+ worker.work()
+ assert captured["err"] == ""
+ assert captured["spec"].metadata.get("active_model") == "measured_feature_poisson"
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_can_deep_link_to_generated_mode(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+
+ assert dlg.current_mode == "learn"
+ assert dlg._field.data_mode == "generated"
+ assert dlg._field.marker_style["marker"] != "o"
+ assert dlg._result_view.banner_text == ""
+ assert dlg.current_tutorial_key == "welcome"
+ if dlg._sandbox_state is not None:
+ dlg.load_tutorial_example("point_pattern")
+ assert dlg.field_point_count > 0
+ assert dlg._field.direct_labels == ("observed particles",)
+ assert dlg._generated_banner.text() == "Tutorial: generated example"
+ else:
+ assert "AdStat" in dlg._status_lbl.text()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_real_mode_surfaces_failed_context(qapp):
+ dlg = ParticleStatisticsDialog(
+ point_sources=[_point_source()],
+ scan=SimpleNamespace(scan_range_m=(8e-9, 6e-9), dims=(8, 6)),
+ image_shape=(6, 8),
+ )
+
+ dlg._generation = 3
+ dlg._on_real_worker_finished(
+ 3,
+ SimpleNamespace(
+ ready=False,
+ status_message="ProbeFlow's AdStat adapter requires the optional 'adstat' package.",
+ view_spec=None,
+ point_source_label="Feature maxima",
+ ),
+ )
+
+ assert "optional 'adstat' package" in dlg._status_lbl.text()
+ assert dlg._result_view.data_mode == "real"
+ assert dlg._result_view.banner_text == ""
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_field_view_keeps_real_and_generated_styles_distinct(qapp):
+ view = ParticleFieldView()
+ view.set_points([[1.0, 1.0]], field_size_nm=(10.0, 10.0), mode="real")
+ assert view.data_mode == "real"
+ assert view.marker_style["marker"] == "o"
+ assert view.marker_style["color"] == "#2f7ed8"
+
+ view.set_points([[1.0, 1.0]], field_size_nm=(10.0, 10.0), mode="generated")
+ assert view.data_mode == "generated"
+ assert view.marker_style["marker"] == "^"
+ assert view.marker_style["color"] == "#f28e2b"
+
+ view.close()
+ view.deleteLater()
+
+
+def test_particle_field_view_preserves_physical_aspect_ratio():
+ square = _aspect_fit_rect(QRectF(0, 0, 800, 300), 100.0, 100.0)
+ wide = _aspect_fit_rect(QRectF(0, 0, 800, 300), 200.0, 100.0)
+
+ assert square.width() == pytest.approx(square.height())
+ assert wide.width() / wide.height() == pytest.approx(2.0)
+ assert wide.width() <= 800
+ assert wide.height() <= 300
+
+
+def test_focused_statistic_panel_renders_concept_and_plot(qapp):
+ panel = FocusedStatisticPanel()
+
+ panel.set_statistic("pair_correlation_g_r")
+ assert panel.focused_statistic == "pair_correlation_g_r"
+ assert panel.has_plot is False
+ assert "Pair correlation" in panel._title.text()
+ assert "Question:" not in panel._body.text()
+
+ panel.set_statistic("pair_correlation_g_r", panel=_curve_panel())
+
+ assert panel.has_plot is True
+ assert panel.findChildren(AdStatPlotWidget)[0].panel_kind == "curve"
+ assert "How to read this plot" not in panel._body.text()
+
+ panel.set_statistic(
+ "pair_correlation_g_r",
+ panel=_curve_panel(),
+ curve_mode="observed_only",
+ )
+
+ assert panel.findChildren(AdStatPlotWidget)[0].curve_mode == "observed_only"
+ assert "model appears in the next step" in panel._body.text()
+
+ directional = _curve_panel()
+ directional.statistic = "pair_correlation_g_r_theta"
+ panel.set_statistic("pair_correlation_g_r_theta", panel=directional)
+ assert "distance-angle" in panel._title.text()
+ assert "θ = pair direction" in panel._annotation.text()
+
+ psi6 = _curve_panel()
+ psi6.statistic = "bond_order_psi6"
+ panel.set_statistic("bond_order_psi6", panel=psi6)
+ assert "triangular-like" in panel._title.text()
+ assert "0 = random-like" in panel._annotation.text()
+
+ panel.close()
+ panel.deleteLater()
+
+
+def test_particle_statistics_layer_toggles_are_display_only(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("point_pattern")
+ original_count = dlg.field_point_count
+ dlg._observed_layer_cb.setChecked(False)
+
+ assert dlg.field_point_count == original_count
+ assert dlg._field.layer_visibility["observed"] is False
+ assert "supported plots" in dlg._layer_hint_lbl.text()
+ assert "still used for comparison" in dlg._layer_hint_lbl.text()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_layer_toggles_update_focus_plot_curves(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="sandbox")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg._sandbox_state.stage(n=20, n_simulations=2)
+ state = dlg._sandbox_state
+ state.run()
+ dlg._on_generated_worker_finished(dlg._sandbox_generation, state, "")
+
+ plot = dlg._focus_panel.findChildren(AdStatPlotWidget)[0]
+ assert plot.show_observed_curve is True
+ assert plot.show_model_curves is True
+ assert dlg._result_view.tab_count >= 1
+
+ dlg._observed_layer_cb.setChecked(False)
+ plot = dlg._focus_panel.findChildren(AdStatPlotWidget)[0]
+ assert plot.show_observed_curve is False
+ assert plot.show_model_curves is True
+ assert dlg._result_view.tab_count >= 1
+ assert "supported plots" in dlg._layer_hint_lbl.text()
+
+ dlg._simulation_layer_cb.setChecked(False)
+ plot = dlg._focus_panel.findChildren(AdStatPlotWidget)[0]
+ assert plot.show_observed_curve is False
+ assert plot.show_model_curves is False
+
+ dlg._observed_layer_cb.setChecked(True)
+ plot = dlg._focus_panel.findChildren(AdStatPlotWidget)[0]
+ assert plot.show_observed_curve is True
+ assert plot.show_model_curves is False
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_tutorial_loads_examples_and_layer_steps(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("feature_biased")
+
+ assert dlg.current_tutorial_key == "feature_biased"
+ assert dlg._pattern_cb.currentData() == "feature_biased"
+ assert dlg._generated_model_cb.currentData() == "measured_feature_poisson"
+ assert dlg._field.layer_visibility["observed"] is False
+ assert dlg._field.layer_visibility["features"] is True
+
+ dlg.next_tutorial_step()
+
+ assert dlg.current_tutorial_step == 1
+ assert dlg._field.layer_visibility["observed"] is True
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_tutorial_marks_next_action_green(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("image_to_statistic")
+
+ assert "#2fb344" in dlg._next_tutorial_btn.styleSheet()
+ assert dlg._run_tutorial_btn.styleSheet() == ""
+ assert dlg._field.layer_visibility["observed"] is True
+ assert dlg._field.layer_visibility["simulated"] is True
+ # Navigation names its destination and points forward (not the call-to-action).
+ assert dlg._next_tutorial_btn.text().strip().endswith("▸")
+ assert dlg._next_tutorial_btn.isEnabled()
+ assert "Look for:" in dlg._tutorial_step_lbl.text()
+ assert not dlg._tutorial_why_frame.isVisibleTo(dlg)
+
+ dlg.next_tutorial_step()
+
+ assert dlg.current_tutorial_key == "simulation_envelope"
+ assert "#2fb344" in dlg._next_tutorial_btn.styleSheet()
+ assert dlg._next_tutorial_btn.text().strip().endswith("▸")
+ assert dlg._field.layer_visibility["observed"] is True
+ assert dlg._field.layer_visibility["simulated"] is True
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_first_example_is_staged_field_lesson(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ assert dlg.current_tutorial_key == "welcome"
+ assert dlg._current_tutorial_step_obj().visible_panel == "field"
+ assert dlg.focus_has_plot is False
+ assert dlg._tabs.isHidden()
+ assert dlg._focus_panel.isHidden()
+ assert dlg._result_view.technical_details_visible is False
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_tutorial_reveals_single_panel_at_a_time(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("image_to_statistic")
+ assert dlg._tabs.isHidden()
+ assert not dlg._focus_panel.isHidden()
+ assert dlg._generated_data_group.isHidden()
+ assert dlg._generated_model_group.isHidden()
+ assert dlg._result_view.technical_details_visible is False
+
+ dlg.load_tutorial_example("verdict")
+ assert not dlg._tabs.isHidden()
+ assert dlg._tabs.tabText(dlg._tabs.currentIndex()) == "Results"
+ assert dlg._tabs.tabBar().isHidden()
+ assert not dlg._focus_panel.isHidden()
+ assert dlg._result_view.technical_details_visible is False
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_results_tab_has_no_duplicate_plots(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ # Run a comparison so verdict rows exist, then check the embedded Results view
+ # only exposes the verdict summary + technical details (no per-statistic plots).
+ dlg.load_tutorial_example("simulation_envelope")
+ dlg._sandbox_state.stage(n=40, n_simulations=4)
+ state = dlg._sandbox_state
+ state.run()
+ dlg._on_generated_worker_finished(dlg._sandbox_generation, state, "")
+
+ titles = set(dlg._result_view.tab_titles)
+ assert titles <= {"Summary", "Technical details"}
+ assert "Summary" in titles
+ assert "Technical details" not in titles
+ assert not any("pair" in t.lower() or "pattern" in t.lower() for t in titles)
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_tutorial_is_available_from_workflow_menu(qapp):
+ dlg = ParticleStatisticsDialog(
+ point_sources=[_point_source()],
+ scan=SimpleNamespace(scan_range_m=(8e-9, 6e-9), dims=(8, 6)),
+ image_shape=(6, 8),
+ initial_mode="real",
+ )
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ assert dlg.current_mode == "real"
+ assert dlg._start_tutorial_btn.isHidden()
+ assert dlg._start_tutorial_action.isEnabled()
+
+ dlg._start_tutorial_action.trigger()
+
+ assert dlg.current_mode == "learn"
+ assert dlg.current_tutorial_key == "welcome"
+ assert dlg.current_tutorial_step == 0
+ assert not dlg._start_tutorial_action.isEnabled()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_exit_and_restart_tutorial(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("clustered")
+ assert dlg.current_mode == "learn"
+
+ dlg.exit_tutorial()
+ assert dlg.current_mode == "real"
+ assert not dlg._tabs.isHidden()
+ assert not dlg._tabs.tabBar().isHidden()
+ assert not dlg._info_panel.isHidden()
+ assert not dlg._layer_group.isHidden()
+ assert dlg._result_view.technical_details_visible is True
+
+ dlg.restart_tutorial()
+ assert dlg.current_mode == "learn"
+ assert dlg.current_tutorial_key == "welcome"
+ assert dlg.current_tutorial_step == 0
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_exit_clears_and_ignores_late_tutorial_result(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ # Produce a generated result, then exit to real mode.
+ dlg._sandbox_state.stage(n=20, n_simulations=2)
+ state = dlg._sandbox_state
+ state.run()
+ dlg._on_generated_worker_finished(dlg._sandbox_generation, state, "")
+ assert dlg.focus_has_plot is True
+
+ stale_generation = dlg._sandbox_generation
+ dlg.exit_tutorial()
+
+ assert dlg.current_mode == "real"
+ assert dlg._sandbox_generation != stale_generation # in-flight run invalidated
+ assert dlg._result_view.data_mode == "real"
+ assert dlg.focus_has_plot is False
+
+ # A late tutorial worker (old or current generation) must not repopulate real view.
+ dlg._on_generated_worker_finished(stale_generation, state, "")
+ dlg._on_generated_worker_finished(dlg._sandbox_generation, state, "")
+ assert dlg._result_view.data_mode == "real"
+ assert dlg.focus_has_plot is False
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_clear_real_view(qapp):
+ dlg = ParticleStatisticsDialog(
+ point_sources=[_point_source()],
+ scan=SimpleNamespace(scan_range_m=(8e-9, 6e-9), dims=(8, 6)),
+ image_shape=(6, 8),
+ initial_mode="real",
+ )
+
+ dlg.clear_real_view()
+ assert dlg.focus_has_plot is False
+ assert dlg._result_view.data_mode == "real"
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_statistic_buttons_are_compact_and_selectable(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ pair_button = dlg._statistic_buttons["pair_correlation_g_r"]
+ neighbor_button = dlg._statistic_buttons["nearest_neighbor_distribution"]
+ directional_button = dlg._statistic_buttons["pair_correlation_g_r_theta"]
+ psi6_button = dlg._statistic_buttons["bond_order_psi6"]
+
+ # Buttons carry only the statistic name (no multi-line description baked in).
+ assert pair_button.text() == "Pair correlation"
+ assert directional_button.text() == "Pair distance-angle map"
+ assert psi6_button.text() == "ψ6 triangular order"
+ assert "\n" not in pair_button.text()
+ assert pair_button.isCheckable()
+ assert any(
+ "General spatial pattern" in label.text()
+ for label in dlg._statistic_group_labels
+ )
+ assert any(
+ "Local order" in label.text()
+ for label in dlg._statistic_group_labels
+ )
+ # The default-focused statistic reads as selected.
+ assert pair_button.isChecked()
+ assert pair_button.styleSheet() != ""
+ assert neighbor_button.styleSheet() == ""
+ assert "Selected: Pair correlation" in dlg._selected_statistic_help_lbl.text()
+ assert all(label.isHidden() for label in dlg._statistic_description_labels.values())
+
+ # Local-order statistics are opt-in: their cards are disabled until enabled.
+ assert not directional_button.isEnabled()
+ dlg._include_ordering_cb.setChecked(True)
+ assert directional_button.isEnabled()
+ directional_button.click()
+ assert "Selected: Pair distance-angle map" in dlg._selected_statistic_help_lbl.text()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_tutorial_focuses_visible_statistic_card(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("image_to_statistic")
+ tutorial_text = dlg._tutorial_step_lbl.text()
+
+ assert dlg.focused_statistic == "pair_correlation_g_r"
+ assert dlg.focus_has_plot is False
+ assert not dlg._focus_panel.isHidden()
+ assert "Pair correlation" in dlg._focus_panel._title.text()
+ assert "Pair correlation" not in tutorial_text or "Pair correlation" in dlg._focus_panel._title.text()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_generated_run_keeps_tutorial_focus(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.next_tutorial_step()
+ dlg._statistic_buttons["nearest_neighbor_distribution"].click()
+ assert dlg.focused_statistic == "nearest_neighbor_distribution"
+
+ dlg._sandbox_state.stage(n=20, n_simulations=2)
+ state = dlg._sandbox_state
+ state.run()
+ dlg._on_generated_worker_finished(dlg._sandbox_generation, state, "")
+
+ assert dlg.focused_statistic == "pair_correlation_g_r"
+ assert "Pair correlation" in dlg._focus_panel._title.text()
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_tutorial_run_shows_actual_focus_plot(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("image_to_statistic")
+
+ # Simulate the comparison that fires when the plotting lesson is shown.
+ dlg._sandbox_state.stage(n=20, n_simulations=2)
+ state = dlg._sandbox_state
+ state.run()
+ dlg._on_generated_worker_finished(dlg._sandbox_generation, state, "")
+
+ # The statistic lesson shows a real plot (observed-only emphasis), not a concept card.
+ assert dlg.current_tutorial_step == 0
+ assert dlg.focused_statistic == "pair_correlation_g_r"
+ assert dlg.focus_has_plot is True
+ assert dlg._result_view.tab_count >= 1
+ assert dlg._focus_panel.findChildren(AdStatPlotWidget)[0].curve_mode == "observed_only"
+
+ dlg.next_tutorial_step()
+
+ # Stepping adds the model envelope to the same live chart; no recompute.
+ before_result = dlg._sandbox_state.result
+ assert dlg.current_tutorial_key == "simulation_envelope"
+ assert dlg.current_tutorial_step == 0
+ assert dlg._tabs.tabText(dlg._tabs.currentIndex()) == "Setup"
+ assert dlg._field.layer_visibility["simulated"] is True
+ assert dlg.focus_has_plot is True
+ assert dlg._focus_panel.findChildren(AdStatPlotWidget)[0].curve_mode == "comparison"
+
+ dlg._statistic_buttons["ripley_l_function"].click()
+
+ assert dlg.focused_statistic == "ripley_l_function"
+ assert dlg.focus_has_plot is True
+ assert dlg._sandbox_state.result is before_result
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_tutorial_green_action_queues_next_example(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("verdict")
+
+ assert dlg.current_tutorial_key == "verdict"
+ assert dlg.current_tutorial_step == 0
+ assert "#2fb344" in dlg._next_tutorial_btn.styleSheet()
+ assert dlg._next_tutorial_btn.text().strip().endswith("▸")
+
+ dlg.next_tutorial_step()
+
+ assert dlg.current_tutorial_key == "homogeneous_poisson"
+ assert dlg.current_tutorial_step == 0
+ assert dlg._pattern_cb.currentData() == "random"
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_feature_biased_tutorial_avoids_unimplemented_statistic(qapp):
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.load_tutorial_example("feature_biased")
+ seen = []
+ for _index in range(4):
+ seen.append(dlg._tutorial_step_lbl.text().lower())
+ dlg.next_tutorial_step()
+
+ assert "feature-distance" not in "\n".join(seen)
+ assert "feature distance" not in "\n".join(seen)
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_adstat_workbench_wrapper_opens_particle_statistics(qapp):
+ dlg = AdStatWorkbenchDialog(initial_mode="learn")
+
+ assert dlg.windowTitle() == "Particle Statistics"
+ assert dlg.current_mode == "learn"
+
+ dlg.close()
+ dlg.deleteLater()
+
+
+def test_particle_statistics_shared_store_add(qapp):
+ """add_feature_sets writes to the shared store and shows in the list."""
+ from probeflow.measurements.feature_sets import FeatureSet, FeatureSetStore
+
+ store = FeatureSetStore()
+ dlg = ParticleStatisticsDialog(feature_set_store=store, initial_mode="real")
+ fs = FeatureSet.from_points(
+ name="imported set",
+ points_px=[[1, 2], [3, 4]],
+ points_m=[[1e-9, 2e-9], [3e-9, 4e-9]],
+ scan_range_m=(10e-9, 10e-9),
+ image_shape=(10, 10),
+ )
+ dlg.add_feature_sets([fs])
+ assert len(store) == 1
+ texts = [
+ dlg._feature_sets_list.item(i).text()
+ for i in range(dlg._feature_sets_list.count())
+ ]
+ assert any("imported set" in t for t in texts)
+
+
+def test_particle_statistics_shared_store_visible_across_dialogs(qapp):
+ """Two dialogs sharing one store both see a set added through either."""
+ from probeflow.measurements.feature_sets import FeatureSet, FeatureSetStore
+
+ store = FeatureSetStore()
+ dlg1 = ParticleStatisticsDialog(feature_set_store=store, initial_mode="real")
+ dlg2 = ParticleStatisticsDialog(feature_set_store=store, initial_mode="real")
+ fs = FeatureSet.from_points(
+ name="shared set",
+ points_px=[[1, 2]],
+ points_m=[[1e-9, 2e-9]],
+ scan_range_m=(10e-9, 10e-9),
+ image_shape=(10, 10),
+ )
+ dlg1.add_feature_sets([fs])
+ dlg2._populate_feature_sets() # resync from the shared store
+ texts = [
+ dlg2._feature_sets_list.item(i).text()
+ for i in range(dlg2._feature_sets_list.count())
+ ]
+ assert any("shared set" in t for t in texts)
+
+
+def test_particle_statistics_import_csv_from_disk(qapp, tmp_path, monkeypatch):
+ """The import button loads a CSV into the store via the calibration dialog."""
+ from PySide6.QtWidgets import QDialog, QFileDialog
+
+ from probeflow.measurements.feature_sets import FeatureSetStore
+
+ csv = tmp_path / "pts.csv"
+ csv.write_text("x_nm,y_nm\n5,10\n15,20\n", encoding="utf-8")
+
+ store = FeatureSetStore()
+ dlg = ParticleStatisticsDialog(feature_set_store=store, initial_mode="real")
+
+ monkeypatch.setattr(
+ QFileDialog, "getOpenFileName", staticmethod(lambda *a, **k: (str(csv), ""))
+ )
+
+ class _FakeImportDialog:
+ def __init__(self, preview, **kwargs):
+ self._preview = preview
+
+ def exec(self):
+ return QDialog.Accepted
+
+ def result_calibration(self):
+ return "nm", (100e-9, 100e-9), (100, 100)
+
+ monkeypatch.setattr(
+ "probeflow.gui.dialogs.import_points.ImportPointsDialog", _FakeImportDialog
+ )
+
+ dlg.import_points_from_disk()
+ assert len(store) == 1
+ assert store.all()[0].point_count == 2
+
+
+def test_particle_statistics_export_csv(qapp, tmp_path, monkeypatch):
+ """The Export menu writes per-statistic CSVs from the current result."""
+ pytest.importorskip("adstat") # building a real view spec needs the engine
+ from types import SimpleNamespace
+
+ from PySide6.QtWidgets import QFileDialog
+
+ from probeflow.analysis.adstat_adapter import compare_point_source_view_spec
+
+ dlg = ParticleStatisticsDialog(initial_mode="real")
+ rng = np.random.default_rng(0)
+ pts_nm = rng.uniform(5.0, 95.0, size=(30, 2))
+ source = SimpleNamespace(
+ label="run", source_type="s",
+ points_px=pts_nm / (100.0 / 256.0), points_m=pts_nm * 1e-9, metadata={},
+ )
+ scan = SimpleNamespace(scan_range_m=(100e-9, 100e-9), dims=(256, 256))
+ dlg._last_view_spec = compare_point_source_view_spec(
+ source, scan=scan, image_shape=(256, 256), n_simulations=4, random_seed=0
+ )
+
+ monkeypatch.setattr(
+ QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: str(tmp_path))
+ )
+ dlg._export_results_csv()
+ files = list(tmp_path.glob("*.csv"))
+ assert files
+ assert any("verdicts" in f.name for f in files)
+
+
+def test_particle_statistics_export_guarded_without_result(qapp):
+ """Exporting before any run is a no-op with a status message, not a crash."""
+ dlg = ParticleStatisticsDialog(initial_mode="real")
+ dlg._export_results_csv() # no result yet → should not raise
+ dlg._export_results_json()
+
+
+def test_particle_statistics_ordering_is_opt_in(qapp):
+ """Local-order checks are off by default; the checkbox enables them + the cards."""
+ dlg = ParticleStatisticsDialog(initial_mode="real")
+ assert dlg._include_ordering_enabled() is False
+ assert dlg._request_from_controls().include_ordering is False
+ for button in dlg._ordering_stat_buttons.values():
+ assert not button.isEnabled()
+
+ dlg._include_ordering_cb.setChecked(True)
+ assert dlg._request_from_controls().include_ordering is True
+ for button in dlg._ordering_stat_buttons.values():
+ assert button.isEnabled()
+
+
+def test_particle_statistics_tutorial_navigation_never_dead_ends(qapp):
+ """Previous/Next are always present, name a destination, and never strand the user."""
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ dlg.start_tutorial()
+ # Both navigation buttons are present (not hidden) with the big title + progress.
+ assert not dlg._prev_tutorial_btn.isHidden()
+ assert not dlg._next_tutorial_btn.isHidden()
+ assert dlg._tutorial_title_lbl.text()
+ assert "Lesson 1 of" in dlg._tutorial_progress_lbl.text()
+ # First lesson: Previous disabled, Next enabled and forward-pointing.
+ assert not dlg._prev_tutorial_btn.isEnabled()
+ assert dlg._next_tutorial_btn.isEnabled()
+ assert dlg._next_tutorial_btn.text().strip().endswith("▸")
+
+ total = dlg._tutorial_position()[1]
+ # Walk forward to the end; Next must stay enabled until the final lesson, so a
+ # "run" step (e.g. the hard-core Generate-points step) can never trap the user.
+ guard = 0
+ while dlg._next_tutorial_btn.isEnabled() and guard <= total + 2:
+ dlg.next_tutorial_step()
+ guard += 1
+ pos, tot = dlg._tutorial_position()
+ assert pos == tot # reached the last lesson
+ assert not dlg._next_tutorial_btn.isEnabled()
+ assert dlg._prev_tutorial_btn.isEnabled()
+
+ # Walk all the way back to the first lesson.
+ guard = 0
+ while dlg._prev_tutorial_btn.isEnabled() and guard <= tot + 2:
+ dlg.previous_tutorial_step()
+ guard += 1
+ assert dlg._tutorial_position()[0] == 1
+ dlg.close()
+
+
+def test_exit_tutorial_and_landing_keep_dialog_in_front(qapp):
+ """Exiting the tutorial (or returning to Workflows) re-raises Particle Statistics."""
+ dlg = ParticleStatisticsDialog(initial_mode="learn")
+ if dlg._sandbox_state is None:
+ pytest.skip("AdStat sandbox is not installed")
+
+ calls: list[str] = []
+ dlg._raise_self = lambda: calls.append("raise") # type: ignore[method-assign]
+
+ dlg.exit_tutorial()
+ assert calls, "exit_tutorial should re-raise the dialog to the front"
+ assert dlg.current_mode == "real"
+
+ calls.clear()
+ dlg.return_to_landing_page()
+ assert calls, "return_to_landing_page should re-raise the dialog to the front"
+ assert dlg.current_mode == "landing"
+ dlg.close()
diff --git a/tests/test_definitions_dialog.py b/tests/test_definitions_dialog.py
index b894b9f..60f1a9f 100644
--- a/tests/test_definitions_dialog.py
+++ b/tests/test_definitions_dialog.py
@@ -185,6 +185,61 @@ def test_definitions_dialog_tabs_can_focus_howto_processing_and_roi(qapp):
qapp.processEvents()
+def test_particle_statistics_reference_has_models_and_formulas():
+ from probeflow.gui.dialogs.definitions import (
+ _PARTICLE_STATISTICS_ENTRIES,
+ render_particle_statistics_html,
+ )
+ from probeflow.gui.styling import THEMES
+
+ html = render_particle_statistics_html(THEMES["light"])
+
+ # Every entry that defines an Operation block renders an equation.
+ with_equations = sum(1 for entry in _PARTICLE_STATISTICS_ENTRIES if entry.equations)
+ assert html.count('class="equation"') >= with_equations
+
+ # The methodology, all three null models, and each statistic are documented.
+ for heading in (
+ "How a comparison works",
+ "Homogeneous Poisson",
+ "Hard-core random",
+ "Measured-feature Poisson",
+ "Pair correlation g(r)",
+ "Nearest-neighbour distribution",
+ "Ripley", # apostrophe in "Ripley's L" is HTML-escaped
+ "Cluster sizes",
+ "Reading verdicts and limitations",
+ ):
+ assert heading in html, heading
+
+ # The mathematical defence and honest caveats are present.
+ assert "extreme rank length" in html.lower()
+ assert "exchangeable" in html.lower()
+ assert "lambda = N / A" in html # CSR intensity
+ assert "L(r) = sqrt( K(r) / pi )" in html # Ripley L transform
+ assert "non-equilibrium" in html.lower() # hard-core honesty
+ assert "least user-tested" in html.lower() # maturity note in the intro
+
+
+def test_definitions_dialog_can_focus_particle_statistics_tab(qapp):
+ from probeflow.gui.dialogs.definitions import _DefinitionsDialog
+ from probeflow.gui.styling import THEMES
+
+ stats_first = _DefinitionsDialog(THEMES["light"], initial_tab="particle_statistics")
+ default = _DefinitionsDialog(THEMES["light"])
+ try:
+ assert stats_first.current_reference_tab() == "particle_statistics"
+ default.set_reference_tab("particle_statistics")
+ assert default.current_reference_tab() == "particle_statistics"
+ default.set_reference_tab("stats") # alias
+ assert default.current_reference_tab() == "particle_statistics"
+ finally:
+ for dlg in (stats_first, default):
+ dlg.close()
+ dlg.deleteLater()
+ qapp.processEvents()
+
+
def test_howto_help_command_is_registered():
from probeflow.gui.viewer.shortcuts import VIEWER_COMMANDS
diff --git a/tests/test_feature_counting_controller.py b/tests/test_feature_counting_controller.py
index 4615f16..1c534f5 100644
--- a/tests/test_feature_counting_controller.py
+++ b/tests/test_feature_counting_controller.py
@@ -124,3 +124,52 @@ def test_pct_to_nm2_rectangle():
# total area = 200 * 100 * 0.5nm * 2.0nm = 20000 nm²
# 0.5% of 20000 = 100 nm²
assert _pct_to_nm2(0.5, arr, px_x_m, px_y_m) == pytest.approx(100.0)
+
+
+def test_send_to_particle_statistics_builds_set_and_emits():
+ """Send-to-stats builds a FeatureSet, adds it to the shared store, emits a request."""
+ from types import SimpleNamespace
+
+ from probeflow.measurements.feature_sets import FeatureSetStore
+
+ panel, sidebar, pool, status_cb, preview_pool = _make_mocks()
+ store = FeatureSetStore()
+ try:
+ ctrl = FeatureCountingController(
+ panel, sidebar, pool, status_cb,
+ preview_pool=preview_pool,
+ feature_set_store=store,
+ )
+ except RuntimeError:
+ pytest.skip("Qt not available in this environment")
+
+ panel.get_particles.return_value = [
+ SimpleNamespace(to_dict=lambda: {"centroid_x_m": 10e-9, "centroid_y_m": 20e-9}),
+ SimpleNamespace(to_dict=lambda: {"centroid_x_m": 30e-9, "centroid_y_m": 40e-9}),
+ ]
+ panel.current_scan.return_value = SimpleNamespace(
+ scan_range_m=(100e-9, 80e-9), dims=(200, 160)
+ )
+ panel.current_entry.return_value = SimpleNamespace(stem="imgA")
+
+ captured: dict = {}
+ ctrl.open_particle_statistics_requested.connect(
+ lambda ctx, sid: captured.update(ctx=ctx, sid=sid)
+ )
+ ctrl._on_send_to_particle_statistics("particles")
+
+ assert len(store) == 1
+ assert captured.get("sid")
+ assert store.all()[0].point_count == 2
+
+
+def test_send_to_particle_statistics_without_store_is_noop():
+ panel, sidebar, pool, status_cb, preview_pool = _make_mocks()
+ try:
+ ctrl = FeatureCountingController(
+ panel, sidebar, pool, status_cb, preview_pool=preview_pool
+ )
+ except RuntimeError:
+ pytest.skip("Qt not available in this environment")
+ ctrl._on_send_to_particle_statistics("particles")
+ panel.get_particles.assert_not_called()
diff --git a/tests/test_feature_finder.py b/tests/test_feature_finder.py
index 0e2a27a..119dbc7 100644
--- a/tests/test_feature_finder.py
+++ b/tests/test_feature_finder.py
@@ -243,3 +243,31 @@ def test_find_image_features_and_detect_local_maxima_agree_on_peak_locations():
ff_locs = sorted((round(pt.x_px), round(pt.y_px)) for pt in ff_result.points)
dlm_locs = sorted((round(pt.x_px), round(pt.y_px)) for pt in dlm_points)
assert ff_locs == dlm_locs
+
+
+@pytest.fixture
+def qapp():
+ from PySide6.QtWidgets import QApplication
+
+ app = QApplication.instance() or QApplication([])
+ yield app
+
+
+def test_feature_finder_thresholds_display_in_channel_unit(qapp):
+ from probeflow.gui.dialogs.feature_finder import FeatureFinderDialog
+
+ arr = np.linspace(0.0, 2e-9, 64, dtype=float).reshape(8, 8) # metres
+ dlg = FeatureFinderDialog(arr, value_scale=1e9, value_unit="nm") # m -> nm
+
+ # High threshold defaults to the data max, shown in nm (≈2.0) not raw SI.
+ assert dlg._high_spin.suffix().strip() == "nm"
+ assert dlg._high_spin.value() == pytest.approx(2.0, abs=1e-3)
+
+ # Detection converts the displayed value back to raw metres.
+ dlg._set_threshold_mode("below") if hasattr(dlg, "_set_threshold_mode") else None
+ dlg._high_spin.setValue(1.0) # 1.0 nm
+ scale = dlg._value_scale
+ assert dlg._high_spin.value() / scale == pytest.approx(1e-9)
+
+ dlg.close()
+ dlg.deleteLater()
diff --git a/tests/test_feature_sets.py b/tests/test_feature_sets.py
new file mode 100644
index 0000000..3aa2c13
--- /dev/null
+++ b/tests/test_feature_sets.py
@@ -0,0 +1,121 @@
+"""Tests for the named feature-set store and its AdStat hand-off."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from probeflow.measurements.feature_sets import FeatureSet, FeatureSetStore
+
+
+def _set(name: str, n: int, seed: int) -> FeatureSet:
+ rng = np.random.default_rng(seed)
+ xy_nm = rng.uniform(0.0, 100.0, size=(n, 2))
+ return FeatureSet.from_points(
+ name=name,
+ points_px=xy_nm, # treat 1 px = 1 nm here for simplicity
+ points_m=xy_nm * 1e-9,
+ scan_range_m=(100e-9, 100e-9),
+ image_shape=(256, 256),
+ image_label=name,
+ metadata={"detection_mode": "maxima"},
+ )
+
+
+def test_feature_set_store_add_rename_remove():
+ store = FeatureSetStore()
+ a = store.add(_set("A", 10, 1))
+ store.add(_set("B", 12, 2))
+
+ assert len(store) == 2
+ assert store.rename(a, "A renamed")
+ assert store.get(a).name == "A renamed"
+ assert store.remove(a)
+ assert len(store) == 1
+ assert store.get(a) is None
+
+
+def test_feature_set_store_json_round_trip(tmp_path):
+ store = FeatureSetStore()
+ store.add(_set("A", 8, 1))
+ store.add(_set("B", 9, 2))
+ path = tmp_path / "sets.json"
+ store.save(path)
+
+ loaded = FeatureSetStore.load(path)
+ assert [fs.name for fs in loaded.all()] == ["A", "B"]
+ assert [fs.point_count for fs in loaded.all()] == [8, 9]
+ # points survive the round trip
+ np.testing.assert_allclose(
+ loaded.all()[0].points_m, store.all()[0].points_m
+ )
+
+
+def test_feature_set_load_missing_file_is_empty(tmp_path):
+ assert len(FeatureSetStore.load(tmp_path / "nope.json")) == 0
+
+
+def test_feature_set_to_point_set_record():
+ pytest.importorskip("adstat")
+ record = _set("A", 20, 1).to_point_set_record()
+ assert len(record.table) == 20
+ assert record.region.area_nm2 == pytest.approx(100.0 * 100.0)
+
+
+def test_single_record_view_spec_has_verdict():
+ pytest.importorskip("adstat")
+ from probeflow.analysis.adstat_adapter import compare_point_set_record_view_spec
+
+ spec = compare_point_set_record_view_spec(
+ _set("A", 120, 1).to_point_set_record(),
+ models=("poisson",),
+ n_simulations=6,
+ random_seed=0,
+ )
+ stats = {getattr(p, "statistic", "") for p in spec.panels}
+ assert "pair_correlation_g_r" in stats
+ assert spec.verdict_rows
+
+
+def test_combined_records_view_spec_pools_replicates():
+ pytest.importorskip("adstat")
+ from probeflow.analysis.adstat_adapter import compare_point_set_records_view_spec
+
+ records = [_set(f"img{i}", 110 + i, i).to_point_set_record() for i in range(3)]
+ spec = compare_point_set_records_view_spec(
+ records, models=("poisson",), n_simulations=6, random_seed=0
+ )
+ stats = {getattr(p, "statistic", "") for p in spec.panels}
+ assert "pair_correlation_g_r" in stats
+ assert spec.verdict_rows
+
+
+def test_feature_set_to_feature_layer_is_independent():
+ layer = _set("edges", 12, 5).to_feature_layer()
+ assert layer["kind"] == "points"
+ assert layer["xy_nm"].shape[1] == 2
+ assert layer["provenance"]["measured_independently"] is True
+ assert layer["provenance"]["derived_from_particles"] is False
+
+
+def test_measured_feature_record_view_spec_uses_feature_layer():
+ pytest.importorskip("adstat")
+ from probeflow.analysis.adstat_adapter import compare_point_set_record_view_spec
+
+ particles = _set("particles", 120, 1)
+ feature = _set("step edges", 15, 99)
+ spec = compare_point_set_record_view_spec(
+ particles.to_point_set_record(),
+ models=("measured_feature_poisson",),
+ feature_layers=[feature.to_feature_layer()],
+ n_simulations=6,
+ random_seed=7,
+ )
+ assert spec.metadata.get("active_model") == "measured_feature_poisson"
+ assert {
+ comparison.ensemble.base_seed
+ for comparison in spec.metadata["comparison_results"]
+ } == {7}
+ assert spec.verdict_rows
+ stats = {getattr(p, "statistic", "") for p in spec.panels}
+ assert "pair_correlation_g_r" in stats
diff --git a/tests/test_gui_processing_panel.py b/tests/test_gui_processing_panel.py
index 91a92dd..a1777c6 100644
--- a/tests/test_gui_processing_panel.py
+++ b/tests/test_gui_processing_panel.py
@@ -5,6 +5,7 @@
import json
import os
from pathlib import Path
+from types import SimpleNamespace
import numpy as np
import pytest
@@ -278,6 +279,10 @@ def test_viewer_align_rows_applies_immediately(qapp, monkeypatch):
entry = SxmFile(path=Path("/tmp/example.sxm"), stem="example", Nx=8, Ny=8)
dlg = ImageViewerDialog(entry, [entry], "gray", THEMES["dark"])
assert "processing.image_operations" in dlg._viewer_command_actions
+ assert "measure.particle_statistics" in dlg._viewer_command_actions
+ assert "measure.adstat_workbench" in dlg._viewer_command_actions
+ assert "measure.adstat_statistics" in dlg._viewer_command_actions
+ assert "measure.adstat_sandbox" in dlg._viewer_command_actions
dlg._processing_panel._align_combo.setCurrentText("Mean")
@@ -294,6 +299,253 @@ def test_viewer_align_rows_applies_immediately(qapp, monkeypatch):
dlg.deleteLater()
+def test_viewer_adstat_statistics_uses_display_scan_context(qapp, monkeypatch):
+ from probeflow.gui import ImageViewerDialog
+ import probeflow.gui.dialogs.particle_statistics as particle_module
+
+ image = np.zeros((6, 8), dtype=float)
+ mask = np.ones((6, 8), dtype=bool)
+ active_roi = object()
+ captured = {}
+
+ class FakeParticleStatisticsDialog:
+ def __init__(
+ self,
+ *,
+ point_sources,
+ scan,
+ active_area_roi,
+ active_mask,
+ image_shape,
+ feature_sets,
+ feature_set_store,
+ theme,
+ initial_mode,
+ parent,
+ context_refresh_fn,
+ ):
+ self.destroyed = SimpleNamespace(
+ connect=lambda cb: captured.__setitem__("destroyed_cb", cb)
+ )
+ captured["dialog"] = {
+ "point_sources": tuple(point_sources),
+ "scan": scan,
+ "active_area_roi": active_area_roi,
+ "active_mask": active_mask,
+ "image_shape": image_shape,
+ "feature_sets": tuple(feature_sets),
+ "feature_set_store": feature_set_store,
+ "theme": theme,
+ "initial_mode": initial_mode,
+ "parent": parent,
+ "context_refresh_fn": context_refresh_fn,
+ }
+
+ def isVisible(self):
+ return False
+
+ def refresh_probe_context(self, **kwargs):
+ captured["refresh"] = kwargs
+
+ def set_current_mode(self, mode):
+ captured["mode"] = mode
+
+ def show(self):
+ captured["shown"] = True
+
+ def raise_(self):
+ captured["raised"] = True
+
+ def activateWindow(self):
+ captured["activated"] = True
+
+ monkeypatch.setattr(particle_module, "ParticleStatisticsDialog", FakeParticleStatisticsDialog)
+
+ dlg = ImageViewerDialog.__new__(ImageViewerDialog)
+ dlg._display_arr = image
+ dlg._raw_arr = None
+ dlg._display_scan_range_m = (80e-9, 30e-9)
+ dlg._scan_range_m = (8e-9, 6e-9)
+ dlg._entries = [SimpleNamespace(path=Path("/tmp/example.sxm"))]
+ dlg._idx = 0
+ dlg._t = {"text.color": "#111111"}
+ dlg._status_lbl = _FakeStatus()
+ dlg._point_source_records = lambda: ["points"]
+ dlg._active_image_roi = lambda: active_roi
+ dlg._active_mask_array = lambda: mask
+ dlg._track_modeless_child = lambda dialog: captured.__setitem__("tracked", dialog)
+
+ dlg._on_open_adstat_statistics()
+
+ dialog = captured["dialog"]
+ assert dialog["point_sources"] == ("points",)
+ assert dialog["scan"].scan_range_m == pytest.approx((80e-9, 30e-9))
+ assert dialog["scan"].dims == (8, 6)
+ assert dialog["scan"].source_path == Path("/tmp/example.sxm")
+ assert dialog["active_area_roi"] is active_roi
+ assert dialog["active_mask"] is mask
+ assert dialog["image_shape"] == (6, 8)
+ assert captured["dialog"]["theme"] == {"text.color": "#111111"}
+ assert captured["dialog"]["initial_mode"] == "landing"
+ assert captured["dialog"]["parent"] is dlg
+ assert captured["tracked"] is dlg._particle_statistics_dialog
+ assert captured["shown"] is True
+ assert captured["raised"] is True
+ assert captured["activated"] is True
+ assert dlg._status_lbl.text == "Particle Statistics opened."
+
+
+def test_viewer_adstat_statistics_opens_workbench_without_preflight(qapp, monkeypatch):
+ from probeflow.gui import ImageViewerDialog
+ import probeflow.gui.dialogs.particle_statistics as particle_module
+
+ captured = {}
+
+ class FakeParticleStatisticsDialog:
+ def __init__(self, **kwargs):
+ self.destroyed = SimpleNamespace(connect=lambda cb: None)
+ captured["dialog"] = dict(kwargs)
+
+ def isVisible(self):
+ return False
+
+ def show(self):
+ captured["shown"] = True
+
+ def raise_(self):
+ captured["raised"] = True
+
+ def activateWindow(self):
+ captured["activated"] = True
+
+ monkeypatch.setattr(particle_module, "ParticleStatisticsDialog", FakeParticleStatisticsDialog)
+
+ dlg = ImageViewerDialog.__new__(ImageViewerDialog)
+ dlg._display_arr = np.zeros((4, 5), dtype=float)
+ dlg._raw_arr = None
+ dlg._display_scan_range_m = (5e-9, 4e-9)
+ dlg._scan_range_m = None
+ dlg._entries = []
+ dlg._idx = 0
+ dlg._t = {"text.color": "#111111"}
+ dlg._status_lbl = _FakeStatus()
+ dlg._point_source_records = lambda: []
+ dlg._active_image_roi = lambda: None
+ dlg._active_mask_array = lambda: None
+ dlg._track_modeless_child = lambda dialog: captured.__setitem__("tracked", dialog)
+
+ dlg._on_open_adstat_statistics()
+
+ assert captured["dialog"]["point_sources"] == []
+ assert captured["dialog"]["initial_mode"] == "landing"
+ assert captured["tracked"] is dlg._particle_statistics_dialog
+ assert captured["shown"] is True
+ assert dlg._status_lbl.text == "Particle Statistics opened."
+
+
+def test_viewer_particle_statistics_reopen_reuses_existing_window(qapp, monkeypatch):
+ from probeflow.gui import ImageViewerDialog
+ import probeflow.gui.dialogs.particle_statistics as particle_module
+
+ instances = []
+
+ class FakeParticleStatisticsDialog:
+ def __init__(self, **kwargs):
+ self.destroyed = SimpleNamespace(connect=lambda cb: None)
+ self.kwargs = dict(kwargs)
+ self.show_count = 0
+ self.refreshes = []
+ instances.append(self)
+
+ def isVisible(self):
+ return False
+
+ def refresh_probe_context(self, **kwargs):
+ self.refreshes.append(kwargs)
+
+ def show(self):
+ self.show_count += 1
+
+ def raise_(self):
+ pass
+
+ def activateWindow(self):
+ pass
+
+ monkeypatch.setattr(particle_module, "ParticleStatisticsDialog", FakeParticleStatisticsDialog)
+
+ dlg = ImageViewerDialog.__new__(ImageViewerDialog)
+ dlg._display_arr = np.zeros((4, 5), dtype=float)
+ dlg._raw_arr = None
+ dlg._display_scan_range_m = (5e-9, 4e-9)
+ dlg._scan_range_m = None
+ dlg._entries = []
+ dlg._idx = 0
+ dlg._t = {"text.color": "#111111"}
+ dlg._status_lbl = _FakeStatus()
+ dlg._active_image_roi = lambda: None
+ dlg._active_mask_array = lambda: None
+ dlg._track_modeless_child = lambda dialog: None
+ sources = [["first"], ["second"]]
+ dlg._point_source_records = lambda: sources.pop(0)
+
+ dlg._on_open_particle_statistics()
+ dlg._on_open_particle_statistics()
+
+ assert len(instances) == 1
+ assert instances[0].show_count == 2
+ assert instances[0].refreshes[-1]["point_sources"] == ["second"]
+ assert dlg._particle_statistics_dialog is instances[0]
+
+
+def test_viewer_adstat_sandbox_opens_isolated_dialog(qapp, monkeypatch):
+ from probeflow.gui import ImageViewerDialog
+ import probeflow.gui.dialogs.particle_statistics as particle_module
+
+ captured = {}
+
+ class FakeParticleStatisticsDialog:
+ def __init__(self, **kwargs):
+ self.destroyed = SimpleNamespace(connect=lambda cb: None)
+ captured["dialog"] = {
+ **kwargs,
+ }
+
+ def isVisible(self):
+ return False
+
+ def show(self):
+ captured["shown"] = True
+
+ def raise_(self):
+ captured["raised"] = True
+
+ def activateWindow(self):
+ captured["activated"] = True
+
+ monkeypatch.setattr(particle_module, "ParticleStatisticsDialog", FakeParticleStatisticsDialog)
+
+ dlg = ImageViewerDialog.__new__(ImageViewerDialog)
+ dlg._display_arr = None
+ dlg._raw_arr = None
+ dlg._t = {"text.color": "#111111"}
+ dlg._status_lbl = _FakeStatus()
+ dlg._track_modeless_child = lambda dialog: captured.__setitem__("tracked", dialog)
+ dlg._point_source_records = lambda: pytest.fail("sandbox must not collect real points")
+ dlg._active_image_roi = lambda: pytest.fail("sandbox must not inspect active ROI")
+
+ dlg._on_open_adstat_sandbox()
+
+ assert captured["dialog"]["theme"] == {"text.color": "#111111"}
+ assert captured["dialog"]["point_sources"] == []
+ assert captured["dialog"]["scan"] is None
+ assert captured["dialog"]["initial_mode"] == "sandbox"
+ assert captured["dialog"]["parent"] is dlg
+ assert captured["tracked"] is dlg._particle_statistics_dialog
+ assert captured["shown"] is True
+ assert dlg._status_lbl.text == "Particle Statistics opened."
+
+
def test_viewer_stm_background_apply_records_processing_state(qapp, monkeypatch):
from probeflow.gui import ImageViewerDialog, SxmFile, THEMES
@@ -1457,6 +1709,7 @@ def __init__(self, *args, **kwargs):
dlg._raw_arr = None
dlg._image_roi_set = roi_set
dlg._pixel_size_xy_m = lambda: (1e-9, 1e-9)
+ dlg._channel_unit = lambda: (1.0, "", "")
dlg._t = {}
dlg._status_lbl = _FakeStatus()
# Tools now open via the modal-overlay helper (which needs a real QWidget);
diff --git a/tests/test_image_viewer_modeless_lifecycle.py b/tests/test_image_viewer_modeless_lifecycle.py
index 354e4b7..d5b5866 100644
--- a/tests/test_image_viewer_modeless_lifecycle.py
+++ b/tests/test_image_viewer_modeless_lifecycle.py
@@ -34,6 +34,7 @@ def _fake_dialog(visible: bool = True, raise_close: bool = False):
# tracker does not blow up.
dlg.destroyed = MagicMock()
dlg.destroyed.connect = MagicMock()
+ dlg.force_close = None
return dlg
@@ -92,6 +93,18 @@ def test_close_modeless_children_closes_visible_dialogs():
hidden.close.assert_not_called()
+def test_close_modeless_children_uses_force_close_when_available():
+ host = _Host()
+ dialog = _fake_dialog(visible=True)
+ dialog.force_close = MagicMock()
+ host._modeless_children.append(dialog)
+
+ host._close_modeless_children()
+
+ dialog.force_close.assert_called_once()
+ dialog.close.assert_not_called()
+
+
def test_close_modeless_children_tolerates_runtime_error_on_isvisible():
"""A child whose C++ object was already deleted raises RuntimeError on
``isVisible()``. Teardown must not cascade out of this."""
diff --git a/tests/test_point_table_io.py b/tests/test_point_table_io.py
new file mode 100644
index 0000000..3617bff
--- /dev/null
+++ b/tests/test_point_table_io.py
@@ -0,0 +1,235 @@
+"""Tests for the point-table importer (CSV / JSON) for Particle Statistics."""
+
+from __future__ import annotations
+
+import json
+
+import numpy as np
+import pytest
+
+from probeflow.measurements.feature_sets import FeatureSet, FeatureSetStore
+from probeflow.measurements.point_table_io import (
+ default_image_shape,
+ default_scan_range_m,
+ feature_items_to_feature_set,
+ load_point_table,
+ sniff_point_table,
+)
+
+
+def _write(path, text):
+ path.write_text(text, encoding="utf-8")
+ return path
+
+
+# --------------------------------------------------------------------------- #
+# CSV
+# --------------------------------------------------------------------------- #
+def test_feature_finder_csv_roundtrip(tmp_path):
+ csv_text = (
+ "index,x_px,y_px,x_nm,y_nm,value\n"
+ "0,10.0,20.0,5.0,10.0,1.23\n"
+ "1,30.0,40.0,15.0,20.0,4.56\n"
+ )
+ p = _write(tmp_path / "ff.csv", csv_text)
+ preview = sniff_point_table(p)
+ assert preview.kind == "probeflow_csv"
+ assert preview.units == "nm"
+ assert preview.has_id_column is True
+ assert preview.n_points == 2
+
+ (fs,) = load_point_table(p, scan_range_m=(100e-9, 100e-9), image_shape=(100, 100))
+ assert fs.point_count == 2
+ # nm columns -> metres
+ np.testing.assert_allclose(fs.points_m[0], [5e-9, 10e-9])
+ np.testing.assert_allclose(fs.points_m[1], [15e-9, 20e-9])
+
+
+def test_measurements_csv_units_from_unit_column(tmp_path):
+ csv_text = (
+ "point_id,x_px,y_px,x_phys,y_phys,z_value,x_unit,y_unit,z_unit,channel,source_label,roi_id\n"
+ "p0,1,2,3.0,4.0,0.1,nm,nm,m,Z,feat,\n"
+ "p1,5,6,7.0,8.0,0.2,nm,nm,m,Z,feat,\n"
+ )
+ p = _write(tmp_path / "meas.csv", csv_text)
+ preview = sniff_point_table(p)
+ assert preview.units == "nm"
+ assert preview.has_id_column is True
+ (fs,) = load_point_table(p, scan_range_m=(50e-9, 50e-9), image_shape=(50, 50))
+ np.testing.assert_allclose(fs.points_m[0], [3e-9, 4e-9])
+
+
+def test_generic_csv_bare_xy_needs_units(tmp_path):
+ p = _write(tmp_path / "bare.csv", "x,y\n1.0,2.0\n3.0,4.0\n")
+ preview = sniff_point_table(p)
+ assert preview.kind == "generic_csv"
+ assert preview.units == "unknown"
+ assert preview.has_id_column is False
+ with pytest.raises(ValueError):
+ load_point_table(p) # units cannot be inferred
+ (fs,) = load_point_table(p, units="nm", scan_range_m=(10e-9, 10e-9), image_shape=(10, 10))
+ np.testing.assert_allclose(fs.points_m[1], [3e-9, 4e-9])
+
+
+def test_generic_csv_headerless_with_implied_id(tmp_path):
+ # leading 0-based integer sequence => id column, x/y are the next two columns
+ p = _write(tmp_path / "noheader.csv", "0,12,34\n1,56,78\n2,90,11\n")
+ preview = sniff_point_table(p)
+ assert preview.has_header is False
+ assert preview.has_id_column is True
+ assert preview.n_points == 3
+ (fs,) = load_point_table(p, units="px", scan_range_m=(100e-9, 100e-9), image_shape=(100, 100))
+ np.testing.assert_allclose(fs.points_px[0], [12.0, 34.0])
+
+
+def test_generic_csv_headerless_no_id(tmp_path):
+ p = _write(tmp_path / "xy.csv", "12,34\n56,78\n")
+ preview = sniff_point_table(p)
+ assert preview.has_id_column is False
+ (fs,) = load_point_table(p, units="px", scan_range_m=(100e-9, 100e-9), image_shape=(100, 100))
+ np.testing.assert_allclose(fs.points_px[0], [12.0, 34.0])
+ np.testing.assert_allclose(fs.points_px[1], [56.0, 78.0])
+
+
+def test_px_units_pixel_columns_detected(tmp_path):
+ p = _write(tmp_path / "pxcols.csv", "x_px,y_px\n4,5\n6,7\n")
+ preview = sniff_point_table(p)
+ assert preview.units == "px"
+
+
+def test_semicolon_delimiter_sniffed(tmp_path):
+ p = _write(tmp_path / "semi.csv", "x_nm;y_nm\n1.0;2.0\n3.0;4.0\n")
+ preview = sniff_point_table(p)
+ assert preview.delimiter == ";"
+ assert preview.units == "nm"
+ (fs,) = load_point_table(p, scan_range_m=(10e-9, 10e-9), image_shape=(10, 10))
+ assert fs.point_count == 2
+
+
+# --------------------------------------------------------------------------- #
+# JSON
+# --------------------------------------------------------------------------- #
+def test_feature_counting_particles_json(tmp_path):
+ payload = {
+ "meta": {
+ "kind": "particles",
+ "scan_range_m": [100e-9, 80e-9],
+ "pixels": [200, 160],
+ },
+ "items": [
+ {"index": 0, "centroid_x_m": 10e-9, "centroid_y_m": 20e-9, "area_nm2": 5.0},
+ {"index": 1, "centroid_x_m": 30e-9, "centroid_y_m": 40e-9, "area_nm2": 6.0},
+ ],
+ }
+ p = _write(tmp_path / "particles.json", json.dumps(payload))
+ preview = sniff_point_table(p)
+ assert preview.kind == "probeflow_json"
+ assert preview.scan_range_m == (100e-9, 80e-9)
+ assert preview.image_shape == (160, 200) # (ny, nx)
+ assert preview.needs_calibration is False
+
+ (fs,) = load_point_table(p)
+ assert fs.point_count == 2
+ np.testing.assert_allclose(fs.points_m[0], [10e-9, 20e-9], rtol=1e-6)
+ np.testing.assert_allclose(fs.points_m[1], [30e-9, 40e-9], rtol=1e-6)
+
+
+def test_feature_counting_detections_json(tmp_path):
+ payload = {
+ "meta": {"kind": "detections", "scan_range_m": [50e-9, 50e-9], "pixels": [100, 100]},
+ "items": [
+ {"index": 0, "x_m": 5e-9, "y_m": 6e-9, "x_px": 10, "y_px": 12, "correlation": 0.9},
+ ],
+ }
+ p = _write(tmp_path / "det.json", json.dumps(payload))
+ (fs,) = load_point_table(p)
+ assert fs.point_count == 1
+ np.testing.assert_allclose(fs.points_m[0], [5e-9, 6e-9], rtol=1e-6)
+
+
+def test_feature_set_store_json_roundtrip(tmp_path):
+ fs1 = FeatureSet.from_points(
+ name="img A",
+ points_px=[[1, 2], [3, 4]],
+ points_m=[[1e-9, 2e-9], [3e-9, 4e-9]],
+ scan_range_m=(10e-9, 10e-9),
+ image_shape=(10, 10),
+ )
+ fs2 = FeatureSet.from_points(
+ name="img B",
+ points_px=[[5, 6]],
+ points_m=[[5e-9, 6e-9]],
+ scan_range_m=(10e-9, 10e-9),
+ image_shape=(10, 10),
+ )
+ store = FeatureSetStore([fs1, fs2])
+ p = tmp_path / "sets.json"
+ store.save(p)
+
+ preview = sniff_point_table(p)
+ assert preview.kind == "feature_set_store_json"
+ assert preview.n_sets == 2
+ assert preview.needs_calibration is False
+
+ sets = load_point_table(p)
+ assert len(sets) == 2
+ assert {s.name for s in sets} == {"img A", "img B"}
+
+
+# --------------------------------------------------------------------------- #
+# Calibration defaults
+# --------------------------------------------------------------------------- #
+def test_default_image_shape_keeps_aspect():
+ assert default_image_shape((100e-9, 50e-9)) == (512, 1024)
+ assert default_image_shape((50e-9, 100e-9)) == (1024, 512)
+
+
+def test_default_scan_range_contains_points():
+ bbox = (0.0, 0.0, 100.0, 80.0) # nm
+ w, h = default_scan_range_m(bbox, "nm")
+ assert w >= 100e-9
+ assert h >= 80e-9
+
+
+def test_unrecognised_json_raises(tmp_path):
+ p = _write(tmp_path / "weird.json", json.dumps({"foo": "bar"}))
+ with pytest.raises(ValueError):
+ sniff_point_table(p)
+
+
+# --------------------------------------------------------------------------- #
+# Feature Counting -> FeatureSet (the live FC send path; exercises the adapter)
+# --------------------------------------------------------------------------- #
+def test_feature_items_to_feature_set_from_dicts():
+ items = [
+ {"index": 0, "centroid_x_m": 10e-9, "centroid_y_m": 20e-9, "area_nm2": 5.0},
+ {"index": 1, "centroid_x_m": 30e-9, "centroid_y_m": 40e-9, "area_nm2": 6.0},
+ ]
+ fs = feature_items_to_feature_set(
+ items,
+ scan_range_m=(100e-9, 80e-9),
+ image_shape=(160, 200),
+ name="fc particles",
+ source_type="feature_counting_particles",
+ )
+ assert fs.point_count == 2
+ np.testing.assert_allclose(fs.points_m[0], [10e-9, 20e-9], rtol=1e-6)
+ np.testing.assert_allclose(fs.points_m[1], [30e-9, 40e-9], rtol=1e-6)
+
+
+def test_feature_items_to_feature_set_accepts_objects():
+ class _P:
+ def __init__(self, x, y):
+ self.x_m, self.y_m, self.x_px, self.y_px = x, y, 0, 0
+
+ def to_dict(self):
+ return {"x_m": self.x_m, "y_m": self.y_m, "x_px": self.x_px, "y_px": self.y_px}
+
+ fs = feature_items_to_feature_set(
+ [_P(5e-9, 6e-9)],
+ scan_range_m=(50e-9, 50e-9),
+ image_shape=(100, 100),
+ name="fc detections",
+ )
+ assert fs.point_count == 1
+ np.testing.assert_allclose(fs.points_m[0], [5e-9, 6e-9], rtol=1e-6)
diff --git a/tests/test_viewer_shortcuts.py b/tests/test_viewer_shortcuts.py
index e53a652..dfbdaf1 100644
--- a/tests/test_viewer_shortcuts.py
+++ b/tests/test_viewer_shortcuts.py
@@ -56,6 +56,10 @@ def test_command_finder_shortcut_and_visible_commands_are_high_level():
assert "help.definitions" in finder_ids
assert "help.measurements" in finder_ids
assert "help.roi_reference" in finder_ids
+ assert "measure.particle_statistics" in finder_ids
+ assert "measure.adstat_workbench" not in finder_ids
+ assert "measure.adstat_statistics" not in finder_ids
+ assert "measure.adstat_sandbox" not in finder_ids
assert viewer_command("help.roi_reference").shortcuts == ()
assert not any(command_id.startswith("roi.tool.") for command_id in finder_ids)
assert "image.threshold" in finder_ids