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