diff --git a/AGENTS.md b/AGENTS.md index ed6fa29..4f202e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ rather than style, ask rather than guess. | Python | 3.14, equal to `requires-python` in `pyproject.toml` | | Type checker | `pyright`, `basic` mode, over `croak/` | | Numerics | NumPy/SciPy, with JAX (optimistix, lineax) for the differentiable solvers | -| GUI | the `gui` extra (PyQt6, superqt); the dev group always installs it, and its tests run offscreen with pytest-qt | +| GUI | the `gui` extra (PySide6, pyqtgraph, superqt); the dev group always installs it, and its tests run offscreen with pytest-qt | | Optional extras | `evo` (evosax, the `cma-es` retriever; in the dev group) | | Planned work | [GitHub issues](https://github.com/LupoLab/croak/issues). [`CONTRIBUTING.md`](CONTRIBUTING.md) says how to write an issue and how to label an issue or a pull request | diff --git a/README.md b/README.md index 5970f55..2c7234a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ the PNPS (parameterised nonlinear process spectra) class of measurements, currently focused on FROG: the SHG, SD and PG/transient-grating geometries, ten solvers over a single physical forward model, and the workflow around them (loading, cleaning, retrieval, post-processing, dispersion tuning, uncertainty, -plotting and saving). The library is headless (NumPy/SciPy/JAX); a PyQt6 GUI is +plotting and saving). The library is headless (NumPy/SciPy/JAX); a Qt GUI (PySide6) is optional. What distinguishes croak from a classic FROG code is the forward model. @@ -56,7 +56,7 @@ methods and the validation are described in two papers in preparation (see the [capabilities overview](https://croak.readthedocs.io/en/latest/reference/features.html) lists everything the package does. -![The wizard after a retrieval: measured and retrieved traces, residuals, the retrieved pulse and spectrum, and a numeric read-out.](docs/_static/gui_retrieve.png) +![The GUI after a retrieval: measured and retrieved traces, the retrieved pulse and spectrum, and the run's numbers beside its convergence.](docs/_static/gui_retrieve.png) ## Install @@ -96,7 +96,7 @@ uv run croak-gui
Platform notes: read this if the install fails -The binary dependencies (`jax`, `nlopt`, `finufft`, `pyqt6`) do not publish +The binary dependencies (`jax`, `nlopt`, `finufft`, `pyside6-essentials`) do not publish wheels for every platform. Where a wheel is missing, pip/uv falls back to building from source, which needs CMake and a C++ toolchain. @@ -131,7 +131,7 @@ Prefer plain pip? `python3.14 -m venv .venv && . .venv/bin/activate && pip insta The [installation guide](https://croak.readthedocs.io/en/latest/getting_started/installation.html) covers all of this in more detail. -Optional extras: `croak[gui]` (the wizard) and +Optional extras: `croak[gui]` (the graphical interface) and `croak[evo]` (the cma-es global solver; pulls in `evosax` and its `flax`/`optax` dependency tree). In a clone the same extras are `uv sync --extra gui` etc. @@ -233,9 +233,12 @@ from the underlying propagation physics. ## GUI -A PyQt6 wizard (Load → Marginal check → Preprocess → Retrieve → Dispersion → -Uncertainty) drives the same library functions, with embedded plots, threaded -retrieval and a live preview: +A PySide6 window drives the same library functions. A session starts on Home +from a measured trace, a simulated scan or a synthetic pulse, and runs through +Data → Check (the trace's marginal) → Prepare → Retrieve → Analyse (the +retrieval's panels, dispersion tuning and uncertainty), with interactive plots, +threaded retrieval, a live preview, and any plot or page exported as a vector +figure for publication: ```bash uv run croak-gui # or: uv run python -m croak.gui diff --git a/changelog.d/gui-display-settings.added.md b/changelog.d/gui-display-settings.added.md new file mode 100644 index 0000000..41a51ea --- /dev/null +++ b/changelog.d/gui-display-settings.added.md @@ -0,0 +1 @@ +Two GUI display settings, at the foot of the window, hold for every plot and are remembered between launches: **Spectral axis** draws every spectral plot, maps and curves, against wavelength or frequency, with the density Jacobian, or keeps each on the axis it was computed on; **Log range** sets how far below its peak every logarithmic map reaches (5–120 dB). A map's colour bar can also be dragged to change that map's scale alone. Retrieve's own Spectrum axis control and Check's marginal axis control give way to them. `croak.plotting.display` does the conversion from a script. diff --git a/changelog.d/gui-figure-export.added.md b/changelog.d/gui-figure-export.added.md new file mode 100644 index 0000000..a3c16c0 --- /dev/null +++ b/changelog.d/gui-figure-export.added.md @@ -0,0 +1 @@ +Every plot and every page of the GUI can be saved as a figure for publication: a plot from its right-click menu (which can also save or copy it as an image), a page with "Export this page as a figure…" (Ctrl+E). Figures are drawn by matplotlib from the data on screen, at a single journal column per plot, as PDF, SVG or PNG; in a vector file the text stays editable and colour maps are rasterized. `croak.plotting.export` draws the same views from a script. diff --git a/changelog.d/gui-matplotlib-live-preview.removed.md b/changelog.d/gui-matplotlib-live-preview.removed.md new file mode 100644 index 0000000..3693026 --- /dev/null +++ b/changelog.d/gui-matplotlib-live-preview.removed.md @@ -0,0 +1 @@ +The matplotlib helpers that only the old GUI's live preview used are gone: `croak.plotting.RetrievalPageView`, `FrogFilterView`, the `update_*` panel functions (and `update_convergence_curve`), `PlotScale`, `plot_scale`, `scaled_rc` and their constants, and `PanelSpec.update`. The GUI draws on screen with pyqtgraph from the new `croak.plotting.screen` view data; the `draw_*` panels, `plot_retrieval`, `plot_retrieval_page`, `plot_frog_filter` and the other figures are unchanged. diff --git a/changelog.d/gui-one-save.changed.md b/changelog.d/gui-one-save.changed.md new file mode 100644 index 0000000..e143161 --- /dev/null +++ b/changelog.d/gui-one-save.changed.md @@ -0,0 +1 @@ +The GUI has one way to save: **Save session…** now also writes what Analyse holds, the dispersed pulse (`result_dispersion.h5`, `dispersion.pdf`) when a dispersion is set and the uncertainty figure (`uncertainty.pdf`) when an estimate is on screen, and the Dispersion and Uncertainty pages lose their own Save… buttons. Prepare shows five numbers over its maps: the grid's size, time step, time window, wavelength span and the share of the trace its edge taper removes. diff --git a/changelog.d/gui-prepare-taper-advice.fixed.md b/changelog.d/gui-prepare-taper-advice.fixed.md new file mode 100644 index 0000000..3435070 --- /dev/null +++ b/changelog.d/gui-prepare-taper-advice.fixed.md @@ -0,0 +1 @@ +The GUI now says when the retrieval grid's λ range cuts off signal that is still in the trace: Prepare shows the share of the trace the edge taper removes and how to widen the range. The warning used to reach only the terminal. diff --git a/changelog.d/gui-preview-help.fixed.md b/changelog.d/gui-preview-help.fixed.md new file mode 100644 index 0000000..116d28a --- /dev/null +++ b/changelog.d/gui-preview-help.fixed.md @@ -0,0 +1 @@ +The help for Retrieve's "Live full-plot preview" gives its real rate: up to four redraws a second, fewer when drawing is slow (it said about once a second). diff --git a/changelog.d/gui-pyside6.changed.md b/changelog.d/gui-pyside6.changed.md new file mode 100644 index 0000000..99908b5 --- /dev/null +++ b/changelog.d/gui-pyside6.changed.md @@ -0,0 +1 @@ +The GUI runs on PySide6 instead of PyQt6, and the `gui` extra now installs `pyside6-essentials`, `pyqtgraph` and `superqt`. Code that embeds croak's GUI widgets in its own Qt application must create a PySide6 `QApplication`. diff --git a/changelog.d/gui-retrieve-notices.changed.md b/changelog.d/gui-retrieve-notices.changed.md new file mode 100644 index 0000000..10c3d57 --- /dev/null +++ b/changelog.d/gui-retrieve-notices.changed.md @@ -0,0 +1 @@ +Retrieve no longer has a ready line under its form: the numbers beneath its plots say how a run goes, its other messages are notices at the foot of the window, and a failed retrieval shows in the advice box over the plots. A slider's value box is now as wide as the numbers it takes, so the slider has the rest of the row, and a range has its two ends either side of one slider (TOD on the synthetic pulse page, among others, had a box wider than its slider). diff --git a/changelog.d/gui-retrieve-strip.added.md b/changelog.d/gui-retrieve-strip.added.md new file mode 100644 index 0000000..b1cc870 --- /dev/null +++ b/changelog.d/gui-retrieve-strip.added.md @@ -0,0 +1 @@ +On Retrieve, once a retrieval is done, the top bar's primary action is **Analyse**, with **Retrieve again** beside it. Beneath the plots the run's numbers (iteration, error, elapsed time, the retrieved peak wavelength and FWHM, the forward-model extras and the truth errors) are shown large, beside the convergence curve, which follows the whole run; the pulse-parameter table is gone. Home's tiles carry drawings, and **Open a session** is a tile of its own. diff --git a/changelog.d/gui-signed-map-noise.fixed.md b/changelog.d/gui-signed-map-noise.fixed.md new file mode 100644 index 0000000..d36c5d4 --- /dev/null +++ b/changelog.d/gui-signed-map-noise.fixed.md @@ -0,0 +1 @@ +A signed linear map in the GUI no longer paints its whole zero background red when its only negative values are rounding noise (Prepare's filtered and regridded traces), and the synthetic generator's spectrum preview is now a wavelength density, as its spectrogram already was. Log axes are labelled at decades only, so a short convergence plot no longer piles its labels up. diff --git a/changelog.d/gui-synthetic-preview.fixed.md b/changelog.d/gui-synthetic-preview.fixed.md new file mode 100644 index 0000000..2c3a950 --- /dev/null +++ b/changelog.d/gui-synthetic-preview.fixed.md @@ -0,0 +1 @@ +The synthetic generator's trace preview shows the trace that Continue hands on, sampled onto the λ window; it used to show a featureless stripe. A flat phase no longer fills its axis with rounding noise: phase axes span at least ±1 rad, in the GUI and in croak's figures. diff --git a/changelog.d/gui-workflow-window.breaking.md b/changelog.d/gui-workflow-window.breaking.md new file mode 100644 index 0000000..8788ad2 --- /dev/null +++ b/changelog.d/gui-workflow-window.breaking.md @@ -0,0 +1 @@ +The GUI is redesigned as one workflow window in place of the six-step wizard: Home (start from a measured trace, a simulated scan or a synthetic pulse; open a session; recent sessions), then Data (load the trace), Check (its marginal), Prepare, Retrieve and Analyse (Overview, Traces, Pulse, Diagnostics, Dispersion and Uncertainty pages), with a Home button and one primary action in the top bar, an About page (version, links to the manual, GitHub and PyPI, authors and copyright), and the session's settings or a replay script on request. Plots are interactive pyqtgraph views with hover readouts, zoom and figure export through matplotlib; forms fold by section and say what the focused setting does. `croak.gui.run_wizard` is now `croak.gui.run_gui` and `croak.gui.Wizard` is now `croak.gui.MainWindow`; the `croak-gui` command is unchanged. diff --git a/croak/gui/__init__.py b/croak/gui/__init__.py index 8efab18..0cc67d1 100644 --- a/croak/gui/__init__.py +++ b/croak/gui/__init__.py @@ -1,11 +1,11 @@ -"""PyQt6 Qt-Widgets wizard GUI for croak (matplotlib plots). +"""croak's desktop GUI (PySide6, pyqtgraph plots). -The GUI is a thin orchestration layer over the croak library: it loads, cleans, -retrieves, post-processes, tunes dispersion, plots and saves — all via the -library functions and the shared :mod:`croak.plotting` code. +The GUI is a thin layer over the croak library: it loads, cleans, retrieves, +post-processes, tunes dispersion, estimates uncertainty, plots and saves — all +through the library's functions and the Qt-free :mod:`croak.session`. Its window +takes a session from Home through Data, Check, Prepare, Retrieve and Analyse. -Launch with the ``croak-gui`` console script, :func:`run_wizard`, or -``python -m croak.gui``. +Launch with the ``croak-gui`` command, :func:`run_gui`, or ``python -m croak.gui``. """ from __future__ import annotations @@ -17,16 +17,16 @@ # Make the lazy __getattr__ re-exports visible to the type checker without # importing Qt at module import time (the runtime path is __getattr__ below). from .state import WizardState - from .wizard import Wizard + from .window import MainWindow -__all__ = ["main", "run_wizard", "Wizard", "WizardState"] +__all__ = ["MainWindow", "WizardState", "main", "run_gui"] -def run_wizard(argv=None) -> int: - """Create a ``QApplication`` (if needed) and run the wizard event loop. +def run_gui(argv=None) -> int: + """Create a ``QApplication`` (if needed) and run the window's event loop. - The window opens maximised — the plot pages want the room. Un-maximising - gives the size the user last left it (remembered between launches by + The window opens maximised — the plots want the room. Un-maximising gives + the size the user last left it (remembered between launches by :mod:`croak.gui.settings`, clamped to the current screen), or on a first run a default fitted to 90 % of the screen. @@ -37,36 +37,36 @@ def run_wizard(argv=None) -> int: through untouched, so Qt's own flags (``-style``, ``-platform``, …) still work. """ try: - from PyQt6.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication except ImportError as exc: # the gui extra is optional — say how to get it raise ImportError( "croak's GUI needs the optional Qt dependencies, which are not " "installed. Install them with `pip install 'croak[gui]'` (or, in " "a uv checkout, `uv sync` — the dev group carries them). " - "Everything except the wizard runs without Qt." + "Everything except the GUI runs without Qt." ) from exc from .branding import APP_NAME, apply_identity - from .wizard import Wizard + from .window import MainWindow args = list(argv if argv is not None else sys.argv) app = QApplication.instance() or QApplication([APP_NAME, *args[1:]]) apply_identity() - window = Wizard() + window = MainWindow() window.showMaximized() return app.exec() def main() -> int: - """Console-script entry point (``croak-gui``); runs the wizard.""" - return run_wizard() + """Console-script entry point (``croak-gui``); runs the GUI.""" + return run_gui() def __getattr__(name): # lazy re-exports (avoid importing Qt at module import) - if name == "Wizard": - from .wizard import Wizard + if name == "MainWindow": + from .window import MainWindow - return Wizard + return MainWindow if name == "WizardState": from .state import WizardState diff --git a/croak/gui/__main__.py b/croak/gui/__main__.py index a232cdb..d85a144 100644 --- a/croak/gui/__main__.py +++ b/croak/gui/__main__.py @@ -1,10 +1,10 @@ -"""Run the wizard with ``python -m croak.gui``.""" +"""Run the GUI with ``python -m croak.gui``.""" from __future__ import annotations import sys -from . import run_wizard +from . import run_gui if __name__ == "__main__": - sys.exit(run_wizard()) + sys.exit(run_gui()) diff --git a/croak/gui/about.py b/croak/gui/about.py new file mode 100644 index 0000000..9d0d500 --- /dev/null +++ b/croak/gui/about.py @@ -0,0 +1,105 @@ +"""About: what croak is, where it lives, who wrote it and under what licence. + +Opened from Help ▸ About croak, the ⋯ menu and Home. The version, summary, +authors and links come from the installed package's metadata, so they cannot +drift from ``pyproject.toml``; only the copyright line and the PyPI address, +which the metadata does not carry, are written here. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from importlib import metadata + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + +from .theme import PALETTE, label + +__all__ = ["COPYRIGHT", "PYPI_URL", "About", "project_links"] + +COPYRIGHT = "Copyright © 2026 John C. Travers and contributors" +"""The copyright line, as in the ``LICENSE`` file.""" + +PYPI_URL = "https://pypi.org/project/croak/" +"""The package's page on PyPI.""" + +ABOUT_WIDTH = 640 +"""The About text's measure, logical px: a comfortable line to read.""" + +LICENCE_NOTE = ( + "croak is free software under the MIT licence. Its window is set in IBM " + "Plex Sans, under the SIL Open Font License 1.1." +) + + +def project_links(entries: Sequence[str] | None = None) -> dict[str, str]: + """Return the project's links by name: the manual, GitHub, PyPI and issues. + + Parameters + ---------- + entries : sequence of str, optional + ``Project-URL`` metadata entries, each ``"Name, url"``; the installed + croak's by default. A link the entries lack is left out (PyPI's is + always there). + + Examples + -------- + >>> project_links(["Documentation, https://croak.readthedocs.io"]) + {'Manual': 'https://croak.readthedocs.io', 'Package on PyPI': 'https://pypi.org/project/croak/'} + """ # noqa: E501 — a doctest's output must stay on one line + if entries is None: + entries = metadata.metadata("croak").get_all("Project-URL") or [] + urls = dict(entry.split(", ", 1) for entry in entries) + links = { + "Manual": urls.get("Documentation", ""), + "Source on GitHub": urls.get("Repository", ""), + "Package on PyPI": PYPI_URL, + "Report an issue": urls.get("Issues", ""), + } + return {name: url for name, url in links.items() if url} + + +def _link(text: str, url: str) -> QLabel: + # Qt draws links in the system's blue; the palette's accent matches the window. + style = f"color: {PALETTE.accent}; text-decoration: none;" + widget = label(f'{text}', "aboutLink") + widget.setTextFormat(Qt.TextFormat.RichText) + widget.setOpenExternalLinks(True) + widget.setToolTip(url) + return widget + + +class About(QWidget): + """The About page.""" + + def __init__(self) -> None: + super().__init__() + self.setObjectName("page") + meta = metadata.metadata("croak") + outer = QVBoxLayout(self) + outer.setContentsMargins(40, 36, 40, 24) + body = QWidget() + body.setMaximumWidth(ABOUT_WIDTH) + outer.addWidget(body) + outer.addStretch(1) + column = QVBoxLayout(body) + column.setContentsMargins(0, 0, 0, 0) + column.setSpacing(10) + column.addWidget(label("croak", "h1")) + self.version = label(f"Version {meta['Version']}", "note") + column.addWidget(self.version) + column.addWidget(label(meta["Summary"], "", wrap=True)) + column.addSpacing(8) + column.addWidget(label("Links", "h3")) + self.links = project_links() + for name, url in self.links.items(): + column.addWidget(_link(name, url)) + column.addSpacing(8) + column.addWidget(label("Authors", "h3")) + authors = meta["Author"] or "" + self.authors = label(authors, "", wrap=True) + column.addWidget(self.authors) + column.addSpacing(8) + column.addWidget(label(COPYRIGHT, "note")) + column.addWidget(label(LICENCE_NOTE, "note", wrap=True)) diff --git a/croak/gui/assets/chevron-right.svg b/croak/gui/assets/chevron-right.svg new file mode 100644 index 0000000..3c4d81a --- /dev/null +++ b/croak/gui/assets/chevron-right.svg @@ -0,0 +1 @@ + diff --git a/croak/gui/assets/chevron.svg b/croak/gui/assets/chevron.svg new file mode 100644 index 0000000..8413b9f --- /dev/null +++ b/croak/gui/assets/chevron.svg @@ -0,0 +1 @@ + diff --git a/croak/gui/assets/fonts/IBMPlexSans-Medium.ttf b/croak/gui/assets/fonts/IBMPlexSans-Medium.ttf new file mode 100644 index 0000000..a3826c4 Binary files /dev/null and b/croak/gui/assets/fonts/IBMPlexSans-Medium.ttf differ diff --git a/croak/gui/assets/fonts/IBMPlexSans-Regular.ttf b/croak/gui/assets/fonts/IBMPlexSans-Regular.ttf new file mode 100644 index 0000000..bd6817d Binary files /dev/null and b/croak/gui/assets/fonts/IBMPlexSans-Regular.ttf differ diff --git a/croak/gui/assets/fonts/IBMPlexSans-SemiBold.ttf b/croak/gui/assets/fonts/IBMPlexSans-SemiBold.ttf new file mode 100644 index 0000000..09ec8cd Binary files /dev/null and b/croak/gui/assets/fonts/IBMPlexSans-SemiBold.ttf differ diff --git a/croak/gui/assets/fonts/LICENSE.txt b/croak/gui/assets/fonts/LICENSE.txt new file mode 100644 index 0000000..01497cc --- /dev/null +++ b/croak/gui/assets/fonts/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/croak/gui/assets/home.svg b/croak/gui/assets/home.svg new file mode 100644 index 0000000..4105606 --- /dev/null +++ b/croak/gui/assets/home.svg @@ -0,0 +1 @@ + diff --git a/croak/gui/assets/tiles/experimental.svg b/croak/gui/assets/tiles/experimental.svg new file mode 100644 index 0000000..42420cb --- /dev/null +++ b/croak/gui/assets/tiles/experimental.svg @@ -0,0 +1 @@ + diff --git a/croak/gui/assets/tiles/open.svg b/croak/gui/assets/tiles/open.svg new file mode 100644 index 0000000..5a31e49 --- /dev/null +++ b/croak/gui/assets/tiles/open.svg @@ -0,0 +1 @@ + diff --git a/croak/gui/assets/tiles/simulated.svg b/croak/gui/assets/tiles/simulated.svg new file mode 100644 index 0000000..25f9d1c --- /dev/null +++ b/croak/gui/assets/tiles/simulated.svg @@ -0,0 +1 @@ + diff --git a/croak/gui/assets/tiles/synthetic.svg b/croak/gui/assets/tiles/synthetic.svg new file mode 100644 index 0000000..78dd950 --- /dev/null +++ b/croak/gui/assets/tiles/synthetic.svg @@ -0,0 +1 @@ + diff --git a/croak/gui/base.py b/croak/gui/base.py index 6ea58b8..532b622 100644 --- a/croak/gui/base.py +++ b/croak/gui/base.py @@ -1,161 +1,85 @@ -"""Shared base for wizard stage pages (left controls + right plots).""" +"""The stage pages' wizard-era interface, on the workflow window's layout. -from __future__ import annotations +:class:`Stage` is a :class:`~croak.gui.stage.StagePage` (form of folding +sections beside the plots) that keeps the calls the stage modules were written +against: ``controls`` to add sections to, :meth:`set_plot_area`, +:meth:`set_status` and the entry pages' :meth:`advance`. +""" -from PyQt6.QtCore import Qt, QTimer -from PyQt6.QtGui import QShowEvent -from PyQt6.QtWidgets import ( - QHBoxLayout, - QLabel, - QScrollArea, - QSplitter, - QVBoxLayout, - QWidget, -) - -from . import settings -from .help import HelpDialog, HelpSections, apply_tooltips, flatten -from .state import WizardState +from __future__ import annotations -__all__ = ["Stage", "CONTROLS_MIN_WIDTH"] +from PySide6.QtCore import Signal, SignalInstance +from PySide6.QtWidgets import QWidget -#: Narrowest the control column can be dragged (logical px) before it snaps shut. -#: Wide enough for a label, a spin box and its unit on one form row. -CONTROLS_MIN_WIDTH = 240 -#: Debounce (ms) between a divider drag and remembering the new width. -_SAVE_WIDTH_DELAY_MS = 250 +from .help import apply_tooltips, flatten +from .stage import StagePage +from .state import WizardState +__all__ = ["RunControls", "Stage"] -class Stage(QWidget): - """A wizard page: a scrollable control column on the left, plots on the right. - The two sit on a :class:`~PyQt6.QtWidgets.QSplitter`, so on a small display the - control column can be narrowed or dragged fully closed to give the plots the - room; its width is shared by every stage and remembered between launches - (:mod:`croak.gui.settings`). The plot side never collapses. +class Stage(StagePage): + """A stage page: build into :attr:`controls`, then :meth:`set_plot_area`. - Subclasses build their controls into ``self.controls`` (a vertical layout) - and hand their plot widget to :meth:`set_plot_area`, then implement - :meth:`on_enter` to refresh when the page becomes visible. + Subclasses add their sections (:func:`croak.gui.controls.group`) to + ``self.controls``, hand their plots to :meth:`set_plot_area`, call + :meth:`apply_help` at the end of ``__init__`` and implement :meth:`on_enter`. - Subclasses may also set :attr:`HELP_TITLE`, :attr:`HELP_INTRO` and - :attr:`HELP` (see :mod:`croak.gui.help`) and call :meth:`apply_help` at the - end of ``__init__`` to attach option tooltips; :meth:`show_help` then opens - a summary dialog of the same content (wired to the wizard header's ``?``). + Attributes + ---------- + advanced : Signal(str) + An entry page finished its forward action and says where the session + goes next: ``"check"`` (the marginal check) or ``"retrieve"`` (a raw + simulated scan, straight to Retrieve). """ - #: Short stage name shown in the help dialog title (e.g. ``"Retrieve"``). - HELP_TITLE: str = "" - #: One-paragraph overview shown at the top of the help dialog. - HELP_INTRO: str = "" - #: Per-option help; ``[(section, [(label, text), ...]), ...]``. - HELP: HelpSections = [] - - def __init__(self, state: WizardState): - super().__init__() - self.state = state - self._help_dialog: HelpDialog | None = None - - outer = QHBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - self.splitter = QSplitter(Qt.Orientation.Horizontal) - # left: scrollable controls - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setMinimumWidth(CONTROLS_MIN_WIDTH) - panel = QWidget() - self.controls = QVBoxLayout(panel) - scroll.setWidget(panel) - # right: plot area (filled by the subclass via set_plot_area) - self._right = QWidget() - self._right_layout = QVBoxLayout(self._right) - self._right_layout.setContentsMargins(0, 0, 0, 0) - self.splitter.addWidget(scroll) - self.splitter.addWidget(self._right) - # Dragging the divider fully left closes the controls; the plots never - # close. The stretch factors are load-bearing: without them QSplitter hands - # the spare width to the *left* pane ([1910, 6] at 1920 px) and squeezes - # the controls to their minimum whenever the window shrinks. - self.splitter.setCollapsible(0, True) - self.splitter.setCollapsible(1, False) - self.splitter.setStretchFactor(0, 0) - self.splitter.setStretchFactor(1, 1) - self.splitter.setSizes([settings.controls_width(), 1]) - # A drag fires splitterMoved continuously; remember the width once it rests. - self._save_width_timer = QTimer(self) - self._save_width_timer.setSingleShot(True) - self._save_width_timer.setInterval(_SAVE_WIDTH_DELAY_MS) - self._save_width_timer.timeout.connect(self._save_controls_width) - self.splitter.splitterMoved.connect(self._on_splitter_moved) - outer.addWidget(self.splitter) - - self.status_label = QLabel("") - self.status_label.setWordWrap(True) + advanced = Signal(str) - def set_plot_area(self, plot: QWidget, *strips: QWidget) -> None: - """Fill the right-hand side: ``plot`` on top, then ``strips`` beneath it. + def __init__(self, state: WizardState, *, find: bool = False): + super().__init__(state, find=find) + #: Where the sections go: the form's column. + self.controls = self.form.content + #: The form's ready line, which :meth:`set_status` writes. + self.status_label = self.form.ready - ``plot`` takes every spare pixel; each strip (a read-out, a fold-away - panel) keeps its natural height. - """ - self._right_layout.addWidget(plot, stretch=1) + def set_plot_area(self, plot: QWidget, *strips: QWidget) -> None: + """Show ``plot`` (taking the room) with ``strips`` beneath it.""" + self.set_plots(plot) for strip in strips: - self._right_layout.addWidget(strip, stretch=0) - - def set_status(self, text: str) -> None: - self.status_label.setText(text) - - def showEvent(self, event: QShowEvent) -> None: # noqa: N802 — Qt override - """Apply the shared control-column width, so a drag on one stage reaches all.""" - super().showEvent(event) - width = settings.controls_width() - if self.splitter.sizes()[0] != width: - self.splitter.setSizes([width, 1]) + self.body.addWidget(strip, stretch=0) - def _on_splitter_moved(self, _pos: int, _index: int) -> None: - self._save_width_timer.start() - - def _save_controls_width(self) -> None: - settings.set_controls_width(self.splitter.sizes()[0]) + def set_status(self, text: str, state: str = "ok") -> None: + """Show ``text`` on the ready line, as ``"ok"``, ``"warn"`` or ``"error"``.""" + self.status_label.say(text, state) def apply_help(self) -> set[tuple[str, str]]: - """Attach :attr:`HELP` text as tooltips on the matching controls. + """Attach :attr:`HELP` as tooltips; return the ``(section, label)`` matched. - Call once at the end of the subclass ``__init__`` (after the controls - exist). Returns the matched ``(section, label)`` keys. + Call once at the end of the subclass ``__init__``, after the controls exist. """ return apply_tooltips(self, flatten(self.HELP)) - def show_help(self) -> HelpDialog | None: - """Open (or re-raise) the modeless summary dialog for this stage.""" - if not self.HELP: - return None - if self._help_dialog is None: - self._help_dialog = HelpDialog( - self.HELP_TITLE, self.HELP_INTRO, self.HELP, self - ) - self._help_dialog.show() - self._help_dialog.raise_() - self._help_dialog.activateWindow() - return self._help_dialog - - def on_enter(self) -> None: - """Called when this stage becomes the visible page (override).""" - - # -- entry-page hooks --------------------------------------------------- - # The *entry pages* (the synthetic generator and the simulated-scan loader) - # are reached from the welcome menu rather than by walking the linear stage - # sequence, so the wizard cannot decide from ``state.stage`` what its footer's - # "Next" button should say or do. They override these three hooks; the linear - # stages never use them (their Next is driven by ``WizardState.ready``). - - def next_label(self) -> str: - """Text for the wizard footer's forward button on this page.""" - return "Next →" - - def can_advance(self) -> bool: - """Whether the forward button should be enabled on this page.""" - return True - def advance(self) -> None: - """Do this page's forward action (generate/load, then move on).""" + """Do an entry page's forward action (generate or load), then emit + :attr:`advanced`; the Data page calls it on Continue.""" + + +class RunControls: + """Running state for a page that runs a worker, driven from the top bar. + + The window's primary action starts the work and becomes Stop while it runs; + :meth:`set_running` records the state and tells the window (``changed``). + Mix in before :class:`Stage`. + """ + + _running: bool = False + changed: SignalInstance + + def set_running(self, running: bool) -> None: + """Show the work as running (the primary action is Stop) or idle.""" + self._running = running + self.changed.emit() + + def busy(self) -> bool: + """Whether the work is running.""" + return self._running diff --git a/croak/gui/branding.py b/croak/gui/branding.py index c674ca7..1740dac 100644 --- a/croak/gui/branding.py +++ b/croak/gui/branding.py @@ -1,11 +1,11 @@ -"""Application icon and desktop identity for the wizard. +"""Application icon and desktop identity for the GUI. Keeps the window-system-facing identity — the name the desktop shows, the icon in the macOS Dock / Windows taskbar / Linux dock — in one place, out of -:func:`~croak.gui.run_wizard`. +:func:`~croak.gui.run_gui`. This module imports Qt, so it must only be imported from *inside* the functions -that already need Qt: importing :mod:`croak.gui` deliberately does not pull PyQt6 +that already need Qt: importing :mod:`croak.gui` deliberately does not pull PySide6 in, so the library stays usable headless. """ @@ -14,8 +14,8 @@ import sys from pathlib import Path -from PyQt6.QtGui import QIcon -from PyQt6.QtWidgets import QApplication +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication __all__ = ["APP_NAME", "ORGANISATION_NAME", "ICON_PATH", "app_icon", "apply_identity"] @@ -46,7 +46,7 @@ def app_icon() -> QIcon: Returns ------- - PyQt6.QtGui.QIcon + PySide6.QtGui.QIcon The icon, carrying a pixmap at each of :data:`_ICON_SIZES`. Null only if the SVG is missing or Qt's SVG image plugin is unavailable. """ @@ -68,7 +68,7 @@ def apply_identity() -> None: but a ``QApplication`` must already exist. Note the macOS *menu bar* title is not set from here — Qt takes it from - ``argv[0]``, which :func:`~croak.gui.run_wizard` overrides when it constructs + ``argv[0]``, which :func:`~croak.gui.run_gui` overrides when it constructs the ``QApplication``. """ from .. import __version__ diff --git a/croak/gui/canvas.py b/croak/gui/canvas.py deleted file mode 100644 index 1793dbd..0000000 --- a/croak/gui/canvas.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Matplotlib canvas embedded in Qt, plus small widget helpers.""" - -from __future__ import annotations - -from collections.abc import Callable -from pathlib import Path - -import matplotlib -from matplotlib.backends.backend_qt import NavigationToolbar2QT -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg -from matplotlib.figure import Figure -from PyQt6.QtCore import QTimer -from PyQt6.QtGui import QResizeEvent -from PyQt6.QtWidgets import ( - QCheckBox, - QComboBox, - QDoubleSpinBox, - QFormLayout, - QGroupBox, - QSpinBox, - QVBoxLayout, - QWidget, -) -from superqt import QCollapsible - -from ..plotting import FULL_SCALE, PlotScale, plot_scale, scaled_rc -from . import settings - -__all__ = [ - "MplCanvas", - "with_toolbar", - "collapsible", - "group", - "add_wide_row", - "spin", - "dspin", - "combo", - "check", -] - - -class MplCanvas(FigureCanvasQTAgg): - """A matplotlib canvas whose text density follows its on-screen size. - - ``figsize`` is the size in inches the figure's layout was *designed* for: the - canvas prefers it, but Qt resizes the figure freely to fill its slot. Text is - sized in points, so in a small window the axes would shrink while the text did - not (on a 1366x768 laptop the twelve-panel retrieval figure spent 78 % of its - area on text and padding). :meth:`render` therefore plots inside a - :func:`matplotlib.rc_context` scaled by :func:`croak.plotting.plot_scale` - (down to 70 % of the default sizes), and a resize that crosses a density step - re-runs the remembered plot function after a short debounce. - - Parameters - ---------- - figsize : tuple of float - Design size ``(width, height)`` in inches; also the initial figure size. - """ - - #: Debounce (ms) between a resize and the re-plot: a window drag fires many - #: resize events, and one redraw once it settles is enough. - REPLOT_DELAY_MS = 150 - - def __init__(self, figsize: tuple[float, float] = (5.0, 4.0)): - self.design_size: tuple[float, float] = figsize - figure = Figure(figsize=figsize, layout="constrained") - super().__init__(figure) - self._plot_fn: Callable[[Figure], object] | None = None - self._drawn_scale: PlotScale | None = None - self._replot_timer = QTimer(self) - self._replot_timer.setSingleShot(True) - self._replot_timer.setInterval(self.REPLOT_DELAY_MS) - self._replot_timer.timeout.connect(self._replot) - - @property - def rendered(self) -> bool: - """Whether :meth:`render` has been called (so there is a plot to export).""" - return self._plot_fn is not None - - @property - def scale(self) -> PlotScale: - """Text density for the canvas's current on-screen size.""" - width, height = self.figure.get_size_inches() - return plot_scale(float(width), float(height), self.design_size) - - def render(self, plot_fn: Callable[[Figure], object]) -> None: - """Clear the figure, run ``plot_fn(figure)`` at the current density, redraw. - - ``plot_fn`` is remembered and re-run whenever a resize changes the density, - so it must read current state rather than values that go stale, and it - must tolerate being called again. Matplotlib resolves font sizes when an - artist is *created*, so everything that creates text has to happen inside - ``plot_fn``; call :meth:`draw_idle` directly only for in-place data updates - that create no new artists (see :meth:`croak.plotting.FrogFilterView.update`). - - Parameters - ---------- - plot_fn : callable - Receives the cleared :class:`~matplotlib.figure.Figure` and draws into - it. Its return value is ignored. - """ - self._plot_fn = plot_fn - self._draw_at(self.scale) - - def clear(self) -> None: - """Blank the figure and forget the plot function (nothing to re-plot).""" - self._plot_fn = None - self._drawn_scale = None - self.figure.clear() - self.draw_idle() - - def draw(self) -> None: - """Render while keeping the owning Qt window alive.""" - # A queued draw retains the Python canvas but not its owning window. - # If cyclic collection destroys that window during rendering, Qt also - # deletes the canvas before matplotlib updates it/restores its cursor. - # Retain the owner only for this call, without creating a permanent cycle. - _window = self.window() - super().draw() - - def save_figure(self, path: str | Path, *, dpi: int) -> None: - """Save the remembered plot at the design size and full text density. - - The on-screen figure may be small and drawn with shrunken text; the export - is drawn afresh into a headless figure of :attr:`design_size` inches at - matplotlib's default sizes, so a PDF saved from a laptop window is the same - file a large display would produce. - - Parameters - ---------- - path : str or Path - Output file; the format follows the extension (``.pdf``, ``.png``, …). - dpi : int - Raster resolution for image content (the pcolormesh panels are - rasterised even inside a PDF). - - Raises - ------ - RuntimeError - If nothing has been rendered on this canvas yet. - """ - if self._plot_fn is None: - raise RuntimeError("nothing has been rendered on this canvas yet") - figure = Figure(figsize=self.design_size, layout="constrained") - with matplotlib.rc_context(scaled_rc(FULL_SCALE)): - self._plot_fn(figure) - figure.savefig(path, dpi=dpi) - - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 — Qt override - """Let matplotlib resize the figure, then schedule a re-plot if needed.""" - super().resizeEvent(event) - if self._plot_fn is not None and self.scale != self._drawn_scale: - self._replot_timer.start() - - def _draw_at(self, scale: PlotScale) -> None: - """Run the remembered plot function at ``scale`` and repaint.""" - if self._plot_fn is None: - raise RuntimeError("no plot function to draw; call render() first") - self._drawn_scale = scale - with matplotlib.rc_context(scaled_rc(scale)): - self.figure.clear() - self._plot_fn(self.figure) - self.draw_idle() - - def _replot(self) -> None: - """Debounced resize handler: re-plot only if the density really changed.""" - if self._plot_fn is not None and self.scale != self._drawn_scale: - self._draw_at(self.scale) - - -def with_toolbar(canvas: MplCanvas) -> QWidget: - """Wrap ``canvas`` in a panel with a matplotlib pan/zoom/save toolbar on top. - - Returns a container widget to drop into a layout; the caller keeps its own - reference to ``canvas`` for plotting. The toolbar adds the standard - Home/Back/Forward, Pan, Zoom-to-rectangle, Subplots, and Save controls, plus a - live cursor read-out. A full redraw (:meth:`MplCanvas.render`) resets the - view, so zoom is most useful between control changes; Home restores it. - """ - panel = QWidget() - layout = QVBoxLayout(panel) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(NavigationToolbar2QT(canvas, panel)) - layout.addWidget(canvas) - return panel - - -def collapsible( - title: str, - content: QWidget, - *, - expanded: bool = True, - settings_key: str | None = None, -) -> QCollapsible: - """Wrap ``content`` in a click-to-fold strip headed ``title``. - - For auxiliary widgets that sit under a plot (a numeric read-out, say) and - have a fixed natural height: folding one hands its pixels back to the plot. - Not for canvases, which should stretch — put those on a splitter instead. - - Parameters - ---------- - title : str - Header text; clicking it toggles the strip. - content : QWidget - The widget to fold. - expanded : bool - Initial state when nothing is remembered. - settings_key : str, optional - With a key, the state is restored from :mod:`croak.gui.settings` and - every toggle (by click or by :meth:`~superqt.QCollapsible.collapse` / - :meth:`~superqt.QCollapsible.expand`) is remembered under it. - - Returns - ------- - superqt.QCollapsible - """ - strip = QCollapsible(title) - strip.setContent(content) - strip.setDuration(120) # visible but brief - if settings_key is not None: - expanded = settings.value(settings_key, expanded) - if expanded: - strip.expand(animate=False) - else: - strip.collapse(animate=False) - if settings_key is not None: - key = settings_key - strip.toggled.connect(lambda state: settings.set_value(key, state)) - return strip - - -# -- tiny declarative widget builders --------------------------------------- -def group(title: str) -> tuple[QGroupBox, QFormLayout]: - """A titled group box with a form layout. - - The field-growth policy is set explicitly because ``QFormLayout``'s default is - style-dependent: the macOS style resolves it to ``FieldsStayAtSizeHint``, which - sizes each field to its *sizeHint* and so collapses the slider controls to the - width of their spin boxes. ``ExpandingFieldsGrow`` widens only the fields that - actually ask for the space (the ``Expanding`` size policy of - :class:`~croak.gui.widgets.RangeControl`/:class:`~croak.gui.widgets.ValueControl` - and of ``QLineEdit``), leaving spin boxes, combos and check boxes at their - natural size. - """ - box = QGroupBox(title) - form = QFormLayout(box) - form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) - return box, form - - -def add_wide_row(form: QFormLayout, label: str, widget: QWidget) -> None: - """Add a form row laid out label-above-field, so the field spans the column. - - Used for the slider controls (:class:`~croak.gui.widgets.RangeControl` / - :class:`~croak.gui.widgets.ValueControl`), which are unusable when squeezed into - the narrow right-hand field column. ``WrapAllRows`` is a *form*-wide policy, so - adding one slider wraps the rest of its group box too — that is the intended - trade and keeps a group visually uniform. The label stays in the form's label - role (rather than becoming a separate spanning row), which is what - :func:`~croak.gui.help.apply_tooltips` matches help entries against. - """ - form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapAllRows) - form.addRow(label, widget) - - -def spin(lo: int, hi: int, value: int, on_change=None, step: int = 1) -> QSpinBox: - w = QSpinBox() - w.setRange(lo, hi) - w.setSingleStep(step) - w.setValue(int(value)) - if on_change: - w.valueChanged.connect(on_change) - return w - - -def dspin( - lo: float, - hi: float, - value: float, - on_change=None, - decimals: int = 3, - step: float = 1.0, -) -> QDoubleSpinBox: - w = QDoubleSpinBox() - w.setRange(lo, hi) - w.setDecimals(decimals) - w.setSingleStep(step) - w.setValue(float(value)) - if on_change: - w.valueChanged.connect(on_change) - return w - - -def combo(items, value=None, on_change=None) -> QComboBox: - w = QComboBox() - w.addItems([str(i) for i in items]) - if value is not None and str(value) in [str(i) for i in items]: - w.setCurrentText(str(value)) - if on_change: - w.currentTextChanged.connect(on_change) - return w - - -def check(label: str, value: bool, on_change=None) -> QCheckBox: - w = QCheckBox(label) - w.setChecked(bool(value)) - if on_change: - w.toggled.connect(on_change) - return w diff --git a/croak/gui/controls.py b/croak/gui/controls.py new file mode 100644 index 0000000..e83232d --- /dev/null +++ b/croak/gui/controls.py @@ -0,0 +1,161 @@ +"""Small builders for a stage's form controls. + +:func:`group` makes a folding section and the form its rows go in; the others +build a spin box, combo or check box already bound to a callback, and +:func:`collapsible` folds a strip (the retrieval read-out) under the plots. +""" + +from __future__ import annotations + +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QSpinBox, + QWidget, +) +from superqt import QCollapsible + +from . import settings +from .sheet import Section +from .theme import ASSETS + +__all__ = [ + "collapsible", + "group", + "add_wide_row", + "spin", + "dspin", + "combo", + "check", +] + + +def collapsible( + title: str, + content: QWidget, + *, + expanded: bool = True, + settings_key: str | None = None, +) -> QCollapsible: + """Wrap ``content`` in a click-to-fold strip headed ``title``. + + For auxiliary widgets that sit under a plot (a numeric read-out, say) and + have a fixed natural height: folding one hands its pixels back to the plot. + Not for canvases, which should stretch — put those on a splitter instead. + + Parameters + ---------- + title : str + Header text; clicking it toggles the strip. + content : QWidget + The widget to fold. + expanded : bool + Initial state when nothing is remembered. + settings_key : str, optional + With a key, the state is restored from :mod:`croak.gui.settings` and + every toggle (by click or by :meth:`~superqt.QCollapsible.collapse` / + :meth:`~superqt.QCollapsible.expand`) is remembered under it. + + Returns + ------- + superqt.QCollapsible + """ + # Icons, not superqt's default "▼"/"▲" strings: it draws those into a + # pixmap sized by the font's *point* size, which is -1 for a pixel-sized + # font, and Qt then complains that it cannot paint on the empty pixmap. + strip = QCollapsible( + title, + expandedIcon=QIcon(str(ASSETS / "chevron.svg")), + collapsedIcon=QIcon(str(ASSETS / "chevron-right.svg")), + ) + strip.setContent(content) + strip.setDuration(120) # visible but brief + if settings_key is not None: + expanded = settings.value(settings_key, expanded) + if expanded: + strip.expand(animate=False) + else: + strip.collapse(animate=False) + if settings_key is not None: + key = settings_key + strip.toggled.connect(lambda state: settings.set_value(key, state)) + return strip + + +# -- tiny declarative widget builders --------------------------------------- +def group( + title: str, *, folded: bool = False, settings_key: str = "" +) -> tuple[Section, QFormLayout]: + """A folding section and the form layout its rows go in. + + The section is added to a stage's ``controls``; its rows are added to the + form with ``form.addRow(label, widget)``. Help tooltips match rows by + ``(title, label)`` (:func:`croak.gui.help.apply_tooltips`). ``folded`` and + ``settings_key`` are as for :class:`~croak.gui.sheet.Section`. + """ + section = Section(title, folded=folded, settings_key=settings_key) + return section, section.form + + +def add_wide_row(form: QFormLayout, label: str, widget: QWidget) -> None: + """Add a form row laid out label-above-field, so the field spans the column. + + Used for the slider controls (:class:`~croak.gui.widgets.RangeControl` / + :class:`~croak.gui.widgets.ValueControl`), which are unusable when squeezed into + the narrow right-hand field column. ``WrapAllRows`` is a *form*-wide policy, so + adding one slider wraps the rest of its group box too — that is the intended + trade and keeps a group visually uniform. The label stays in the form's label + role (rather than becoming a separate spanning row), which is what + :func:`~croak.gui.help.apply_tooltips` matches help entries against. + """ + form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapAllRows) + form.addRow(label, widget) + + +def spin(lo: int, hi: int, value: int, on_change=None, step: int = 1) -> QSpinBox: + w = QSpinBox() + w.setRange(lo, hi) + w.setSingleStep(step) + w.setValue(int(value)) + if on_change: + w.valueChanged.connect(on_change) + return w + + +def dspin( + lo: float, + hi: float, + value: float, + on_change=None, + decimals: int = 3, + step: float = 1.0, +) -> QDoubleSpinBox: + w = QDoubleSpinBox() + w.setRange(lo, hi) + w.setDecimals(decimals) + w.setSingleStep(step) + w.setValue(float(value)) + if on_change: + w.valueChanged.connect(on_change) + return w + + +def combo(items, value=None, on_change=None) -> QComboBox: + w = QComboBox() + w.addItems([str(i) for i in items]) + if value is not None and str(value) in [str(i) for i in items]: + w.setCurrentText(str(value)) + if on_change: + w.currentTextChanged.connect(on_change) + return w + + +def check(label: str, value: bool, on_change=None) -> QCheckBox: + w = QCheckBox(label) + w.setChecked(bool(value)) + if on_change: + w.toggled.connect(on_change) + return w diff --git a/croak/gui/display.py b/croak/gui/display.py new file mode 100644 index 0000000..bfc7626 --- /dev/null +++ b/croak/gui/display.py @@ -0,0 +1,133 @@ +"""The display settings every plot follows: spectral axis and logarithmic range. + +The setting is the viewer's, not the session's: it is remembered between +launches (:mod:`croak.gui.settings`) and holds on every page until it is changed +or reset. :func:`current` reads it, :func:`set_display` changes it and tells +every plot (:func:`hub`'s ``changed``), and :class:`DisplayBar` is the control +at the foot of the window. The conversion itself is Qt-free: +:func:`croak.plotting.display.displayed`. +""" + +from __future__ import annotations + +from typing import cast + +from PySide6.QtCore import QObject, Signal +from PySide6.QtWidgets import QHBoxLayout, QSpinBox, QWidget + +from ..plotting.display import DB_RANGE_LIMITS, Display +from . import settings +from .sheet import Segments +from .theme import button, label + +__all__ = ["DisplayBar", "DisplayHub", "current", "hub", "set_display"] + +KEY_AXIS = "display/spectral_axis" +KEY_DB_RANGE = "display/db_range" + +AXIS_CHOICES = ( + ("native", "As computed"), + ("wavelength", "Wavelength"), + ("frequency", "Frequency"), +) +"""The spectral axis control's choices and captions.""" + +DB_STEP = 5 +"""The logarithmic range control's step, dB.""" + + +class DisplayHub(QObject): + """Tells every plot that the display settings changed.""" + + changed = Signal() + + +_HUB: list[DisplayHub] = [] + + +def hub() -> DisplayHub: + """Return the one :class:`DisplayHub` (made on first use).""" + if not _HUB: + _HUB.append(DisplayHub()) + return _HUB[0] + + +def current() -> Display: + """Read the display settings in force. + + The defaults if none were kept, or if what was kept is no longer valid. + """ + store = settings.user_settings() + axis = str(store.value(KEY_AXIS, "native", str)) + # QSettings converts what it kept to the type asked for. + db_range = cast(float, store.value(KEY_DB_RANGE, Display().db_range, float)) + try: + return Display(axis, db_range) + except ValueError: + return Display() + + +def set_display(display: Display) -> None: + """Keep ``display`` and redraw every plot with it.""" + store = settings.user_settings() + store.setValue(KEY_AXIS, display.axis) + store.setValue(KEY_DB_RANGE, display.db_range) + store.sync() + hub().changed.emit() + + +class DisplayBar(QWidget): + """The spectral-axis and log-range controls, with a reset.""" + + def __init__(self) -> None: + super().__init__() + shown = current() + row = QHBoxLayout(self) + row.setContentsMargins(0, 0, 8, 0) + row.setSpacing(8) + row.addWidget(label("Spectral axis", "help")) + self.axis = Segments(AXIS_CHOICES, shown.axis) + self.axis.setToolTip( + "Draw every spectral plot against wavelength or frequency, with the " + "density Jacobian; 'As computed' keeps each on its own axis " + "(wavelength for a loaded trace, frequency for a retrieval)." + ) + self.axis.changed.connect(lambda _value: self._apply()) + row.addWidget(self.axis) + row.addWidget(label("Log range", "help")) + self.db_range = QSpinBox() + low, high = DB_RANGE_LIMITS + self.db_range.setRange(int(low), int(high)) + self.db_range.setSingleStep(DB_STEP) + self.db_range.setSuffix(" dB") + self.db_range.setValue(int(shown.db_range)) + self.db_range.setToolTip( + "How far below its peak every logarithmic map reaches. Drag a colour " + "bar to adjust one map on its own." + ) + self.db_range.valueChanged.connect(lambda _value: self._apply()) + row.addWidget(self.db_range) + self.reset = button("Reset", "link") + self.reset.setToolTip("Back to each plot's own axis and a 30 dB range.") + self.reset.clicked.connect(self.restore_defaults) + row.addWidget(self.reset) + hub().changed.connect(self._follow) + + def _follow(self) -> None: + """Show the display in force, however it was set.""" + shown = current() + self.db_range.blockSignals(True) + self.db_range.setValue(int(shown.db_range)) + self.db_range.blockSignals(False) + self.axis.set_value(shown.axis) + + def display(self) -> Display: + """Return the display the controls show.""" + return Display(self.axis.value, float(self.db_range.value())) + + def _apply(self) -> None: + set_display(self.display()) + + def restore_defaults(self) -> None: + """Return to the default display (the controls follow).""" + set_display(Display()) diff --git a/croak/gui/help.py b/croak/gui/help.py index 69794a1..44ecf31 100644 --- a/croak/gui/help.py +++ b/croak/gui/help.py @@ -4,32 +4,32 @@ ``(section, [(label, text)])``. The same ``text`` is shown in two places from a single source: -* the scrollable summary dialog opened by the wizard header's ``?`` button +* the scrollable summary dialog opened by Help for this stage (F1, or the ⋯ menu) (:class:`HelpDialog`), and * the hover tooltip on the matching control, applied by :func:`apply_tooltips`. -Tooltips are matched by the control's ``(group-box title, form label)`` so that +Tooltips are matched by the control's ``(section title, form label)`` so that labels reused across groups (e.g. ``"λ unit"``) stay distinct. Widget labels spell -a literal ampersand ``&&`` (see :func:`_plain`), while :data:`Stage.HELP` keys are -written the way the user reads them, so the escape is undone before matching. +a literal ampersand ``&&`` (see :func:`croak.gui.sheet.plain`), while +:data:`Stage.HELP` keys are written the way the user reads them, so the escape is +undone before matching. """ from __future__ import annotations from html import escape -from PyQt6.QtWidgets import ( - QCheckBox, +from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QFormLayout, - QGroupBox, - QLabel, QTextBrowser, QVBoxLayout, QWidget, ) +from .sheet import Section, row_field, row_text + __all__ = ["HelpDialog", "HelpSections", "apply_tooltips", "build_html", "flatten"] #: A stage's help content: ordered ``(section_title, [(label, text), ...])``. @@ -82,41 +82,12 @@ def __init__( layout.addWidget(buttons) -def _plain(text: str) -> str: - """Undo Qt's mnemonic escaping, giving the label as the user reads it. - - Qt treats a lone ``&`` in a check box, push button, group-box title or form-row - label as a keyboard-mnemonic marker and does not draw it, so a *literal* - ampersand must be written ``&&``. Help keys are written unescaped, so strip the - escape before comparing. - - Examples - -------- - >>> _plain("Skip filtering && regrid") - 'Skip filtering & regrid' - """ - return text.replace("&&", "&") - - -def _field_widget(form: QFormLayout, row: int) -> QWidget | None: - """The widget in a form row, whether it occupies the field or spans both.""" - for role in (QFormLayout.ItemRole.FieldRole, QFormLayout.ItemRole.SpanningRole): - item = form.itemAt(row, role) - if item is not None and item.widget() is not None: - return item.widget() - return None - - -def _row_label(form: QFormLayout, row: int, field: QWidget) -> str | None: - """Label text for a form row: the label column, else a checkbox's own text.""" - item = form.itemAt(row, QFormLayout.ItemRole.LabelRole) - if item is not None: - label = item.widget() - if isinstance(label, QLabel): - return _plain(label.text()) - if isinstance(field, QCheckBox): - return _plain(field.text()) - return None +def _section_title(form: QFormLayout) -> str: + """The title of the section a form row sits in ("" outside any section).""" + widget = form.parentWidget() + while widget is not None and not isinstance(widget, Section): + widget = widget.parentWidget() + return widget.section_title if widget is not None else "" def apply_tooltips( @@ -125,20 +96,17 @@ def apply_tooltips( """Set tooltips on ``root``'s form controls from ``help_map``. Walks every :class:`QFormLayout` under ``root`` and, for each row, looks up - ``(enclosing group-box title, row label)`` in ``help_map``; on a hit the + ``(enclosing section title, row label)`` in ``help_map``; on a hit the field widget's tooltip is set. Returns the set of keys that matched, so callers (and tests) can detect help entries with no corresponding control. """ matched: set[tuple[str, str]] = set() for form in root.findChildren(QFormLayout): - box = form.parentWidget() - section = _plain(box.title()) if isinstance(box, QGroupBox) else "" + section = _section_title(form) for row in range(form.rowCount()): - field = _field_widget(form, row) - if field is None: - continue - label = _row_label(form, row, field) - if label is None: + field = row_field(form, row) + label = row_text(form, row) + if field is None or not label: continue key = (section, label) text = help_map.get(key) diff --git a/croak/gui/home.py b/croak/gui/home.py new file mode 100644 index 0000000..e041264 --- /dev/null +++ b/croak/gui/home.py @@ -0,0 +1,212 @@ +"""Home: what to retrieve, a session to reopen, and the recent sessions. + +Shown when no session is open, and from the brand button in the top bar. Each +tile, with a drawing of its own (``assets/tiles``), starts a new session from one +of the three entries, or opens a saved one; the recent sessions are +the ``options.toml`` files last saved or opened (:mod:`croak.gui.settings`), +each reopened with its data and, where it matches, its saved result. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime +from pathlib import Path + +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QIcon, QMouseEvent +from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from .theme import ASSETS, button, label + +__all__ = ["ENTRY_TILES", "Home"] + +TILE_ICON_SIZE = 44 +"""The size of a tile's drawing, logical px.""" + +OPEN_TILE = ( + "open", + "Open a session", + "Reopen a saved session's options.toml, with its data and, where it still " + "matches, its result.", +) +"""The tile that reopens a saved session: key, title, description.""" + +ENTRY_TILES = ( + ( + "experimental", + "Measured trace", + "Load a measured FROG trace from HDF5 or NPZ, with its independent " + "spectrum, backgrounds and calibration.", + ), + ( + "simulated", + "Simulated scan", + "Load a numerically simulated scan (a Luna scansave file), with its " + "known input pulse and instrument geometry.", + ), + ( + "synthetic", + "Synthetic trace", + "Generate a trace from a pulse you specify, with dispersion and noise, " + "and test a retrieval against the known answer.", + ), +) +"""The three ways to start a session: entry, title, description.""" + + +class _Clickable(QFrame): + """A frame that acts as a button: it lights up under the pointer and clicks.""" + + clicked = Signal() + + def __init__(self, role: str) -> None: + super().__init__() + self.setObjectName(role) + self.setAttribute(Qt.WidgetAttribute.WA_Hover) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802 — Qt override + if event.button() == Qt.MouseButton.LeftButton and self.rect().contains( + event.position().toPoint() + ): + self.clicked.emit() + super().mouseReleaseEvent(event) + + +def _tile(key: str, title: str, text: str) -> _Clickable: + """A tile: its drawing (``assets/tiles/.svg``) beside its title and text.""" + tile = _Clickable("tile") + row = QHBoxLayout(tile) + row.setContentsMargins(16, 14, 16, 14) + row.setSpacing(14) + art = label("", "tileArt") + icon = QIcon(str(ASSETS / "tiles" / f"{key}.svg")) + art.setPixmap(icon.pixmap(QSize(TILE_ICON_SIZE, TILE_ICON_SIZE))) + art.setFixedSize(TILE_ICON_SIZE, TILE_ICON_SIZE) + row.addWidget(art, alignment=Qt.AlignmentFlag.AlignTop) + words = QVBoxLayout() + words.setSpacing(4) + words.addWidget(label(title, "tileTitle")) + words.addWidget(label(text, "tileText", wrap=True)) + words.addStretch(1) # tiles in a row share a height; text stays at the top + row.addLayout(words, stretch=1) + return tile + + +def describe_session(path: Path) -> tuple[str, str]: + """The title and detail line Home shows for a saved session at ``path``. + + The folder names the session (every session file is ``options.toml``); the + detail is the folder's parent and the file's modification time. + """ + when = datetime.fromtimestamp(path.stat().st_mtime).strftime("%d %b %Y, %H:%M") + return path.parent.name, f"{path.parent.parent} · {when}" + + +class Home(QWidget): + """The start page. + + Attributes + ---------- + start : Signal(str) + A tile was chosen: the entry to start a session from. + open_session : Signal(str) + A session to reopen: an ``options.toml`` path, or ``""`` to ask for one. + about : Signal + About croak was asked for. + """ + + start = Signal(str) + open_session = Signal(str) + about = Signal() + + def __init__(self) -> None: + super().__init__() + self.setObjectName("page") + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + content = QWidget() + content.setObjectName("page") + columns = QHBoxLayout(content) + columns.setContentsMargins(40, 36, 40, 24) + columns.setSpacing(40) + + main = QVBoxLayout() + main.setSpacing(14) + main.addWidget(label("What are you retrieving?", "h1")) + grid = QGridLayout() + grid.setSpacing(12) + self.tiles: dict[str, _Clickable] = {} + for index, (key, title, text) in enumerate((*ENTRY_TILES, OPEN_TILE)): + tile = _tile(key, title, text) + if key == OPEN_TILE[0]: + tile.clicked.connect(lambda: self.open_session.emit("")) + else: + tile.clicked.connect(lambda entry=key: self.start.emit(entry)) + grid.addWidget(tile, index // 2, index % 2) + self.tiles[key] = tile + main.addLayout(grid) + actions = QHBoxLayout() + actions.addStretch(1) + self.about_button = button("About croak", "link") + self.about_button.clicked.connect(self.about) + actions.addWidget(self.about_button) + main.addLayout(actions) + main.addStretch(1) + columns.addLayout(main, stretch=3) + + side = QVBoxLayout() + side.setSpacing(6) + side.addWidget(label("Recent sessions", "h3")) + self.recent = QVBoxLayout() + self.recent.setSpacing(2) + side.addLayout(self.recent) + side.addStretch(1) + columns.addLayout(side, stretch=2) + + scroll.setWidget(content) + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(scroll) + self.recent_items: list[_Clickable] = [] + self._recent_widgets: list[QWidget] = [] + self.show_recent([]) + + def show_recent(self, paths: Sequence[str]) -> None: + """List the sessions at ``paths`` that still exist, newest first.""" + for old in self._recent_widgets: + self.recent.removeWidget(old) + # Hidden at once: deleteLater alone leaves it drawn, loose in the + # page, until the event loop next runs. + old.hide() + old.deleteLater() + self._recent_widgets: list[QWidget] = [] + self.recent_items = [] + for raw in paths: + path = Path(raw) + if not path.is_file(): + continue + title, detail = describe_session(path) + item = _Clickable("item") + item.setToolTip(str(path)) + layout = QVBoxLayout(item) + layout.setContentsMargins(10, 7, 10, 7) + layout.setSpacing(2) + layout.addWidget(label(title, "itemTitle")) + layout.addWidget(label(detail, "itemText", wrap=True)) + item.clicked.connect(lambda raw=raw: self.open_session.emit(raw)) + self.recent_items.append(item) + hint = "Sessions you save or open appear here." + shown = self.recent_items or [label(hint, "itemText", wrap=True)] + for widget in shown: + self.recent.addWidget(widget) + self._recent_widgets.append(widget) diff --git a/croak/gui/mirror_stack.py b/croak/gui/mirror_stack.py index 8657ea5..3331b00 100644 --- a/croak/gui/mirror_stack.py +++ b/croak/gui/mirror_stack.py @@ -17,8 +17,8 @@ import contextlib -from PyQt6.QtCore import pyqtSignal -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( QComboBox, QFileDialog, QFormLayout, @@ -32,7 +32,7 @@ from .. import dispersion, io, mirrors from ..session.params import BeamPathMirror -from .canvas import combo, spin +from .controls import combo, spin __all__ = ["MirrorRow", "MirrorStack"] @@ -57,10 +57,10 @@ class MirrorRow(QWidget): removal); chirped compressors and custom files are phase-only. """ - changed = pyqtSignal() - status = pyqtSignal(str) - remove_requested = pyqtSignal(object) - auto_requested = pyqtSignal(object) + changed = Signal() + status = Signal(str) + remove_requested = Signal(object) + auto_requested = Signal(object) def __init__(self, parent: QWidget | None = None): super().__init__(parent) @@ -102,7 +102,7 @@ def __init__(self, parent: QWidget | None = None): outer.addWidget(line) self.range_label = QLabel("–") - self.range_label.setStyleSheet("color: gray;") + self.range_label.setObjectName("help") # secondary text, from the theme outer.addWidget(self.range_label) # custom-file sub-controls, shown only when 'Custom file…' is selected @@ -319,9 +319,9 @@ def cleanup(self) -> None: class MirrorStack(QWidget): """An editable list of :class:`MirrorRow` widgets plus an 'Add' button.""" - changed = pyqtSignal() - status = pyqtSignal(str) - auto_requested = pyqtSignal(object) + changed = Signal() + status = Signal(str) + auto_requested = Signal(object) def __init__(self, parent: QWidget | None = None): super().__init__(parent) diff --git a/croak/gui/pages.py b/croak/gui/pages.py new file mode 100644 index 0000000..129db11 --- /dev/null +++ b/croak/gui/pages.py @@ -0,0 +1,312 @@ +"""The window's pages for a session, and the pages made of other pages. + +:func:`default_pages` builds a session's Data, Check, Prepare, Retrieve and +Analyse pages from the shared state. Data holds the entry's own loader (a +measured trace, a simulated scan or a synthetic pulse) and brings a reopened +session's trace back; Check is the marginal check of what it loaded. Analyse is +a rail of pages that read the retrieved result: its panels (Overview, Traces, +Pulse, Diagnostics), then Dispersion and Uncertainty. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import cast + +from matplotlib.figure import Figure +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QHBoxLayout, + QPushButton, + QStackedWidget, + QVBoxLayout, +) + +from ..session.pipeline import ( + assemble_load_data, + assemble_simulated_load_data, + assemble_simulated_tracedata, +) +from .base import Stage +from .help import HelpDialog +from .home import ENTRY_TILES +from .retrieval_views import ResultPage +from .stage import PageBase +from .stage_dispersion import StageDispersion +from .stage_load import StageLoad, compute_preproc_defaults, trace_signature +from .stage_marginal_check import StageMarginalCheck +from .stage_preprocess import StagePreprocess +from .stage_retrieve import StageRetrieve +from .stage_simulated import StageSimulated, simulated_signature +from .stage_synthetic import StageSynthetic +from .stage_uncertainty import StageUncertainty +from .state import WizardState +from .theme import frame, set_flag +from .view_panel import file_name +from .workflow import Stage as Place + +__all__ = ["AnalysePage", "DataPage", "RAIL_WIDTH", "default_pages"] + +RAIL_WIDTH = 170 +"""The width of Analyse's rail, logical px.""" + +_ENTRY_TITLES = {entry: title for entry, title, _ in ENTRY_TILES} + + +class DataPage(PageBase): + """Data: the entry's loader, which hands a trace on to the check. + + Parameters + ---------- + state : WizardState + The session; its ``entry`` picks the loader. + adopt_geometry : callable + Retrieve's ``apply_simulated_geometry``, which a simulated scan's loader + calls with the geometry the file records. + """ + + def __init__( + self, state: WizardState, adopt_geometry: Callable[[object], str] + ) -> None: + super().__init__(state) + entry = state.entry + self.entry_page: Stage + if entry == "synthetic": + self.entry_page = StageSynthetic(state) + elif entry == "simulated": + self.entry_page = StageSimulated(state, adopt_geometry) + else: + self.entry_page = StageLoad(state) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.entry_page) + self.entry_page.changed.connect(self.changed) + self.entry_page.message.connect(self.message) + # Wherever the loader hands its trace on to, the window's route rules + # know (a raw simulated scan skips to Retrieve). + self.entry_page.advanced.connect(lambda _where: self.moved_on.emit()) + + # -- hooks the window reads --------------------------------------------- + def on_enter(self) -> None: + self.entry_page.on_enter() + + def can_advance(self) -> bool: + if self.state.entry == "experimental": + return self.state.load_data is not None + return self.entry_page.can_advance() + + def act(self, kind: str) -> bool: + """Continue: hand the trace on to the next stage. + + A measured trace is loaded by its own button, so its Continue just + moves on. The synthetic and simulated loaders build the trace here and, + if they could, say so through :attr:`moved_on`; if not, the page stays + and the loader says why. + """ + if kind != "continue": + return super().act(kind) + if self.state.entry == "experimental": + return True + self.entry_page.advance() + return False + + def show_help(self) -> HelpDialog | None: + return self.entry_page.show_help() + + def figure_name(self) -> str: + return file_name(_ENTRY_TITLES[self.state.entry]) + + # -- session replay ----------------------------------------------------- + def restore(self) -> bool: + """Reload a reopened session's trace with its saved options. + + Returns whether ``state.load_data`` is set. The saved preprocess settings + are kept (the preprocess defaults are only recorded, for "Reset + defaults"); when nothing can be reloaded the loader says why. + """ + if self.state.entry == "simulated": + return self._restore_simulated() + return self._restore_measured() + + def _restore_measured(self) -> bool: + state = self.state + if not state.load.frog_path: + self.entry_page.set_status( + "Options loaded. Choose a trace file and run the stages.", "warn" + ) + return False + try: + data = assemble_load_data(state.load) + except Exception as exc: # any reader failure is the user's to see + self.entry_page.set_status( + f"Options loaded, but reloading the trace failed: {exc}", "error" + ) + return False + state.set_load_data(data) + state.trace_sig = trace_signature(state.load) + state.preproc_defaults = compute_preproc_defaults(data) + # A measured session's loader is a StageLoad (the entry chose it). + cast(StageLoad, self.entry_page)._draw(data) + return True + + def _restore_simulated(self) -> bool: + state = self.state + # A simulated session's loader is a StageSimulated (the entry chose it). + loader = cast(StageSimulated, self.entry_page) + if not state.simulated.frog_path: + loader.set_status( + "Options loaded. Choose a simulated scan file and run the stages.", + "warn", + ) + return False + loader.seed_widgets(state.simulated) + try: + data = assemble_simulated_load_data(state.simulated) + except Exception as exc: # any reader failure is the user's to see + loader.set_status( + f"Options loaded, but reloading the simulated scan failed: {exc}", + "error", + ) + return False + state.set_load_data(data) + state.trace_sig = simulated_signature(state.simulated) + state.preproc_defaults = compute_preproc_defaults(data) + loader._draw(data) + if state.simulated.raw_direct: + try: + state.set_tracedata(assemble_simulated_tracedata(state.simulated)) + except Exception as exc: # as above + loader.set_status( + f"Options loaded, but the raw simulated trace failed: {exc}", + "error", + ) + return False + return True + + +class AnalysePage(PageBase): + """Analyse: a rail of pages that read the retrieved result. + + Parameters + ---------- + state : WizardState + The session. + pages : sequence of (key, title, page) + The rail's entries, in order; the first is shown first. + """ + + def __init__( + self, state: WizardState, pages: Sequence[tuple[str, str, PageBase]] + ) -> None: + super().__init__(state) + if not pages: + raise ValueError("Analyse needs at least one page") + rail = frame("rail") + rail.setFixedWidth(RAIL_WIDTH) + rl = QVBoxLayout(rail) + rl.setContentsMargins(8, 10, 8, 10) + rl.setSpacing(2) + self.stack = QStackedWidget() + self.pages: dict[str, PageBase] = {} + self.rail_items: dict[str, QPushButton] = {} + for key, title, page in pages: + item = QPushButton(title) + item.setObjectName("railItem") + item.setFocusPolicy(Qt.FocusPolicy.NoFocus) + item.clicked.connect(lambda _=False, key=key: self.show_page(key)) + rl.addWidget(item) + self.rail_items[key] = item + self.pages[key] = page + self.stack.addWidget(page) + page.changed.connect(self.changed) + page.message.connect(self.message) + rl.addStretch(1) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(rail) + layout.addWidget(self.stack, stretch=1) + self.key = "" + # Up and Down step through the rail while focus is anywhere on the page. + for key, step in ((Qt.Key.Key_Up, -1), (Qt.Key.Key_Down, 1)): + shortcut = QShortcut(QKeySequence(key), self) + shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + shortcut.activated.connect(lambda step=step: self.step(step)) + self.show_page(pages[0][0]) + + def step(self, offset: int) -> None: + """Show the page ``offset`` places along the rail (clamped at its ends).""" + keys = list(self.pages) + index = min(max(keys.index(self.key) + offset, 0), len(keys) - 1) + self.show_page(keys[index]) + + def show_page(self, key: str) -> None: + """Show the rail's page ``key`` and refresh it.""" + page = self.pages[key] + self.key = key + self.stack.setCurrentWidget(page) + for name, item in self.rail_items.items(): + set_flag(item, "current", name == key) + page.on_enter() + self.changed.emit() + + def on_enter(self) -> None: + self.pages[self.key].on_enter() + + def page_key(self) -> str: + return self.key + + def busy(self) -> bool: + return any(page.busy() for page in self.pages.values()) + + def act(self, kind: str) -> bool: + return self.pages[self.key].act(kind) + + def show_help(self) -> HelpDialog | None: + return self.pages[self.key].show_help() + + def figure(self) -> Figure | None: + return self.pages[self.key].figure() + + def figure_name(self) -> str: + return self.pages[self.key].figure_name() + + def save_extras(self, folder: Path) -> list[str]: + written: list[str] = [] + for page in self.pages.values(): + written += page.save_extras(folder) + return written + + +def default_pages(state: WizardState) -> Mapping[Place, PageBase]: + """Build a session's Data, Check, Prepare, Retrieve and Analyse pages.""" + retrieve = StageRetrieve(state) + results: list[tuple[str, str, PageBase]] = [ + (key, title, ResultPage(state, key, retrieve.overview)) + for key, title in ( + ("overview", "Overview"), + ("traces", "Traces"), + ("pulse", "Pulse"), + ("diagnostics", "Diagnostics"), + ) + ] + analyse = AnalysePage( + state, + [ + *results, + ("dispersion", "Dispersion", StageDispersion(state)), + ("uncertainty", "Uncertainty", StageUncertainty(state)), + ], + ) + return { + # Looked up at each load, so Retrieve's current method is the one used. + Place.DATA: DataPage( + state, lambda geom: retrieve.apply_simulated_geometry(geom) + ), + Place.CHECK: StageMarginalCheck(state), + Place.PREPARE: StagePreprocess(state), + Place.RETRIEVE: retrieve, + Place.ANALYSE: analyse, + } diff --git a/croak/gui/panel_pages.py b/croak/gui/panel_pages.py deleted file mode 100644 index a74abb9..0000000 --- a/croak/gui/panel_pages.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Tabbed pages of retrieval panels for the Retrieve stage, with pop-out windows. - -Why pages rather than one twelve-panel figure: on a 1366×768 display a single -3×4 figure leaves each axes about 124×61 px once titles, tick labels and colorbars -have taken their fixed share, while three 2×2 pages of the same panels give each -axes roughly 370×137 px in the same canvas. The panels are the headless ones in -:mod:`croak.plotting` (:data:`~croak.plotting.RETRIEVAL_PAGES`), so a page shows -exactly what :func:`~croak.plotting.plot_retrieval` shows, and the overview stays -available for export. - -Only the visible page is drawn when new data arrive; the others are marked stale -and drawn when their tab is selected. Before the first full live preview of a run -the stage shows the convergence curve instead ("convergence mode"), on whichever -page is visible. - -A page is drawn once and later results are pushed onto its artists in place -(:class:`~croak.plotting.RetrievalPageView`), so a live preview costs no layout -solve and nothing jumps between frames; the page is redrawn from scratch only -when the result's shape, its optional curves, or the canvas's text density change. -""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence - -from matplotlib.figure import Figure -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QDialog, QTabWidget, QToolButton, QVBoxLayout, QWidget - -from .. import plotting -from ..plotting import ( - RETRIEVAL_PAGES, - ConvergenceArtists, - PageSpec, - PlotScale, - RetrievalPageView, - RetrievalPlotData, -) -from .canvas import MplCanvas, with_toolbar - -__all__ = ["RetrievalPages", "PagePopout", "PAGE_FIGSIZE"] - -#: Design size (inches) of one 2×2 page: the overview's default 17.0×9.45 in -#: gives each of its twelve panels this much, so a page panel is drawn at the same -#: size as its overview twin whenever the canvas has the room. -PAGE_FIGSIZE = (8.5, 6.3) - - -class PagePopout(QDialog): - """A top-level window showing one page of retrieval panels. - - Opened by :meth:`RetrievalPages.pop_out`; follows every later result the pages - widget receives and closes with the stage that owns it. - """ - - def __init__(self, page: PageSpec, parent: QWidget | None = None): - super().__init__(parent, Qt.WindowType.Window) - self.page = page - self.setWindowTitle(f"croak — {page.title}") - # Closing frees the window; the owner drops its reference on ``finished``. - self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) - self.canvas = MplCanvas(figsize=PAGE_FIGSIZE) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(with_toolbar(self.canvas)) - self.resize(int(PAGE_FIGSIZE[0] * 100), int(PAGE_FIGSIZE[1] * 100) + 40) - - -class RetrievalPages(QWidget): - """The Retrieve stage's plot area: one tab per page of retrieval panels. - - Parameters - ---------- - pages : mapping of str to PageSpec - The pages to offer, in tab order; defaults to - :data:`~croak.plotting.RETRIEVAL_PAGES`. - parent : QWidget, optional - - Notes - ----- - Every canvas goes through :meth:`~croak.gui.canvas.MplCanvas.render`, so the - pages re-plot themselves when a resize changes their text density. Each tab - carries the matplotlib toolbar, whose Save writes *that page*; the stage's own - Save… writes the full overview. - """ - - def __init__( - self, - pages: Mapping[str, PageSpec] = RETRIEVAL_PAGES, - parent: QWidget | None = None, - ): - super().__init__(parent) - self._pages: dict[str, PageSpec] = dict(pages) - self._data: RetrievalPlotData | None = None - self._errors: Sequence[float] | None = None - self._stale: set[str] = set() - self._canvases: dict[str, MplCanvas] = {} - self._popouts: dict[str, PagePopout] = {} - # Per-canvas presentation state (tabs and pop-outs alike): the artist- - # reusing view of its page, the convergence-curve artists while a run has - # no full preview yet, and the text density the canvas was last drawn at. - self._views: dict[MplCanvas, RetrievalPageView] = {} - self._conv: dict[MplCanvas, ConvergenceArtists] = {} - self._scales: dict[MplCanvas, PlotScale] = {} - - self.tabs = QTabWidget() - self.tabs.setDocumentMode(True) # a slim tab bar: every pixel is the plot's - for key, page in self._pages.items(): - canvas = MplCanvas(figsize=PAGE_FIGSIZE) - self._canvases[key] = canvas - self.tabs.addTab(with_toolbar(canvas), page.title) - self.popout_btn = QToolButton() - self.popout_btn.setText("Pop out") - self.popout_btn.setToolTip( - "Open the current page in its own window, which follows later results." - ) - self.popout_btn.clicked.connect(lambda: self.pop_out()) - self.tabs.setCornerWidget(self.popout_btn, Qt.Corner.TopRightCorner) - self.tabs.currentChanged.connect(self._on_tab_changed) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.tabs) - - # -- queries ------------------------------------------------------------ - def page_keys(self) -> tuple[str, ...]: - """The page keys in tab order.""" - return tuple(self._pages) - - def current_page(self) -> str: - """Key of the page whose tab is selected.""" - return self.page_keys()[self.tabs.currentIndex()] - - def set_current_page(self, key: str) -> None: - """Select the tab of page ``key`` (drawing it if it is stale).""" - self.tabs.setCurrentIndex(self.page_keys().index(key)) - - def is_stale(self, key: str) -> bool: - """Whether page ``key`` still shows older content than the latest data.""" - return key in self._stale - - def canvas(self, key: str) -> MplCanvas: - """The canvas of page ``key``.""" - return self._canvases[key] - - @property - def data(self) -> RetrievalPlotData | None: - """The data the pages currently show, if any.""" - return self._data - - # -- content ------------------------------------------------------------ - def set_data(self, data: RetrievalPlotData) -> None: - """Show a result: draw the visible page (and pop-outs), mark the rest stale.""" - self._data = data - self._errors = None # leaves convergence mode - self._present() - - def show_progress(self, errors: Sequence[float]) -> None: - """Show the convergence curve of a run in progress instead of a result. - - ``errors`` is read live (the stage appends to it), so a re-plot after a - resize shows the latest history. - """ - self._errors = errors - self._present() - - def clear(self) -> None: - """Blank every page and forget the data (a new run is starting).""" - self._data = None - self._errors = None - self._stale = set() - self._conv.clear() - for view in self._views.values(): - view.reset() - for canvas in self._all_canvases(): - canvas.clear() - - def pop_out(self, key: str | None = None) -> PagePopout: - """Open (or raise) a window showing page ``key`` (default: the current one).""" - key = self.current_page() if key is None else key - popout = self._popouts.get(key) - if popout is None: - popout = PagePopout(self._pages[key], self) - popout.finished.connect(lambda _result, k=key: self._forget_popout(k)) - self._popouts[key] = popout - self._present_canvas(popout.canvas, key) - popout.show() - popout.raise_() - popout.activateWindow() - return popout - - # -- internals ---------------------------------------------------------- - def _all_canvases(self) -> list[MplCanvas]: - return [*self._canvases.values(), *(p.canvas for p in self._popouts.values())] - - def _forget_popout(self, key: str) -> None: - popout = self._popouts.pop(key, None) - if popout is not None: - for store in (self._views, self._conv, self._scales): - store.pop(popout.canvas, None) - - def _present_canvas(self, canvas: MplCanvas, key: str) -> None: - """Bring ``canvas`` (showing page ``key``) up to date with the current mode.""" - if self._errors is not None: - self._present_convergence(canvas, self._errors) - elif self._data is not None: - self._present_data(canvas, key, self._data) - else: - canvas.clear() - - def _present_convergence(self, canvas: MplCanvas, errors: Sequence[float]) -> None: - """Show the live error curve on ``canvas``, growing it in place if it exists. - - ``errors`` is the stage's own (growing) list, so a re-plot reads it live. - """ - artists = self._conv.get(canvas) - if ( - artists is not None - and canvas.rendered - and self._scales.get(canvas) == canvas.scale - ): - plotting.update_convergence_curve(artists, errors) - canvas.draw() # synchronous: cheap, and no second idle draw follows - return - view = self._views.get(canvas) - if view is not None: - view.reset() # the page's artists go with the cleared figure - - def plot(fig: Figure) -> None: - # Re-run by the canvas on a density change; `errors` is read live. - self._conv[canvas] = plotting.plot_convergence(fig.add_subplot(111), errors) - self._scales[canvas] = canvas.scale - - canvas.render(plot) - - def _present_data( - self, canvas: MplCanvas, key: str, data: RetrievalPlotData - ) -> None: - """Show ``data`` on ``canvas``: update in place when possible, else redraw. - - A redraw is needed when the page has never been drawn on this figure, - when the result changed shape or gained/lost optional curves - (:meth:`~croak.plotting.RetrievalPageView.can_update`), or when the - canvas's text density changed: rc sizes bind at artist creation, so an - in-place update would keep the stale size. - """ - view = self._views.get(canvas) - if view is None: - view = self._views[canvas] = RetrievalPageView(self._pages[key]) - if ( - view.attached - and view.fig is canvas.figure - and self._scales.get(canvas) == canvas.scale - and view.can_update(data) - ): - view.update(data) - # Synchronous, so the caller's timing of a preview includes the paint - # (the stage feeds it back to the worker's preview cadence) and no - # second idle draw follows. - canvas.draw() - return - self._conv.pop(canvas, None) - - def plot(fig: Figure) -> None: - # Re-run by the canvas on a density change: draw the latest result. - # clear() makes every canvas forget this function, so the pages still - # hold data whenever it runs; ``data`` only satisfies the type. - view.draw(fig, self._data if self._data is not None else data) - self._scales[canvas] = canvas.scale - - canvas.render(plot) - - def _present(self) -> None: - """Bring the visible page and every pop-out up to date; mark the rest stale.""" - current = self.current_page() - self._stale = set(self._pages) - {current} - self._present_canvas(self._canvases[current], current) - for key, popout in self._popouts.items(): - self._present_canvas(popout.canvas, key) - - def _on_tab_changed(self, index: int) -> None: - key = self.page_keys()[index] - if key in self._stale: - self._present_canvas(self._canvases[key], key) - self._stale.discard(key) diff --git a/croak/gui/plots.py b/croak/gui/plots.py new file mode 100644 index 0000000..edf2311 --- /dev/null +++ b/croak/gui/plots.py @@ -0,0 +1,723 @@ +"""On-screen plots: white pyqtgraph views with a crosshair and a hover readout. + +A view draws the arrays it is given and nothing else. What to show is decided +by the data functions in :mod:`croak.plotting`, which the exported matplotlib +figures share, so the screen and a saved figure agree. Mouse zoom and pan are on +(they replace the matplotlib toolbars); a double-click returns to the view the +data was shown with. + +The readout under the pointer comes from a ``reader(x, y) -> str`` the owner +sets; :func:`map_reader` and :func:`curve_reader` build the usual ones. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace +from functools import partial +from typing import cast + +import numpy as np +import pyqtgraph as pg +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QColor, QFont, QMouseEvent, QPainter + +from ..plotting.screen import CURVE_COLOURS +from .theme import FONT_FAMILY, PALETTE, PLOT_PX + +__all__ = [ + "COLOURS", + "Curve", + "LinePlot", + "MapPlot", + "Plot", + "colormap", + "curve_reader", + "map_reader", + "signed_colormap", +] + +pg.setConfigOptions(antialias=True, background="w", foreground=PALETTE.ink) + +COLOURS = CURVE_COLOURS +"""The curves' colours, in order of use (shared with the exported figures).""" + +PEN_WIDTH = 1.7 +"""A curve's line width, in logical pixels.""" + +NEGATIVE_RGBA = (0.70, 0.15, 0.12, 1.0) +"""The colour of the most negative value on a signed map (fading to white at 0).""" + +_CROSSHAIR = (120, 130, 140, 190) +_NOTE_FILL = (27, 36, 48, 235) +_LABEL_STYLE = { + "color": PALETTE.muted, + "font-size": f"{PLOT_PX}px", + "font-family": FONT_FAMILY, +} +# Past these fractions of the view the readout flips to the other side of the +# pointer, so it never runs off the plot. +_FLIP_X = 0.62 +_FLIP_Y = 0.9 + + +def _font(px: int = PLOT_PX) -> QFont: + font = QFont(FONT_FAMILY) + font.setPixelSize(px) + return font + + +def _set_label(axis: pg.AxisItem, text: str) -> None: + """Label ``axis`` in the plots' label style.""" + # setLabel takes CSS properties as **kwargs; its stubs name only a few. + axis.setLabel(text, **_LABEL_STYLE) # pyright: ignore[reportArgumentType] + + +def _style_axis(axis: pg.AxisItem, label: str) -> None: + """Give ``axis`` the plots' look: grey, inward ticks, no SI rescaling.""" + axis.setPen(pg.mkPen(PALETTE.axis)) + axis.setTextPen(pg.mkPen(PALETTE.muted)) + axis.setTickFont(_font()) + axis.setStyle(tickLength=-4, tickTextOffset=3) + # The label states its unit; a silent "(×1e-3)" would contradict it. + axis.enableAutoSIPrefix(False) + _set_label(axis, label) + + +class _SmoothImage(pg.ImageItem): + """An image drawn with bilinear smoothing between its samples.""" + + def paint(self, painter: QPainter, *args: object) -> None: + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True) + super().paint(painter, *args) + + +class Plot(pg.PlotWidget): + """A plot box with a crosshair and a hover readout. + + Parameters + ---------- + xlabel, ylabel : str + The axis labels, with their units. + """ + + def __init__(self, *, xlabel: str = "", ylabel: str = "") -> None: + super().__init__(background="w") + self.setMinimumSize(140, 100) + self.reader: Callable[[float, float], str] | None = None + self._home: tuple[tuple[float, float], tuple[float, float]] | None = None + # A PlotWidget always owns its PlotItem; the stubs allow None. + self.item = cast(pg.PlotItem, self.getPlotItem()) + self.item.hideButtons() + self.item.setMenuEnabled(False) + self.item.layout.setContentsMargins(0, 8, 8, 0) # pyright: ignore[reportAttributeAccessIssue] + self.box = self.item.getViewBox() + _style_axis(self.item.getAxis("bottom"), xlabel) + _style_axis(self.item.getAxis("left"), ylabel) + self.item.getAxis("left").setWidth(54) + self.item.getAxis("bottom").setHeight(38) + # A closed box: top and right axes drawn, without numbers. + for side in ("top", "right"): + self.item.showAxis(side) + axis = self.item.getAxis(side) + axis.setPen(pg.mkPen(PALETTE.axis)) + axis.setStyle(showValues=False, tickLength=0) + pen = pg.mkPen(_CROSSHAIR) + self.cross_x = pg.InfiniteLine(angle=90, pen=pen) + self.cross_y = pg.InfiniteLine(angle=0, pen=pen) + self.note = pg.TextItem("", color="#fff", fill=_NOTE_FILL, anchor=(0, 1)) + self.note.setFont(_font(PLOT_PX + 1)) + for extra in (self.cross_x, self.cross_y, self.note): + self.addItem(extra, ignoreBounds=True) # pyright: ignore[reportCallIssue] + extra.setZValue(50) + extra.hide() + self.scene().sigMouseMoved.connect(self.hover) # pyright: ignore[reportOptionalMemberAccess, reportAttributeAccessIssue] + + # -- labels and view ---------------------------------------------------- + def set_labels(self, xlabel: str | None = None, ylabel: str | None = None) -> None: + """Change the axis labels (``None`` keeps a label).""" + if xlabel is not None: + _set_label(self.item.getAxis("bottom"), xlabel) + if ylabel is not None: + _set_label(self.item.getAxis("left"), ylabel) + + def set_view( + self, xrange: tuple[float, float], yrange: tuple[float, float] + ) -> None: + """Show ``xrange`` × ``yrange``, and return there on a double-click.""" + self._home = (xrange, yrange) + self.box.setRange(xRange=xrange, yRange=yrange, padding=0) + + def reset_view(self) -> None: + """Return to the view last set with :meth:`set_view` (else fit the data).""" + if self._home is None: + self.box.autoRange(padding=0.02) + else: + self.box.setRange(xRange=self._home[0], yRange=self._home[1], padding=0) + + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: # noqa: N802 — Qt override + self.reset_view() + event.accept() + + # -- hover -------------------------------------------------------------- + def hover(self, position: QPointF) -> None: + """Show the crosshair and the readout at scene point ``position``.""" + inside = self.box.sceneBoundingRect().contains(position) + if not inside or self.reader is None: + self.clear_hover() + return + point = self.box.mapSceneToView(position) + text = self.reader(point.x(), point.y()) + if not text: + self.clear_hover() + return + self.cross_x.setPos(point.x()) + self.cross_y.setPos(point.y()) + self.note.setText(f" {text} ") + (x0, x1), (y0, y1) = self.box.viewRange() + right = (point.x() - x0) / ((x1 - x0) or 1.0) > _FLIP_X + top = (point.y() - y0) / ((y1 - y0) or 1.0) > _FLIP_Y + self.note.setAnchor((1.0 if right else 0.0, 0.0 if top else 1.0)) + self.note.setPos(point.x(), point.y()) + for extra in (self.cross_x, self.cross_y, self.note): + extra.show() + + def clear_hover(self) -> None: + """Hide the crosshair and the readout.""" + for extra in (self.cross_x, self.cross_y, self.note): + extra.hide() + + def leaveEvent(self, event: object) -> None: # noqa: N802 — Qt override # pyright: ignore[reportIncompatibleMethodOverride] + self.clear_hover() + super().leaveEvent(event) # pyright: ignore[reportArgumentType] + + +def colormap(name: str) -> pg.ColorMap: + """Return the colour map ``name``: pyqtgraph's own, else matplotlib's. + + Raises + ------ + ValueError + If neither knows the name. + """ + for source in (None, "matplotlib"): + try: + # Either source raises for a name it lacks (pyqtgraph looks for a + # file of that name); the stubs allow None, which it never returns. + return cast(pg.ColorMap, pg.colormap.get(name, source=source)) + except FileNotFoundError, ValueError: + continue + raise ValueError(f"unknown colour map {name!r}") + + +NEGLIGIBLE_NEGATIVE = 1e-3 +"""Negative levels within this fraction of the top level draw no red. + +Below it the red part would be finer than an entry of the colour table, which +then paints zero itself red: rounding noise (around -1e-17 after a filter) set +the bottom level of a whole map that way.""" + + +def signed_colormap(name: str, low: float, high: float) -> pg.ColorMap: + """Return colour map ``name`` for levels ``(low, high)``, white at zero. + + A measured trace can dip below zero after background subtraction. One map + shows both signs: the negative part fades from red to white and the positive + part runs white → ``name`` (whose own lowest colour is replaced by white, + so a zero background reads as paper). ``low >= 0``, or ``low`` no further + below zero than :data:`NEGLIGIBLE_NEGATIVE` of ``high``, gives the plain + white-based map. + + Parameters + ---------- + name : str + A pyqtgraph/matplotlib colour map name, e.g. ``"viridis"``. + low, high : float + The levels the map will span. + + Returns + ------- + pyqtgraph.ColorMap + Stops at the positions of ``low``, ``0`` and ``high``. + + Raises + ------ + ValueError + If ``high <= 0``, ``low >= high`` or ``name`` is not a colour map. + """ + if not high > 0 or not low < high: + raise ValueError(f"need low < high and high > 0, got ({low}, {high})") + base = colormap(name) + stops = np.linspace(0.0, 1.0, 9) + colours = np.array(base.map(stops, mode=pg.ColorMap.FLOAT), dtype=float) + colours[0] = (1.0, 1.0, 1.0, 1.0) + if low >= -NEGLIGIBLE_NEGATIVE * high: + # pg.ColorMap reads numeric colours on a 0–255 scale. + return pg.ColorMap(stops, 255 * colours) + zero = -low / (high - low) + positions = np.concatenate([[0.0], zero + (1.0 - zero) * stops]) + return pg.ColorMap(positions, 255 * np.vstack([NEGATIVE_RGBA, colours])) + + +def is_uniform(axis: np.ndarray) -> bool: + """Whether ``axis`` increases in equal steps (to 0.1 %). + + Examples + -------- + >>> is_uniform(np.array([0.0, 1.0, 2.0])) + True + >>> is_uniform(np.array([0.0, 1.0, 3.0])) + False + """ + steps = np.diff(np.asarray(axis, float)) + if steps.size == 0 or not np.all(steps > 0): + return False + return bool(np.allclose(steps, steps[0], rtol=1e-3, atol=0)) + + +class MapPlot(Plot): + """A false-colour image with a colour bar. + + Dragging the colour bar's ends (or the bar) changes the colour scale of + this map alone; a double-click returns the view and the scale to the ones + last shown. + + Parameters + ---------- + xlabel, ylabel : str + The axis labels. + colours : str + The colour map, e.g. ``"viridis"`` or ``"bwr"``. + unit : str + The colour bar's label. + signed : bool + Draw values below zero in red fading to white (:func:`signed_colormap`). + white : bool + Start the colour map at white, so an empty background reads as paper, + as the matplotlib figures do (off for a diverging map such as ``bwr``). + """ + + def __init__( + self, + *, + xlabel: str = "", + ylabel: str = "", + colours: str = "viridis", + unit: str = "", + signed: bool = False, + white: bool = True, + ) -> None: + super().__init__(xlabel=xlabel, ylabel=ylabel) + self.colours = colours + self.signed = signed + self.white = white + self.data: np.ndarray | None = None + self.x_axis = np.zeros(0) + self.y_axis = np.zeros(0) + self.image = _SmoothImage(axisOrder="row-major") + self.addItem(self.image) + self.bar = pg.ColorBarItem( + colorMap=signed_colormap(colours, 0.0, 1.0) if white else colormap(colours), + interactive=True, + width=10, + colorMapMenu=False, + ) + self.bar.setImageItem(self.image, insert_in=self.item) + self.bar.sigLevelsChanged.connect(self._follow_levels) + self._levels = (0.0, 1.0) # the scale last shown, for a double-click + _style_axis(self.bar.axis, unit) + self.message = pg.TextItem("", color=PALETTE.muted, anchor=(0.5, 0.5)) + self.addItem(self.message, ignoreBounds=True) # pyright: ignore[reportCallIssue] + + def show_map( + self, + data: np.ndarray, + x: np.ndarray, + y: np.ndarray, + levels: tuple[float, float], + ) -> None: + """Show ``data`` (rows along ``y``, columns along ``x``) spanning ``levels``. + + Parameters + ---------- + data : numpy.ndarray, shape (len(y), len(x)) + The image; NaN draws as blank. + x, y : numpy.ndarray + The sample positions, increasing and evenly spaced (the data + functions resample a non-uniform axis before it reaches the screen). + levels : tuple of float + The colour bar's span. + + Raises + ------ + ValueError + If the shapes disagree or an axis is not evenly spaced. + """ + x = np.asarray(x, float) + y = np.asarray(y, float) + data = np.asarray(data, float) + if data.shape != (y.size, x.size): + raise ValueError(f"data {data.shape} does not match y×x {(y.size, x.size)}") + if not (is_uniform(x) and is_uniform(y)): + raise ValueError("a map's axes must be increasing and evenly spaced") + self.data, self.x_axis, self.y_axis = data, x, y + self.message.hide() + self.image.setImage(data, autoLevels=False) + dx, dy = x[1] - x[0], y[1] - y[0] + # Each sample is a pixel centred on its position. + self.image.setRect( + QRectF(x[0] - dx / 2, y[0] - dy / 2, dx * x.size, dy * y.size) + ) + self.set_levels(levels) + self.set_view((x[0] - dx / 2, x[-1] + dx / 2), (y[0] - dy / 2, y[-1] + dy / 2)) + + def update_map(self, data: np.ndarray) -> None: + """Replace the image's values in place, on the same axes and levels.""" + data = np.asarray(data, float) + if self.data is None or data.shape != self.data.shape: + raise ValueError("update_map needs an image of the shape already shown") + self.data = data + self.image.setImage(data, autoLevels=False) + + def set_levels(self, levels: tuple[float, float]) -> None: + """Change the colour bar's span without touching the data.""" + self._levels = (float(levels[0]), float(levels[1])) + self.bar.setLevels(values=self._levels) + self._follow_levels() + + def _follow_levels(self, _bar: object = None) -> None: + """Keep a signed map's white at zero, wherever the span is dragged.""" + if not self.signed: + return + low, high = self.bar.levels() + if high > 0 > low: + self.bar.setColorMap(signed_colormap(self.colours, low, high)) + else: + self.bar.setColorMap(signed_colormap(self.colours, 0.0, 1.0)) + + def reset_view(self) -> None: + """Return to the view and the colour scale last shown.""" + super().reset_view() + self.set_levels(self._levels) + + def value_at(self, x: float, y: float) -> float | None: + """The sample nearest ``(x, y)``, or ``None`` outside the image.""" + if self.data is None: + return None + xs, ys = self.x_axis, self.y_axis + col = int(round((x - xs[0]) / (xs[1] - xs[0]))) + row = int(round((y - ys[0]) / (ys[1] - ys[0]))) + if not (0 <= col < xs.size and 0 <= row < ys.size): + return None + return float(self.data[row, col]) + + def say(self, text: str) -> None: + """Show ``text`` in place of the image.""" + self.image.clear() + self.data = None + self.message.setText(text) + (x0, x1), (y0, y1) = self.box.viewRange() + self.message.setPos((x0 + x1) / 2, (y0 + y1) / 2) + self.message.show() + + +@dataclass(frozen=True) +class Curve: + """One line of a :class:`LinePlot`. + + Attributes + ---------- + x, y : numpy.ndarray + The points. + name : str + The legend entry ("" for none). + colour : str + Its colour (default: the next of :data:`COLOURS`). + dashed : bool + Draw dashed. + right : bool + Plot against the right-hand axis (e.g. a phase beside an intensity). + width : float + Line width, logical px. + under : bool + Draw beneath the other curves (a reference such as a known pulse). + points : bool + Draw the samples as dots (a scatter) instead of a line. + """ + + x: np.ndarray + y: np.ndarray + name: str = "" + colour: str = "" + dashed: bool = False + right: bool = False + width: float = PEN_WIDTH + under: bool = False + points: bool = False + + +class LinePlot(Plot): + """Curves against a shared x axis, with an optional right-hand y axis. + + Parameters + ---------- + xlabel, ylabel : str + The axis labels. + right_label : str or None + The right-hand axis's label; ``None`` for no right-hand axis. + """ + + def __init__( + self, *, xlabel: str = "", ylabel: str = "", right_label: str | None = None + ) -> None: + super().__init__(xlabel=xlabel, ylabel=ylabel) + self.curves: list[Curve] = [] + self.items: list[pg.PlotDataItem] = [] + self.log_y = False + self.legend: pg.LegendItem | None = None + self.right_box: pg.ViewBox | None = None + self.marks: list[pg.InfiniteLine] = [] + self.extras: list[pg.GraphicsObject | pg.FillBetweenItem] = [] + if right_label is not None: + right = pg.ViewBox() + self.right_box = right + self.item.scene().addItem(right) # pyright: ignore[reportOptionalMemberAccess] + axis = self.item.getAxis("right") + axis.linkToView(right) + _style_axis(axis, right_label) + axis.setStyle(showValues=True) + right.setXLink(self.box) + match = partial(self._match_right, right) + self.box.sigResized.connect(match) + match() + + def _match_right(self, right: pg.ViewBox, _resized: object = None) -> None: + """Keep the right-hand view box over the main one as it resizes. + + ``_resized`` is the view box ``sigResized`` passes along; unused. + """ + right.setGeometry(self.box.sceneBoundingRect()) + right.linkedViewChanged(self.box, right.XAxis) + + def _points(self, curve: Curve, x: np.ndarray, y: np.ndarray): + """The points of ``curve`` to draw: on a log axis, only ``y > 0``.""" + x, y = np.asarray(x, float), np.asarray(y, float) + if self.log_y and not curve.right: + keep = y > 0 + return x[keep], y[keep] + return x, y + + def set_curves(self, curves: Sequence[Curve], *, log_y: bool = False) -> None: + """Replace the curves; a legend appears when more than one is named. + + On a log axis values ``<= 0`` are dropped rather than drawn as -inf. + """ + for item, curve in zip(self.items, self.curves, strict=True): + box = self.right_box if curve.right else None + (box or self.item).removeItem(item) + if self.legend is not None: + self.legend.clear() + self.items = [] + self.curves = list(curves) + self.log_y = log_y + self.item.setLogMode(False, log_y) + # A tick at each decade only, as matplotlib's log axes: pyqtgraph also + # labels the 2…9 between decades, which pile up on a short plot. + left = self.item.getAxis("left") + if log_y: + left.setTickSpacing(1.0, 1.0) + else: + left.setTickSpacing() + named = [c for c in curves if c.name] + if len(named) > 1 and self.legend is None: + self.legend = self.item.addLegend( + offset=(-8, 8), labelTextSize="8pt", brush=(255, 255, 255, 210) + ) + if self.legend is not None: + self.legend.setVisible(len(named) > 1) + auto = 0 + for curve in self.curves: + colour = curve.colour or COLOURS[auto % len(COLOURS)] + auto += not curve.colour + style = Qt.PenStyle.DashLine if curve.dashed else Qt.PenStyle.SolidLine + pen = pg.mkPen(colour, width=curve.width, style=style) + x, y = self._points(curve, curve.x, curve.y) + if curve.points: + item = pg.PlotDataItem( + x, + y, + pen=None, + symbol="o", + symbolSize=5, + symbolPen=None, + symbolBrush=pg.mkColor(colour), + ) + else: + item = pg.PlotDataItem(x, y, pen=pen) + item.setZValue(-1 if curve.under else 0) + if curve.right and self.right_box is not None: + self.right_box.addItem(item) + else: + self.item.addItem(item) + if self.legend is not None and curve.name: + self.legend.addItem(item, curve.name) + self.items.append(item) + + def update_curve(self, index: int, x: np.ndarray, y: np.ndarray) -> None: + """Move curve ``index``'s points in place (cheap: no legend or layout).""" + curve = replace(self.curves[index], x=np.asarray(x), y=np.asarray(y)) + self.curves[index] = curve + self.items[index].setData(*self._points(curve, curve.x, curve.y)) + + def set_right_label(self, text: str) -> None: + """Change the right-hand axis's label (no-op without a right axis).""" + if self.right_box is not None: + _set_label(self.item.getAxis("right"), text) + + def set_right_range(self, low: float, high: float) -> None: + """Fix the right-hand axis's range.""" + if self.right_box is not None: + self.right_box.setYRange(low, high, padding=0) + + def set_marks( + self, marks: Sequence[tuple[float, str]], *, dashed: bool = True + ) -> None: + """Draw vertical rules, each ``(x, colour)`` (replacing the last set).""" + for mark in self.marks: + self.item.removeItem(mark) + style = Qt.PenStyle.DashLine if dashed else Qt.PenStyle.DotLine + self.marks = [] + for position, colour in marks: + pen = pg.mkPen(colour or PALETTE.accent, width=1.2, style=style) + line = pg.InfiniteLine(pos=position, angle=90, pen=pen) + self.addItem(line, ignoreBounds=True) # pyright: ignore[reportCallIssue] + self.marks.append(line) + + def _shade(self, colour: str, opacity: float) -> QColor: + shade = pg.mkColor(colour) + shade.setAlphaF(opacity) + return shade + + def _extra(self, item: pg.GraphicsObject | pg.FillBetweenItem, z: float) -> None: + item.setZValue(z) + self.addItem(item, ignoreBounds=True) # pyright: ignore[reportCallIssue] + self.extras.append(item) + + def clear_extras(self) -> None: + """Remove the bars, bands, spans and levels drawn with the curves.""" + for item in self.extras: + self.item.removeItem(item) + self.extras = [] + + def add_bars( + self, edges: np.ndarray, counts: np.ndarray, colour: str + ) -> pg.BarGraphItem: + """Draw a histogram: ``counts`` over the bins between ``edges``.""" + edges = np.asarray(edges, dtype=float) + bars = pg.BarGraphItem( + x0=edges[:-1], + x1=edges[1:], + height=np.asarray(counts, dtype=float), + brush=self._shade(colour, 0.7), + pen=pg.mkPen("w", width=0.6), + ) + self._extra(bars, -2) + return bars + + def add_band( + self, + x: np.ndarray, + low: np.ndarray, + high: np.ndarray, + colour: str, + opacity: float, + ) -> pg.FillBetweenItem: + """Shade between the curves ``low`` and ``high`` over ``x``.""" + lower = pg.PlotDataItem(x, low, pen=None) + upper = pg.PlotDataItem(x, high, pen=None) + band = pg.FillBetweenItem(lower, upper, brush=self._shade(colour, opacity)) + self._extra(band, -3) + return band + + def add_span( + self, + low: float, + high: float, + colour: str, + opacity: float, + *, + vertical: bool = True, + ) -> pg.LinearRegionItem: + """Shade the interval ``[low, high]`` of x (``vertical``) or of y.""" + span = pg.LinearRegionItem( + values=(low, high), + orientation="vertical" if vertical else "horizontal", + movable=False, + brush=self._shade(colour, opacity), + pen=pg.mkPen(None), + ) + self._extra(span, -4) + return span + + def add_level(self, y: float, colour: str) -> pg.InfiniteLine: + """Draw a horizontal rule at ``y``.""" + line = pg.InfiniteLine(pos=y, angle=0, pen=pg.mkPen(colour, width=1.4)) + self._extra(line, -1) + return line + + def fit(self) -> None: + """Fit the view to the left-axis curves, and make that the home view.""" + drawn = [ + self._points(c, c.x, c.y) + for c in self.curves + if not c.right and np.size(c.x) + ] + if not drawn: + return + x = np.concatenate([d[0] for d in drawn]) + y = np.concatenate([d[1] for d in drawn]) + y = y[np.isfinite(y)] + xr = (float(np.min(x)), float(np.max(x))) + if self.log_y and y.size: + # Whole decades, as matplotlib's log axes: inside one decade + # pyqtgraph has no tick to label. + low = float(np.floor(np.log10(np.min(y)))) + high = max(float(np.ceil(np.log10(np.max(y)))), low + 1.0) + self.set_view(xr, (low, high)) + return + yr = (float(np.min(y)), float(np.max(y))) if y.size else (0.0, 1.0) + pad = 0.05 * ((yr[1] - yr[0]) or 1.0) + self.set_view(xr, (yr[0] - pad, yr[1] + pad)) + + +def map_reader( + plot: MapPlot, xname: str, yname: str, value: str = "{:.3g}" +) -> Callable[[float, float], str]: + """Return a reader that says the position and the map's value there.""" + + def read(x: float, y: float) -> str: + found = plot.value_at(x, y) + if found is None: + return "" + return f"{xname} {x:.4g} {yname} {y:.4g} {value.format(found)}" + + return read + + +def curve_reader( + plot: LinePlot, xname: str, value: str = "{:.3g}" +) -> Callable[[float, float], str]: + """Return a reader that says x and each left-axis curve's value there.""" + + def read(x: float, _y: float) -> str: + parts = [f"{xname} {x:.4g}"] + for curve in plot.curves: + cx = np.asarray(curve.x, float) + if curve.right or cx.size == 0 or not (cx[0] <= x <= cx[-1]): + continue + found = float(np.interp(x, cx, np.asarray(curve.y, float))) + name = f"{curve.name} " if curve.name else "" + parts.append(f"{name}{value.format(found)}") + return " ".join(parts) if len(parts) > 1 else "" + + return read diff --git a/croak/gui/readout.py b/croak/gui/readout.py index e272617..4586eed 100644 --- a/croak/gui/readout.py +++ b/croak/gui/readout.py @@ -1,241 +1,131 @@ -"""Numeric read-out strip shown beneath the Retrieve stage's figure. - -The plot pages are a *qualitative* view: it annotates only the FROG error, the -fitted GDD/TOD and the three temporal FWHMs. This strip carries the numbers you -actually compare between runs, and it updates live as the retrieval converges. - -Two blocks, stacked: - -* a **parameter table** with one row per curve drawn in the figure — ``R`` - (retrieved), ``M`` (measured spectrum), ``TL`` (transform-limited) and ``truth`` - — and one column per quantity: peak wavelength, spectral FWHM, pulse FWHM and - peak power. The row labels deliberately match the figure's legend entries so the - table reads as a key to the panels above it. Not every cell is defined: the - measured spectrum has no phase, hence no temporal envelope, and the - transform-limited pulse is the retrieved ``|E(ω)|`` with the phase removed, so - its spectrum *is* the retrieved one and repeating it would imply independent - information. Undefined cells show an en dash. -* a **scalar block**: iteration, elapsed time and FROG error on one line; then the - three forward-model extras (delay-zero offset, slab thickness, geometric - smearing), each marked ``(fit)`` or ``(fixed)`` so it is obvious at a glance - whether the retriever was free to move it; then, when the pulse is known, the - three truth errors (:mod:`croak.truth_metrics`). The last line answers a - different question from the FROG error above it: ``R`` says how well the model - fits the *trace*, the ε's say how close the answer is to the true *pulse*, and - on a non-unique inverse problem those can disagree. - -The widget is pure display: it holds no state beyond the label text and is fed by -:class:`~croak.gui.stage_retrieve.StageRetrieve`. +"""The numbers beneath the Retrieve stage's plots, as large derived values. + +The plots are a *qualitative* view; these are the numbers you compare between +runs, and they move live as the retrieval converges. Each is a small grey name +over a large value (:class:`~croak.gui.sheet.DerivedGrid`), in two rows: + +* **the run and its pulse:** iteration, FROG error R and elapsed time, then the + retrieved spectrum's peak wavelength and the pulse's FWHM; +* **the forward model and the truth:** the three forward-model extras + (delay-zero offset, slab thickness, geometric smearing), each named with + ``fit`` or ``fixed`` so it is obvious whether the retriever was free to move + it; then, when the pulse is known, the three truth errors + (:mod:`croak.truth_metrics`). ``R`` says how well the model fits the *trace*, + the ε's say how close the answer is to the true *pulse*, and on a non-unique + inverse problem those can disagree. + +The widget is pure display, fed by :class:`~croak.gui.stage_retrieve.StageRetrieve`. """ from __future__ import annotations -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QFontDatabase -from PyQt6.QtWidgets import ( - QGridLayout, - QGroupBox, - QHBoxLayout, - QLabel, - QVBoxLayout, - QWidget, -) +from collections.abc import Mapping -__all__ = ["RetrievalReadout", "DASH"] +from PySide6.QtWidgets import QVBoxLayout, QWidget -#: Shown in every cell whose quantity is undefined for that row (the house -#: convention, matching the dispersion stage's power read-outs). -DASH = "–" +from .sheet import DerivedGrid -# Curve rows, keyed to the figure's legend labels. -_ROWS: tuple[tuple[str, str], ...] = ( - ("R", "Retrieved pulse."), - ("M", "Independent measured spectrum (intensity only — no temporal envelope)."), - ("TL", "Transform limit of the retrieved spectrum (its spectrum is R's)."), - ("truth", "Known ground truth, for synthetic and simulated traces."), -) +__all__ = ["DASH", "NUMBERS", "RetrievalReadout"] -# Quantity columns. The peak-power header gains an SI unit when a pulse energy is -# known; the others carry their unit permanently. -_COLUMNS: tuple[tuple[str, str, str], ...] = ( - ("lam_peak", "λ peak (nm)", "Wavelength of the spectral intensity maximum."), - ("lam_fwhm", "λ FWHM (nm)", "Full width at half maximum of the spectrum."), - ("fwhm", "FWHM (fs)", "Full width at half maximum of the temporal intensity."), - ("peak_power", "Peak power", "Needs a pulse energy — set one on the Load stage."), -) +DASH = "–" +"""Shown for a number that is undefined (the house convention).""" -_SCALARS: tuple[tuple[str, str, str], ...] = ( - ("iteration", "Iteration", "Solver iterations completed."), - ("elapsed", "Elapsed", "Wall-clock time since Run was pressed."), - ("error", "Error R", "Normalised FROG error of the current iterate."), +NUMBERS: tuple[tuple[tuple[str, str, str], ...], ...] = ( ( - "tau0", - "τ₀", - "Delay-zero offset of the measured trace: fitted with 'fit τ₀', else 0.", + ("iteration", "Iteration", "Solver iterations completed."), + ("error", "Error R", "Normalised FROG error of the current iterate."), + ("elapsed", "Elapsed", "Wall-clock time since the run started."), + ( + "lam_peak", + "λ peak", + "Wavelength of the retrieved spectrum's intensity maximum.", + ), + ("fwhm", "FWHM", "Full width at half maximum of the retrieved pulse."), ), ( - "thickness", - "Thickness", - "Dispersive-slab thickness the forward model propagated through.", - ), - ( - "smearing", - "Smearing", - "Geometric time-smearing width (delay σ) the forward model convolved with.", - ), - # Known-truth errors: only the synthetic and simulated workflows can fill - # these, and the two spectral ones need the truth's complex field. - ( - "eps_It", - "ε(Iₜ)", - "Temporal-intensity error against the known truth: the energy-normalised " - "rms distance between |E(t)|², blind to energy scale and time origin.", - ), - ( - "eps_Iw", - "ε(I𝜔)", - "Spectral-intensity error against the known truth: |Ẽ(ω)|² over the " - "truth's band, blind to scale and to ALL phase. Needs a complex truth. " - "Scored in the measurement frame: any spectrum_frame_p reweighting is " - "undone first, so a phase-only retrieval reads 0.", - ), - ( - "eps_Ew", - "ε(E𝜔)", - "Complex-field error against the known truth (Geib's ε): minimised over " - "amplitude scale, absolute phase and delay — the three a PNPS " - "measurement cannot determine. Small ε(I𝜔) with large ε(E𝜔) means the " - "amplitude is right and the phase is not. Needs a complex truth. " - "Scored in the measurement frame (spectrum_frame_p undone).", + ( + "tau0", + "τ₀", + "Delay-zero offset of the measured trace: fitted with 'fit τ₀', else 0.", + ), + ( + "thickness", + "Thickness", + "Dispersive-slab thickness the forward model propagated through.", + ), + ( + "smearing", + "Smearing", + "Geometric time-smearing width (delay σ) the forward model convolved " + "with, and its factor on the nominal kernel.", + ), + # Known-truth errors: only the synthetic and simulated workflows can fill + # these, and the two spectral ones need the truth's complex field. + ( + "eps_It", + "ε(Iₜ)", + "Temporal-intensity error against the known truth: the energy-" + "normalised rms distance between |E(t)|², blind to energy scale and " + "time origin.", + ), + ( + "eps_Iw", + "ε(I𝜔)", + "Spectral-intensity error against the known truth: |Ẽ(ω)|² over the " + "truth's band, blind to scale and to ALL phase. Needs a complex truth. " + "Scored in the measurement frame: any spectrum_frame_p reweighting is " + "undone first, so a phase-only retrieval reads 0.", + ), + ( + "eps_Ew", + "ε(E𝜔)", + "Complex-field error against the known truth (Geib's ε): minimised " + "over amplitude scale, absolute phase and delay — the three a PNPS " + "measurement cannot determine. Small ε(I𝜔) with large ε(E𝜔) means " + "the amplitude is right and the phase is not. Needs a complex truth. " + "Scored in the measurement frame (spectrum_frame_p undone).", + ), ), ) +"""The rows of numbers: ``(key, name, tooltip)`` for each, in reading order.""" - -def _cell(*, bold: bool = False, fixed_width: bool = False) -> QLabel: - """A right-aligned read-out label (fixed-width digits keep columns lined up).""" - label = QLabel(DASH if not bold else "") - label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - if bold: - font = label.font() - font.setBold(True) - label.setFont(font) - elif fixed_width: - label.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) - return label +_NAMES = {key: name for row in NUMBERS for key, name, _tip in row} class RetrievalReadout(QWidget): - """The parameter table and scalar read-outs beneath the retrieval figure. - - Built from plain :class:`~PyQt6.QtWidgets.QLabel` grids rather than a table - widget: the labels size themselves to their content, so the strip stays the - few-line height the figure can spare without any per-style height arithmetic, - and a refresh is a handful of ``setText`` calls with no item allocation. + """The retrieval's numbers; every update is safe to make at any time.""" - All three update methods are safe to call in any order and at any time; the - widget starts, and :meth:`clear` returns it to, an all-:data:`DASH` state. - """ - - def __init__(self): + def __init__(self) -> None: super().__init__() - outer = QVBoxLayout(self) - outer.setContentsMargins(0, 2, 0, 2) - outer.setSpacing(2) - - row = QWidget() - row_layout = QHBoxLayout(row) - row_layout.setContentsMargins(0, 0, 0, 0) - row_layout.addWidget(self._build_table(), stretch=1) - outer.addWidget(row) - outer.addWidget(self._build_scalars()) - - # -- construction ------------------------------------------------------- - def _build_table(self) -> QGroupBox: - box = QGroupBox("Pulse parameters") - grid = QGridLayout(box) - grid.setContentsMargins(8, 2, 8, 4) - grid.setHorizontalSpacing(16) - grid.setVerticalSpacing(1) - - self._header: dict[str, QLabel] = {} - for col, (key, title, tip) in enumerate(_COLUMNS, start=1): - head = _cell(bold=True) - head.setText(title) - head.setToolTip(tip) - grid.addWidget(head, 0, col) - self._header[key] = head - - self._cells: dict[tuple[str, str], QLabel] = {} - for r, (name, tip) in enumerate(_ROWS, start=1): - label = QLabel(name) - label.setToolTip(tip) - font = label.font() - font.setBold(True) - label.setFont(font) - grid.addWidget(label, r, 0) - for col, (key, _title, _tip) in enumerate(_COLUMNS, start=1): - cell = _cell(fixed_width=True) - cell.setToolTip(tip) - grid.addWidget(cell, r, col) - self._cells[name, key] = cell - grid.setColumnStretch(len(_COLUMNS) + 1, 1) - return box - - def _build_scalars(self) -> QWidget: - # A bare grid rather than a second group box: the "Name:" labels are - # self-describing, and dropping the title and frame keeps the whole strip - # to the few lines the figure can spare. - box = QWidget() - grid = QGridLayout(box) - grid.setContentsMargins(10, 0, 8, 0) - grid.setHorizontalSpacing(8) - grid.setVerticalSpacing(2) - self._scalars: dict[str, QLabel] = {} - # Two rows of three (name, value) pairs: the solver's progress on top, the - # forward model's fitted-or-fixed extras beneath. - for i, (key, title, tip) in enumerate(_SCALARS): - r, c = divmod(i, 3) - name = QLabel(f"{title}:") - name.setToolTip(tip) - value = _cell() - value.setAlignment( - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter - ) - value.setToolTip(tip) - grid.addWidget(name, r, 2 * c) - grid.addWidget(value, r, 2 * c + 1) - self._scalars[key] = value - grid.setColumnStretch(6, 1) - return box - - # -- updates ------------------------------------------------------------ + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.grid = DerivedGrid(columns=max(len(row) for row in NUMBERS)) + for row in NUMBERS: + for _key, name, tip in row: + self.grid.add(name, tip) + self.grid.end_row() + layout.addWidget(self.grid) + + def text(self, key: str) -> str: + """The value shown for ``key``.""" + return self.grid.values[_NAMES[key]].text() + + def name(self, key: str) -> str: + """The name shown over ``key``, with any note.""" + return self.grid.names[_NAMES[key]].text() + def clear(self) -> None: - """Blank every cell and scalar back to :data:`DASH`.""" - for cell in self._cells.values(): - cell.setText(DASH) - for cell in self._scalars.values(): - cell.setText(DASH) - self.set_power_unit(None) - - def set_power_unit(self, unit: str | None) -> None: - """Put the shared SI power prefix in the peak-power column header. - - One prefix for the whole column (rather than a unit per cell) keeps the - numbers comparable at a glance and the digits aligned. - """ - title = "Peak power" if unit is None else f"Peak power ({unit})" - self._header["peak_power"].setText(title) - - def set_cells(self, row: str, values: dict[str, str]) -> None: - """Set one curve row from ``{column key: formatted text}``. - - Columns absent from ``values`` are reset to :data:`DASH`, so a caller need - only supply what that row defines. - """ - for key, _title, _tip in _COLUMNS: - self._cells[row, key].setText(values.get(key, DASH)) - - def set_scalars(self, values: dict[str, str]) -> None: - """Set the named scalar read-outs; keys absent are left untouched.""" + """Show every number as :data:`DASH`, with no notes.""" + self.grid.clear() + for name in _NAMES.values(): + self.grid.note(name, "") + + def set_scalars(self, values: Mapping[str, str]) -> None: + """Show ``{key: text}``; keys absent are left as they are.""" for key, text in values.items(): - self._scalars[key].setText(text) + self.grid.set(_NAMES[key], text) + + def set_notes(self, notes: Mapping[str, str]) -> None: + """Add ``{key: note}`` to the numbers' names, e.g. ``fit`` or ``fixed``.""" + for key, note in notes.items(): + self.grid.note(_NAMES[key], note) diff --git a/croak/gui/resources/croak.svg b/croak/gui/resources/croak.svg index a49983f..f6cf1b1 100644 --- a/croak/gui/resources/croak.svg +++ b/croak/gui/resources/croak.svg @@ -2,7 +2,7 @@ croak diff --git a/croak/gui/retrieval_views.py b/croak/gui/retrieval_views.py new file mode 100644 index 0000000..0bafa20 --- /dev/null +++ b/croak/gui/retrieval_views.py @@ -0,0 +1,285 @@ +"""The retrieval's panels on screen, in pyqtgraph. + +:class:`RetrievalPanels` shows a grid of the retrieval's panels, named by the +same keys as the matplotlib figure (:data:`croak.plotting.RETRIEVAL_PANELS`), +from the screen data of :mod:`croak.plotting.screen`. A live retrieval updates +it in place; before the first preview it shows the convergence curve instead. +:class:`ResultPage` puts a grid on Analyse's rail, with export and pop-out. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import cast + +from matplotlib.figure import Figure +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QGridLayout, + QHBoxLayout, + QMenu, + QStackedWidget, + QToolButton, + QVBoxLayout, + QWidget, +) + +from ..plotting import ( + RETRIEVAL_PAGES, + PageSpec, + RetrievalPlotData, + plot_retrieval_page, +) +from ..plotting import screen as sc +from .display import current as current_display +from .stage import PageBase +from .state import WizardState +from .theme import label +from .view_panel import ViewPanel + +__all__ = [ + "OVERVIEW", + "PANELS", + "Panel", + "ResultPage", + "RetrievalPanels", +] + +OVERVIEW: tuple[tuple[str, ...], ...] = ( + ("measured_log", "retrieved_log"), + ("temporal", "spectral"), +) +"""The live overview: the traces above the pulse and its spectrum.""" + + +@dataclass(frozen=True) +class Panel: + """One panel: how to build its view, and how to draw it.""" + + view: Callable[[RetrievalPlotData], sc.MapView | sc.LineView | None] + kind: str # "map" or "line" + colours: str = "viridis" + signed: bool = False + white: bool = True + right: bool = False + note: Callable[[RetrievalPlotData], str] | None = None + + +def _retrieved_note(data: RetrievalPlotData) -> str: + rows, cols = data.retrieved.shape + return f"R {data.result.error:.2%} · {rows}×{cols}" + + +PANELS: Mapping[str, Panel] = { + "measured_log": Panel(lambda d: sc.trace_map(d, "measured", db=True), "map"), + "retrieved_log": Panel(lambda d: sc.trace_map(d, "retrieved", db=True), "map"), + "measured_lin": Panel( + lambda d: sc.trace_map(d, "measured", db=False), "map", signed=True + ), + "retrieved_lin": Panel( + lambda d: sc.trace_map(d, "retrieved", db=False), + "map", + signed=True, + note=_retrieved_note, + ), + "temporal": Panel(sc.temporal_view, "line", right=True), + "spectral": Panel(sc.spectral_view, "line", right=True), + "delay_marginal": Panel(sc.delay_marginal_view, "line"), + "freq_marginal": Panel(sc.frequency_marginal_view, "line"), + "convergence": Panel( + lambda d: sc.convergence_view(d.processed.errors, d.result.stage_boundaries), + "line", + ), + "residual": Panel(sc.residual_map, "map", colours="bwr", white=False), + "spectral_filter": Panel(sc.filter_view, "line"), + "spectrogram": Panel(sc.spectrogram_map, "map"), +} +"""The panels the screen can show, by the figure's keys.""" + + +class PanelBox(ViewPanel): + """One of the retrieval's panels (:data:`PANELS`).""" + + def __init__(self, key: str) -> None: + spec = PANELS[key] + super().__init__( + spec.kind, + colours=spec.colours, + signed=spec.signed, + white=spec.white, + right=spec.right, + ) + self.key = key + self.spec = spec + + def show_data(self, data: RetrievalPlotData) -> None: + """Draw this panel's view of ``data``.""" + view = self.spec.view(data) + if view is None: # only the residual can be missing + self.say("Residuals", "No residual for this result.") + return + note = self.spec.note(data) if self.spec.note is not None else "" + self.show_view(view, note) + + +class RetrievalPanels(QWidget): + """A grid of retrieval panels, or a note while there is nothing to show. + + Parameters + ---------- + mosaic : sequence of sequence of str + Rows of panel keys (:data:`PANELS`). + """ + + def __init__(self, mosaic: Sequence[Sequence[str]] = OVERVIEW) -> None: + super().__init__() + self.stack = QStackedWidget() + grid_widget = QWidget() + grid = QGridLayout(grid_widget) + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(14) + grid.setVerticalSpacing(10) + self.boxes: dict[str, PanelBox] = {} + for r, row in enumerate(mosaic): + for c, key in enumerate(row): + box = PanelBox(key) + grid.addWidget(box, r, c) + self.boxes[key] = box + self._link_maps() + self.empty = label("Run a retrieval to see it here.", "note") + self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + for widget in (self.empty, grid_widget): + self.stack.addWidget(widget) + self.grid_widget = grid_widget + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.stack) + self.data: RetrievalPlotData | None = None + + def _link_maps(self) -> None: + """Trace maps share delay and frequency axes: zooming one zooms all.""" + maps = [ + b.plot + for k, b in self.boxes.items() + if k in ("measured_log", "retrieved_log", "measured_lin", "retrieved_lin") + ] + for other in maps[1:]: + other.setXLink(maps[0]) + other.setYLink(maps[0]) + + @property + def mode(self) -> str: + """``"empty"`` or ``"panels"``.""" + return ("empty", "panels")[self.stack.currentIndex()] + + def set_data(self, data: RetrievalPlotData) -> None: + """Show ``data`` on every panel (cheap enough for a live preview).""" + self.data = data + for box in self.boxes.values(): + box.show_data(data) + self.stack.setCurrentWidget(self.grid_widget) + + def clear(self) -> None: + """Forget the shown result.""" + self.data = None + self.stack.setCurrentWidget(self.empty) + + +class ResultPage(PageBase): + """An Analyse page: one of the figure's pages of panels, with export. + + Parameters + ---------- + state : WizardState + The session. + key : str + The figure page (:data:`croak.plotting.RETRIEVAL_PAGES`), or + ``"overview"`` for :data:`OVERVIEW`. + plot_data : callable + Returns the current retrieval's plot data, or ``None`` without one. + """ + + def __init__( + self, + state: WizardState, + key: str, + plot_data: Callable[[], RetrievalPlotData | None], + ) -> None: + super().__init__(state) + self.key = key + self.plot_data = plot_data + self.mosaic = OVERVIEW if key == "overview" else RETRIEVAL_PAGES[key].mosaic + self.title = "Overview" if key == "overview" else RETRIEVAL_PAGES[key].title + self.popouts: list[QDialog] = [] + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 10, 16, 10) + layout.setSpacing(8) + head = QHBoxLayout() + head.addWidget(label(self.title, "h2")) + head.addStretch(1) + self.export = QToolButton() + self.export.setObjectName("plain") + self.export.setText("Export ▾") + self.export.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + menu = QMenu(self.export) + menu.addAction("Figure…", self.export_figure) + menu.addAction("Pop out", self.pop_out) + self.export.setMenu(menu) + head.addWidget(self.export) + layout.addLayout(head) + self.panels = RetrievalPanels(self.mosaic) + layout.addWidget(self.panels, stretch=1) + state.result_changed.connect(self._stale) + + def _stale(self) -> None: + """Redraw now if on screen; otherwise when next shown.""" + if self.isVisible(): + self.on_enter() + + def on_enter(self) -> None: + data = self.plot_data() + if data is None: + self.panels.clear() + return + self.panels.set_data(data) + for dialog in self.popouts: + cast(RetrievalPanels, dialog.findChild(RetrievalPanels)).set_data(data) + + def pop_out(self) -> QDialog: + """Open this page in a window of its own that follows later results.""" + dialog = QDialog(self) + dialog.setWindowTitle(f"croak — {self.title}") + dialog.resize(900, 640) + layout = QVBoxLayout(dialog) + panels = RetrievalPanels(self.mosaic) + layout.addWidget(panels) + data = self.plot_data() + if data is not None: + panels.set_data(data) + dialog.finished.connect(lambda _code: self.popouts.remove(dialog)) + self.popouts.append(dialog) + dialog.show() + return dialog + + def figure(self) -> Figure | None: + """The page as croak's own figure of it (:func:`plot_retrieval_page`). + + croak's figures draw the traces against frequency; with the display on + wavelength, the page's plots are exported as they are shown instead. + """ + data = self.plot_data() + if data is None: + return None + if current_display().axis == "wavelength": + return super().figure() + spec = ( + PageSpec("overview", "Overview", self.mosaic) + if self.key == "overview" + else RETRIEVAL_PAGES[self.key] + ) + return plot_retrieval_page(data, spec) + + def figure_name(self) -> str: + return self.key diff --git a/croak/gui/settings.py b/croak/gui/settings.py index a7e486c..f57567d 100644 --- a/croak/gui/settings.py +++ b/croak/gui/settings.py @@ -1,13 +1,14 @@ -"""Persistent user preferences: window geometry, control-column width, folded strips. +"""Persistent user preferences: window geometry, folded sections, recent sessions. -The wizard remembers how its user last arranged it — the un-maximised window -size and position, the width of the control column, which auxiliary strips were -folded — so a small-screen user sets things up once. Values live in a -:class:`~PyQt6.QtCore.QSettings` store in INI format under the user scope, keyed +The window remembers how its user last arranged it — the un-maximised window +size and position, which sections and strips were folded, the spectral axis — +and the sessions last saved or opened, so a small-screen user sets things up +once. Values live in a +:class:`~PySide6.QtCore.QSettings` store in INI format under the user scope, keyed by the organisation and application names from :mod:`.branding`; passing those explicitly means the store is the same whether or not :func:`~croak.gui.branding.apply_identity` has run (the tests build a -:class:`~croak.gui.wizard.Wizard` without it). +:class:`~croak.gui.window.MainWindow` without it). Every function opens a short-lived ``QSettings``, reads or writes one value and syncs, so the side effects stay at the edges and the module keeps no state. Only @@ -19,45 +20,46 @@ from __future__ import annotations -from PyQt6.QtCore import QByteArray, QSettings, QSize -from PyQt6.QtGui import QGuiApplication -from PyQt6.QtWidgets import QWidget +import json + +from PySide6.QtCore import QByteArray, QSettings, QSize +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import QWidget from .branding import APP_NAME, ORGANISATION_NAME +from .workflow import remember_recent __all__ = [ "KEY_WINDOW_GEOMETRY", - "KEY_CONTROLS_WIDTH", "DEFAULT_WINDOW_SIZE", - "DEFAULT_CONTROLS_WIDTH", "SCREEN_FILL", "user_settings", "fit_to_screen", "restore_window", "save_window", - "controls_width", - "set_controls_width", "value", "set_value", "text", "set_text", + "KEY_RECENT_SESSIONS", + "recent_sessions", + "remember_session", ] #: Settings key holding ``QWidget.saveGeometry()`` of the main window. KEY_WINDOW_GEOMETRY = "window/geometry" -#: Settings key holding the control column's width in logical pixels. -KEY_CONTROLS_WIDTH = "stage/controls_width" +#: Settings key holding the recently saved or opened sessions (``options.toml`` +#: paths, newest first) as JSON text. +KEY_RECENT_SESSIONS = "home/recent_sessions" #: First-run window size (logical px) before clamping to the screen; large enough #: for the plot pages at full text density on a 1920x1080 display. DEFAULT_WINDOW_SIZE = QSize(1500, 950) -#: First-run width (logical px) of the control column on every stage. -DEFAULT_CONTROLS_WIDTH = 315 #: Fraction of the screen's available area a first-run window may fill. SCREEN_FILL = 0.9 def user_settings() -> QSettings: - """Open the wizard's settings store (INI, user scope, explicit names).""" + """Open the GUI's settings store (INI, user scope, explicit names).""" return QSettings( QSettings.Format.IniFormat, QSettings.Scope.UserScope, @@ -126,18 +128,6 @@ def save_window(window: QWidget) -> None: store.sync() -def controls_width() -> int: - """The remembered control-column width (logical px), else the default.""" - return int(user_settings().value(KEY_CONTROLS_WIDTH, DEFAULT_CONTROLS_WIDTH, int)) - - -def set_controls_width(px: int) -> None: - """Remember the control-column width (logical px) for every stage and launch.""" - store = user_settings() - store.setValue(KEY_CONTROLS_WIDTH, int(px)) - store.sync() - - def value(key: str, default: bool) -> bool: """Read a remembered flag (e.g. whether a strip is expanded).""" return bool(user_settings().value(key, default, bool)) @@ -160,3 +150,26 @@ def set_text(key: str, choice: str) -> None: store = user_settings() store.setValue(key, str(choice)) store.sync() + + +def recent_sessions() -> list[str]: + """The recently saved or opened sessions' ``options.toml`` paths, newest first. + + Stored as JSON text: an INI list of one item reads back as a bare string, and + a hand-edited or corrupt value reads as no history rather than an error. + """ + raw = text(KEY_RECENT_SESSIONS, "[]") + try: + paths = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(paths, list): + return [] + return [p for p in paths if isinstance(p, str)] + + +def remember_session(path: str) -> list[str]: + """Put ``path`` first in the recent sessions and return the new list.""" + paths = remember_recent(recent_sessions(), path) + set_text(KEY_RECENT_SESSIONS, json.dumps(paths)) + return paths diff --git a/croak/gui/sheet.py b/croak/gui/sheet.py new file mode 100644 index 0000000..297983e --- /dev/null +++ b/croak/gui/sheet.py @@ -0,0 +1,488 @@ +"""The building blocks of a stage: a form of folding sections beside its plots. + +A :class:`Sheet` is the layout every stage shares: a :class:`Form` on the left +(folding :class:`Section` s of rows, a one-line help for the focused control and +a :class:`ReadyLine`) and the stage's plots on the right, which move below the +form when the window is narrow. + +A section's rows live in an ordinary :class:`~PySide6.QtWidgets.QFormLayout` +(:attr:`Section.form`), so a stage adds them with ``form.addRow(label, widget)``. +Help text is attached afterwards by :func:`croak.gui.help.apply_tooltips`, keyed +by ``(section title, row label)``; the one-line help under the form shows the +tooltip of whatever has focus, so the tooltip is the help's single source. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import cast + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QApplication, + QBoxLayout, + QCheckBox, + QComboBox, + QFormLayout, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from . import settings +from .theme import label, set_flag + +__all__ = [ + "AdviceBox", + "DerivedGrid", + "Form", + "ReadyLine", + "Section", + "Segments", + "Sheet", + "plain", + "row_field", + "row_text", +] + +COMBO_MIN_CHARS = 6 +"""The characters a combo box keeps when the form column squeezes it.""" + +# Qt's QWIDGETSIZE_MAX, the "no limit" of setMaximumWidth; PySide6 does not +# export the constant. +_NO_LIMIT = 16_777_215 + +NARROW_WIDTH = 1000 +"""Below this window width (logical px) a sheet's plots move under its form.""" + +FORM_WIDTH = 340 +"""The form column's width beside the plots, logical px: room for a label and +its editor, and no more, so the plots get the rest of the window.""" + + +def plain(text: str) -> str: + """Undo Qt's ``&&`` escape, giving a label as the user reads it. + + Qt reads a lone ``&`` in a check box, button or section title as a keyboard + mnemonic, so a literal ampersand is written ``&&``; help keys are written + the way the user reads them. + + Examples + -------- + >>> plain("Skip filtering && regrid") + 'Skip filtering & regrid' + """ + return text.replace("&&", "&") + + +def row_text(form: QFormLayout, row: int) -> str: + """The label of ``form``'s row ``row``: its label, else a check box's own text. + + A row with neither (a button row, a note) has no label: ``""``. + """ + item = form.itemAt(row, QFormLayout.ItemRole.LabelRole) + label_widget = item.widget() if item is not None else None + if isinstance(label_widget, QLabel): + return plain(label_widget.text()) + field = row_field(form, row) + return plain(field.text()) if isinstance(field, QCheckBox) else "" + + +def row_field(form: QFormLayout, row: int) -> QWidget | None: + """The widget in ``form``'s row ``row``: its field, or one spanning the row.""" + for role in (QFormLayout.ItemRole.FieldRole, QFormLayout.ItemRole.SpanningRole): + item = form.itemAt(row, role) + widget = item.widget() if item is not None else None + if widget is not None: + return widget + return None + + +class Section(QWidget): + """A titled group of form rows that folds open and shut in place. + + Parameters + ---------- + title : str + Its heading (``&&`` for a literal ampersand); the first half of its + rows' help keys. + folded : bool + Its initial state; a remembered state (``settings_key``) wins. + settings_key : str + Remember the fold state under this key ("" to not remember it). + """ + + toggled = Signal(bool) + + def __init__( + self, title: str, *, folded: bool = False, settings_key: str = "" + ) -> None: + super().__init__() + self.section_title = plain(title) + self._key = settings_key + if settings_key: + folded = not settings.value(settings_key, not folded) + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 8) + outer.setSpacing(0) + self.header = QToolButton() + self.header.setObjectName("sectionHeader") + self.header.setText(title) + self.header.setCheckable(True) + self.header.setChecked(not folded) + self.header.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.header.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.header.toggled.connect(self._fold) + outer.addWidget(self.header) + self.body = QWidget() + self.form = QFormLayout(self.body) + self.form.setContentsMargins(14, 2, 0, 2) + self.form.setHorizontalSpacing(10) + self.form.setVerticalSpacing(6) + self.form.setLabelAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + # Grow only the fields that ask for room (sliders, paths): the macOS + # style's default would size every field to its hint instead. + self.form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow + ) + outer.addWidget(self.body) + self._fold(not folded, remember=False) + + @property + def folded(self) -> bool: + """Whether the rows are hidden.""" + return not self.header.isChecked() + + def set_folded(self, folded: bool) -> None: + """Fold or unfold (remembered, when the section has a key).""" + self.header.setChecked(not folded) + + def _fold(self, open_: bool, remember: bool = True) -> None: + self.header.setArrowType( + Qt.ArrowType.DownArrow if open_ else Qt.ArrowType.RightArrow + ) + self.body.setVisible(open_) + if remember and self._key: + settings.set_value(self._key, open_) + self.toggled.emit(open_) + + def filter(self, query: str) -> int: + """Show only the rows whose label or help contains ``query``; count them. + + An empty ``query``, or one in the section's own title, shows every row. + """ + query = query.casefold() + if query in self.section_title.casefold(): + query = "" + hits = 0 + for row in range(self.form.rowCount()): + field = row_field(self.form, row) + tip = field.toolTip() if field is not None else "" + hit = not query or query in f"{row_text(self.form, row)}\n{tip}".casefold() + self.form.setRowVisible(row, hit) + hits += hit + return hits + + +class Form(QFrame): + """The left column of a sheet: sections in a scroll area, help, ready line. + + Parameters + ---------- + find : bool + Offer "Find a setting", which shows only the rows that match. + """ + + def __init__(self, *, find: bool = False) -> None: + super().__init__() + self.setObjectName("form") + self.setMinimumWidth(FORM_WIDTH) + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + self.find: QLineEdit | None = None + if find: + self.find = QLineEdit() + self.find.setObjectName("find") + self.find.setPlaceholderText("Find a setting") + self.find.setClearButtonEnabled(True) + self.find.textChanged.connect(self.filter) + box = QWidget() + bl = QVBoxLayout(box) + bl.setContentsMargins(18, 12, 14, 0) + bl.addWidget(self.find) + outer.addWidget(box) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + content = QWidget() + self.content = QVBoxLayout(content) + self.content.setContentsMargins(18, 10, 14, 10) + self.content.setSpacing(2) + scroll.setWidget(content) + outer.addWidget(scroll, stretch=1) + foot = QWidget() + fl = QVBoxLayout(foot) + fl.setContentsMargins(18, 8, 14, 12) + fl.setSpacing(6) + self.help = label("", "help", wrap=True) + self.help.setMinimumHeight(32) + self.help.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + self.ready = ReadyLine() + fl.addWidget(self.help) + fl.addWidget(self.ready) + outer.addWidget(foot) + # A widget exists only under a QApplication; the stubs allow None. + cast(QApplication, QApplication.instance()).focusChanged.connect( + self._focus_moved + ) + + @property + def sections(self) -> list[Section]: + """The form's sections, in order.""" + return self.findChildren(Section) + + def add_section( + self, title: str, *, folded: bool = False, settings_key: str = "" + ) -> Section: + """Append a section and return it.""" + section = Section(title, folded=folded, settings_key=settings_key) + self.content.addWidget(section) + return section + + def add_widget(self, widget: QWidget) -> None: + """Append a widget outside any section (a button row, a note, …).""" + self.content.addWidget(widget) + + def _focus_moved(self, _old: QWidget | None, new: QWidget | None) -> None: + """Show the help of the newly focused control, if it is on this form.""" + widget = new + while widget is not None and widget is not self: + if widget.toolTip(): + if self.isAncestorOf(widget): + self.help.setText(widget.toolTip()) + return + widget = widget.parentWidget() + + def showEvent(self, event) -> None: # noqa: N802, ANN001 — Qt override + """Let every combo box shrink to the column. + + A combo is as wide as its longest item by default, which would push the + fixed-width column into clipping; elided to the column, the full text + is in its drop-down. Done on show, so it covers combos filled late. + """ + for box in self.findChildren(QComboBox): + box.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + box.setMinimumContentsLength(COMBO_MIN_CHARS) + # Qt keeps the minimum size hint it computed before the policy + # changed; an explicit minimum width, from the fresh size hint, + # is what the layout then uses. + box.setMinimumWidth(box.sizeHint().width()) + super().showEvent(event) + + def filter(self, query: str) -> None: + """Show only the rows whose label or help contains ``query``.""" + query = query.strip() + for section in self.sections: + hits = section.filter(query) + section.setVisible(not query or hits > 0) + section.body.setVisible(bool(query and hits) or not section.folded) + + +class Sheet(QWidget): + """A form beside the stage's plots; the plots move below on a narrow window.""" + + def __init__(self, form: Form, plots: QWidget) -> None: + super().__init__() + self.form = form + self.plots = plots + self.split = QBoxLayout(QBoxLayout.Direction.LeftToRight, self) + self.split.setContentsMargins(0, 0, 0, 0) + self.split.setSpacing(0) + self.split.addWidget(form) + self.split.addWidget(plots, stretch=1) + form.setMaximumWidth(FORM_WIDTH) + + @property + def narrow(self) -> bool: + """Whether the plots are below the form.""" + return self.split.direction() == QBoxLayout.Direction.TopToBottom + + def resizeEvent(self, event) -> None: # noqa: N802, ANN001 — Qt override + narrow = self.width() < NARROW_WIDTH + if narrow != self.narrow: + self.split.setDirection( + QBoxLayout.Direction.TopToBottom + if narrow + else QBoxLayout.Direction.LeftToRight + ) + # Stacked, the form spans the window; beside the plots it keeps + # its fixed width. + self.form.setMaximumWidth(_NO_LIMIT if narrow else FORM_WIDTH) + super().resizeEvent(event) + + +class ReadyLine(QLabel): + """Whether the stage can go on: green, amber with a warning, red when not.""" + + def __init__(self) -> None: + super().__init__() + self.setObjectName("ready") + self.setWordWrap(True) + self.state = "ok" + + def say(self, text: str, state: str = "ok") -> None: + """Show ``text`` as ``state``: ``"ok"``, ``"warn"`` or ``"error"``. + + Raises + ------ + ValueError + For any other state. + """ + if state not in ("ok", "warn", "error"): + raise ValueError(f"state must be ok, warn or error, not {state!r}") + self.state = state + self.setText(text) + set_flag(self, "warn", state == "warn") + set_flag(self, "error", state == "error") + + +class AdviceBox(QFrame): + """An amber box above the plots: what went wrong and what to do about it.""" + + def __init__(self) -> None: + super().__init__() + self.setObjectName("advice") + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 10, 14, 10) + layout.setSpacing(2) + self.title = label("", "h3") + self.text = label("", "note", wrap=True) + self.text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + layout.addWidget(self.title) + layout.addWidget(self.text) + self.hide() + + def say(self, title: str, text: str) -> None: + """Show the box.""" + self.title.setText(title) + self.text.setText(text) + self.show() + + def clear(self) -> None: + """Hide the box: there is nothing to advise.""" + self.title.setText("") + self.text.setText("") + self.hide() + + +class DerivedGrid(QWidget): + """Numbers derived from the settings: a small grey name over a large value.""" + + def __init__(self, columns: int = 4) -> None: + super().__init__() + self.columns = columns + self.grid = QGridLayout(self) + self.grid.setContentsMargins(0, 0, 0, 0) + self.grid.setHorizontalSpacing(24) + self.grid.setVerticalSpacing(8) + self.values: dict[str, QLabel] = {} + self.names: dict[str, QLabel] = {} + self._next = 0 # the next free cell, counted row by row + + def add(self, name: str, tooltip: str = "") -> QLabel: + """Add a number called ``name`` (shown as an en dash until set).""" + cell = QWidget() + cl = QVBoxLayout(cell) + cl.setContentsMargins(0, 0, 0, 0) + cl.setSpacing(0) + self.names[name] = label(name, "derivedLabel") + cl.addWidget(self.names[name]) + value = label("–", "derivedValue") + value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + cl.addWidget(value) + if tooltip: + cell.setToolTip(tooltip) + row, column = divmod(self._next, self.columns) + self.grid.addWidget(cell, row, column) + self._next += 1 + self.values[name] = value + return value + + def end_row(self) -> None: + """Start the next number on a new row (a group of its own).""" + self._next = -(-self._next // self.columns) * self.columns + + def set(self, name: str, text: str, state: str = "ok") -> None: + """Show ``text`` for ``name``; ``state`` ``"warn"``/``"error"`` colours it.""" + value = self.values[name] + value.setText(text) + set_flag(value, "warn", state == "warn") + set_flag(value, "error", state == "error") + + def note(self, name: str, text: str) -> None: + """Add ``text`` to the name of ``name`` ("τ₀ · fit"); ``""`` removes it.""" + self.names[name].setText(f"{name} · {text}" if text else name) + + def clear(self) -> None: + """Show every number as unknown.""" + for name in self.values: + self.set(name, "–") + + +class Segments(QFrame): + """A row of joined buttons choosing one of a few values. + + Parameters + ---------- + choices : sequence of (value, text) + The values and their captions, in order. + current : str + The value chosen at first. + """ + + changed = Signal(str) + + def __init__(self, choices: Sequence[tuple[str, str]], current: str) -> None: + super().__init__() + self.setObjectName("segments") + self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) + layout = QHBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(2) + self.buttons: dict[str, QPushButton] = {} + for value, text in choices: + b = QPushButton(text) + b.setObjectName("segment") + b.setFocusPolicy(Qt.FocusPolicy.NoFocus) + b.clicked.connect(lambda _=False, v=value: self.set_value(v, emit=True)) + layout.addWidget(b) + self.buttons[value] = b + if current not in self.buttons: + raise ValueError(f"{current!r} is not one of {list(self.buttons)}") + self.value = "" + self.set_value(current) + + def set_value(self, value: str, *, emit: bool = False) -> None: + """Choose ``value``; with ``emit``, say so (only when it changed).""" + if value not in self.buttons: + raise ValueError(f"{value!r} is not one of {list(self.buttons)}") + changed = value != self.value + self.value = value + for key, b in self.buttons.items(): + set_flag(b, "current", key == value) + if emit and changed: + self.changed.emit(value) diff --git a/croak/gui/stage.py b/croak/gui/stage.py new file mode 100644 index 0000000..24de14e --- /dev/null +++ b/croak/gui/stage.py @@ -0,0 +1,159 @@ +"""The base of a page in the workflow window. + +The window owns the navigation and the one primary action; a page says what it +can do through a few hooks (:class:`PageBase`) and does it in :meth:`~PageBase.act`. +Most pages are a :class:`StagePage`: a :class:`~croak.gui.sheet.Sheet` with a form +of folding sections on the left and, on the right, an advice box over the +stage's plots. Pages made of other pages +(Data's loader, Analyse's rail) build on :class:`PageBase` directly. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QVBoxLayout, QWidget + +from .help import HelpDialog, HelpSections +from .sheet import AdviceBox, Form, Sheet +from .state import WizardState +from .view_panel import export_figure, file_name, page_figure + +if TYPE_CHECKING: + from matplotlib.figure import Figure + + from ..plotting import RetrievalPlotData + +__all__ = ["PageBase", "StagePage"] + + +class PageBase(QWidget): + """What the window asks of a page. + + Attributes + ---------- + changed : Signal + Emitted when what the page can do changes (it can now go on, a run + started or ended), so the window re-reads the hooks. + message : Signal(str) + A passing notice for the window's status bar. + moved_on : Signal + The page handed its work on by itself (an entry loader's own forward + action did), so the window goes to the next stage. + """ + + HELP_TITLE: str = "" + """The page's name in its help dialog.""" + HELP_INTRO: str = "" + """One paragraph on what the page is for.""" + HELP: HelpSections = [] + """Per-setting help, ``[(section, [(label, text), ...]), ...]``.""" + + changed = Signal() + message = Signal(str) + moved_on = Signal() + + def __init__(self, state: WizardState) -> None: + super().__init__() + self.state = state + self._help_dialog: HelpDialog | None = None + + def on_enter(self) -> None: + """Refresh as the page comes on screen (default: nothing to do).""" + + def can_advance(self) -> bool: + """Whether the primary action can be pressed now (default: yes).""" + return True + + def busy(self) -> bool: + """Whether the page is running work the primary action would stop.""" + return False + + def page_key(self) -> str: + """Which sub-page is shown, for pages that have several (default: "").""" + return "" + + def save_extras(self, folder: Path) -> list[str]: + """Write what the page adds to a saved session into ``folder``. + + Called by Save session after the session's own files; returns the names + of the files written (by default none). + """ + return [] + + def act(self, kind: str) -> bool: + """Do the primary action ``kind``; return whether the window moves on. + + ``kind`` is a :class:`croak.gui.workflow.Action` kind. By default + ``"continue"`` has nothing to do here and the window moves to the next + stage; a page with steps of its own moves through them first. + + Raises + ------ + ValueError + If this page has no such action. + """ + if kind == "continue": + return True + raise ValueError(f"{type(self).__name__} has no {kind!r} action") + + def overview(self) -> RetrievalPlotData | None: + """The retrieval this page shows, for a saved session's overview PDF.""" + return None + + def figure(self) -> Figure | None: + """The page as a publication figure; ``None`` while it shows nothing. + + By default every plot on the page, laid out as on the screen + (:func:`~croak.gui.view_panel.page_figure`). + """ + return page_figure(self) + + def figure_name(self) -> str: + """The file name a saved figure of the page is offered under.""" + return file_name(self.HELP_TITLE) + + def export_figure(self) -> str: + """Ask where to save :meth:`figure`; return the path, or ``""``.""" + return export_figure(self, self.figure(), self.figure_name(), self.message.emit) + + def show_help(self) -> HelpDialog | None: + """Open (or raise) the page's help dialog; ``None`` if it has no help.""" + if not self.HELP: + return None + if self._help_dialog is None: + self._help_dialog = HelpDialog( + self.HELP_TITLE, self.HELP_INTRO, self.HELP, self + ) + self._help_dialog.show() + self._help_dialog.raise_() + return self._help_dialog + + +class StagePage(PageBase): + """A page that is a sheet: its form beside its plots. + + Subclasses fill :attr:`form` with sections, add their plots with + :meth:`set_plots`, and override the hooks they need. + """ + + def __init__(self, state: WizardState, *, find: bool = False) -> None: + super().__init__(state) + self.form = Form(find=find) + self.advice = AdviceBox() + right = QWidget() + right.setObjectName("page") + self.body = QVBoxLayout(right) + self.body.setContentsMargins(16, 12, 16, 12) + self.body.setSpacing(10) + self.body.addWidget(self.advice) + self.sheet = Sheet(self.form, right) + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(self.sheet) + + def set_plots(self, plots: QWidget) -> None: + """Put the stage's plots under its advice box; they take the room.""" + self.body.addWidget(plots, stretch=1) diff --git a/croak/gui/stage_dispersion.py b/croak/gui/stage_dispersion.py index 5b5610b..3c77661 100644 --- a/croak/gui/stage_dispersion.py +++ b/croak/gui/stage_dispersion.py @@ -1,4 +1,4 @@ -"""Stage 4 — tune residual dispersion (GDD/TOD/FOD, material) and save. +"""Analyse ▸ Dispersion — tune residual dispersion (GDD/TOD/FOD, material). GDD/TOD/FOD and material thicknesses use slider + spin-box controls (default 0). The auto-tuners maximise the absolute temporal @@ -8,12 +8,13 @@ from __future__ import annotations -import os from dataclasses import replace +from pathlib import Path import numpy as np -from PyQt6.QtWidgets import ( - QFileDialog, +from matplotlib.figure import Figure +from PySide6.QtWidgets import ( + QGridLayout, QHBoxLayout, QLabel, QPushButton, @@ -21,10 +22,19 @@ ) from .. import dispersion, gases, materials, plotting, save +from ..plotting.screen import ( + CurveView, + LineView, + spectral_view_of, + spectrogram_map_of, + temporal_view_of, +) from ..processing import process_result, spectrogram +from ..session.files import DISPERSION_FIGURE, DISPERSION_FILE from .base import Stage -from .canvas import MplCanvas, combo, group, with_toolbar +from .controls import add_wide_row, combo, group from .mirror_stack import MirrorStack +from .view_panel import ViewPanel from .widgets import SciSpinBox, ValueControl _GAS_NONE = "(none)" @@ -246,21 +256,28 @@ def __init__(self, state): form.addRow("TL peak power", self.peak_tl_label) self.controls.addWidget(box) - btns = QWidget() - bl = QHBoxLayout(btns) - bl.setContentsMargins(0, 0, 0, 0) + # Save session writes the dispersed pulse (save_extras); only Reset + # stays here. reset = QPushButton("Reset") reset.clicked.connect(self._reset) - save_btn = QPushButton("Save…") - save_btn.clicked.connect(self._save) - bl.addWidget(reset) - bl.addWidget(save_btn) - self.controls.addWidget(btns) - self.controls.addWidget(self.status_label) + self.controls.addWidget(reset) self.controls.addStretch(1) - self.canvas = MplCanvas(figsize=(9, 7)) - self.set_plot_area(with_toolbar(self.canvas)) + self.views = { + "spectral": ViewPanel("line", right=True), + "temporal": ViewPanel("line", right=True), + "applied": ViewPanel("line"), + "spectrogram": ViewPanel("map"), + } + grid = QGridLayout() + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(14) + grid.setVerticalSpacing(10) + for index, view in enumerate(self.views.values()): + grid.addWidget(view, index // 2, index % 2) + plots = QWidget() + plots.setLayout(grid) + self.set_plot_area(plots) self.apply_help() @@ -289,7 +306,8 @@ def _build_gas_group(self): *_GAS_PRESSURE_RANGE_BAR, 1.0, decimals=3, step=0.001, slider_scale=1000.0 ) self.gas_pressure.changed.connect(self._on_gas_pressure) - form.addRow("pressure (bar)", self.gas_pressure) + # Label above, so the sliders (and the path's Auto) fit the form column. + add_wide_row(form, "pressure (bar)", self.gas_pressure) self.gas_path = ValueControl( *_GAS_PATH_RANGE_CM, 0.0, decimals=2, slider_scale=10.0 ) @@ -301,7 +319,7 @@ def _build_gas_group(self): auto = QPushButton("Auto") auto.clicked.connect(self._auto_gas) rl.addWidget(auto) - form.addRow("path length (cm)", row) + add_wide_row(form, "path length (cm)", row) self.controls.addWidget(box) # -- mirror-stack handlers ---------------------------------------------- @@ -541,20 +559,37 @@ def _applied_phase(self): ) def _draw(self, mod, pr): - self.canvas.render(lambda fig: self._plot(fig, mod, pr)) + """Show the dispersed pulse: spectrum, time, applied phase, spectrogram.""" + # Wavelength, to match the applied-phase panel beneath it. + self.views["spectral"].show_view(spectral_view_of(pr, axis="wavelength")) + self.views["temporal"].show_view(temporal_view_of(pr)) + lam_nm, phase = self._applied_curve(pr) + self.views["applied"].show_view( + LineView( + curves=(CurveView(lam_nm, phase, "retrieved", "applied"),), + title="Applied phase", + xlabel="Wavelength (nm)", + ylabel="Phase (rad)", + ) + ) + tc, lam, s = spectrogram(mod) + self.views["spectrogram"].show_view(spectrogram_map_of(tc, lam, s)) + + def _applied_curve(self, pr): + """The applied phase on the pulse's spectrum, zero at its peak.""" + phi = self._applied_phase() + phi = phi - phi[pr.mask_w][int(np.argmax(pr.Iw[pr.mask_w]))] + return pr.wavelength[pr.mask_w] / 1e-9, phi[pr.mask_w] def _plot(self, fig, mod, pr) -> None: - """Draw the four dispersion panels into ``fig``.""" + """Draw the four dispersion panels into ``fig`` (the saved PDF).""" axd = fig.subplot_mosaic("ab\ncd") # Wavelength, to match the applied-phase panel beneath it. plotting.plot_spectral(axd["a"], pr, axis="wavelength") # phase + fit plotting.plot_temporal(axd["b"], pr) # bottom-left: only the *applied* dispersion phase - phi_applied = self._applied_phase() - phi_applied = ( - phi_applied - phi_applied[pr.mask_w][int(np.argmax(pr.Iw[pr.mask_w]))] - ) - axd["c"].plot(pr.wavelength[pr.mask_w] / 1e-9, phi_applied[pr.mask_w], c="C0") + lam_nm, phase = self._applied_curve(pr) + axd["c"].plot(lam_nm, phase, c="C0") axd["c"].set_xlabel("Wavelength (nm)") axd["c"].set_ylabel("Phase (rad)") axd["c"].set_title("Applied phase") @@ -651,29 +686,39 @@ def _reset(self): self._unregister_gas() self._changed() - def _save(self): - if self.state.result is None: - return - folder = QFileDialog.getExistingDirectory(self, "Select output folder") - if not folder: - return + def _applied(self) -> bool: + """Whether any dispersion is set (a Taylor term, a material, a mirror).""" + mats, bounces = self._extra_dispersion() + taylor = (self.gdd.value(), self.tod.value(), self.fod.value()) + return any(taylor) or bool(mats) or bool(bounces) + + def save_extras(self, folder: Path) -> list[str]: + """Write the dispersed pulse and its figure, if a dispersion is applied. + + The pulse is processed with the pulse energy, so the file carries peak + power, and with a synthetic or simulated session's known truth, as the + session's ``result.h5`` is. + """ + if self._base is None or self.state.result is None or not self._applied(): + return [] + # The page takes its copy of the spectrum when it is shown; a retrieval + # run since then (Save session can be reached from any page) would + # otherwise pair the old pulse with the new result's grid and error. + self._base = self.state.result.spectrum.copy() mod = self._modified() - self._sync_to_params() # ensure the saved options.toml captures dispersion measured = self.state.tracedata.trace if self.state.tracedata else None - # Process with the pulse energy so the saved HDF5 carries peak power. pr = process_result( mod, measured=measured, energy=self.state.load.energy_j or None ) save.save_result( mod, - os.path.join(folder, "result_dispersion.h5"), + str(folder / DISPERSION_FILE), processed=pr, measured=measured, - # a synthetic/simulated session's ground truth belongs in every saved - # file, as it does in the Retrieve stage's result.h5 truth=self.state.truth, force=True, ) - save.save_options(self.state.to_options(), os.path.join(folder, "options.toml")) - self.canvas.save_figure(os.path.join(folder, "dispersion.pdf"), dpi=600) - self.set_status(f"Saved to {folder}") + figure = Figure(figsize=(9, 7), layout="constrained") + self._plot(figure, mod, pr) + figure.savefig(folder / DISPERSION_FIGURE, dpi=600) + return [DISPERSION_FILE, DISPERSION_FIGURE] diff --git a/croak/gui/stage_load.py b/croak/gui/stage_load.py index b249dd8..e8bb667 100644 --- a/croak/gui/stage_load.py +++ b/croak/gui/stage_load.py @@ -13,7 +13,7 @@ import os import numpy as np -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QComboBox, QFileDialog, QHBoxLayout, @@ -24,11 +24,12 @@ from .. import io from ..interactions import get_interaction -from ..plotting import cmap_white, signed_pcolormesh +from ..plotting.screen import raw_trace_map from ..session.pipeline import assemble_load_data from .base import Stage -from .canvas import MplCanvas, check, combo, group, with_toolbar +from .controls import check, combo, group from .state import PreprocParams +from .view_panel import ViewPanel from .widgets import SciSpinBox _LAM_UNITS = ["nm", "µm", "m"] @@ -339,7 +340,7 @@ def __init__(self, state): ) self.controls.addWidget(box) - # Units & interaction ("&&" escapes the literal ampersand; see help._plain) + # Units & interaction ("&&" escapes the literal ampersand; see sheet.plain) box, form = group("Units && interaction") self.lam_unit_combo = combo( _LAM_UNITS, p.lam_unit, lambda v: setattr(p, "lam_unit", v) @@ -454,11 +455,10 @@ def __init__(self, state): load_btn = QPushButton("Load && Preview") load_btn.clicked.connect(self._load_preview) self.controls.addWidget(load_btn) - self.controls.addWidget(self.status_label) self.controls.addStretch(1) - self.trace_canvas = MplCanvas() - self.set_plot_area(with_toolbar(self.trace_canvas)) + self.trace_view = ViewPanel("map", signed=True) + self.set_plot_area(self.trace_view) self.apply_help() @@ -799,32 +799,10 @@ def _draw(self, data): tau_fs = ( io.position_to_delay(scan) if data["input_unit"] == "position" else scan ) / 1e-15 - trace = data["trace"] - lo, hi = _PREVIEW_TRUST_NM - in_band = (lam_nm >= lo) & (lam_nm <= hi) - band = trace[in_band] if np.any(in_band) else trace - vmax = float(np.nanmax(np.abs(band))) if band.size else 0.0 - self.trace_canvas.render( - lambda fig: self._plot_trace( - fig.add_subplot(111), tau_fs, lam_nm, trace, in_band, vmax - ) - ) - - @staticmethod - def _plot_trace(ax, tau_fs, lam_nm, trace, in_band, vmax) -> None: - """Draw the raw-trace preview into ``ax`` (the body of :meth:`_draw`).""" - signed_pcolormesh( - ax, - tau_fs, - lam_nm, - trace, - db=False, - cmap_pos=cmap_white("viridis"), - vmax=vmax if vmax > 0 else None, + trace = np.asarray(data["trace"]) + if min(trace.shape) < 2: # nothing to draw as an image + self.trace_view.say("Measured trace", "The trace holds no samples.") + return + self.trace_view.show_view( + raw_trace_map(tau_fs, lam_nm, trace, _PREVIEW_TRUST_NM) ) - sig = lam_nm[in_band & (trace.max(axis=1) > 1e-2 * (vmax or 1.0))] - if sig.size: - ax.set_ylim(sig.min(), sig.max()) - ax.set_xlabel("Delay (fs)") - ax.set_ylabel("Wavelength (nm)") - ax.set_title("Measured trace") diff --git a/croak/gui/stage_marginal_check.py b/croak/gui/stage_marginal_check.py index 2e42eb2..5924d85 100644 --- a/croak/gui/stage_marginal_check.py +++ b/croak/gui/stage_marginal_check.py @@ -27,8 +27,8 @@ from __future__ import annotations import numpy as np -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QWidget +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QWidget from .. import io from ..marginal_checks import ( @@ -38,7 +38,7 @@ rms_width, ) from ..maths import planck_taper, wlfreq -from ..plotting import cmap_white, signed_pcolormesh +from ..plotting.screen import CurveView, LineView, raw_trace_map from ..preprocess import ( omega_to_lambda_density, resample_spectrum, @@ -46,7 +46,10 @@ ) from ..session.pipeline import assemble_load_data, assemble_simulated_load_data from .base import Stage -from .canvas import MplCanvas, add_wide_row, check, combo, dspin, group, with_toolbar +from .controls import add_wide_row, check, combo, dspin, group +from .display import current as current_display +from .display import hub as display_hub +from .view_panel import ViewPanel from .widgets import RangeControl # Number of uniform angular-frequency samples used to resample the independent @@ -73,7 +76,11 @@ class StageMarginalCheck(Stage): "shape, centroid and width flags calibration, spectral-response, scatter " "and detector-nonlinearity problems. Tune the (λ/µm)^exp third-order " "efficiency correction here; 'Auto' matches the marginal centroid to the " - "prediction." + "prediction. The marginals are drawn against frequency, the physically " + "correct space, unless the display at the foot of the window asks for " + "wavelength: there the predicted marginal becomes a wavelength density " + "(×1/λ²), which blue-shifts a broad (e.g. transform-limited) marginal " + "even when its frequency centroid matches the measured one." ) HELP = [ ( @@ -95,16 +102,6 @@ class StageMarginalCheck(Stage): ( "Display", [ - ( - "marginal axis", - "Plot the marginals against frequency or wavelength. Frequency " - "(the default) is the physically correct space: the marginal is " - "a frequency density and the centroid/width identities live " - "there. On a wavelength axis the predicted marginal is converted " - "to a wavelength density (×1/λ²), which blue-shifts a broad " - "(e.g. transform-limited) marginal even when its frequency " - "centroid matches the measured one — visually misleading.", - ), ( "marginal scale", "Plot the marginal comparison on a linear or logarithmic " @@ -147,9 +144,8 @@ class StageMarginalCheck(Stage): ), ] - def __init__(self, state, wizard): + def __init__(self, state): super().__init__(state) - self.wizard = wizard # Measured default; re-seeded per trace in on_enter (full range for the # noise-free simulated/synthetic traces, 190–1000 nm for measured). self._trust_nm = (190.0, 1000.0) @@ -164,10 +160,6 @@ def __init__(self, state, wizard): # -- display -- box, form = group("Display") - self.axis_combo = combo( - ["frequency", "wavelength"], "frequency", self._on_scale - ) - form.addRow("marginal axis", self.axis_combo) self.scale_combo = combo(["linear", "log"], "linear", self._on_scale) form.addRow("marginal scale", self.scale_combo) self.controls.addWidget(box) @@ -212,20 +204,21 @@ def __init__(self, state, wizard): form.addRow(self.marginal_correct_check) self.controls.addWidget(box) - self.controls.addWidget(self.status_label) self.controls.addStretch(1) # Trace above, marginal comparison below, on a draggable divider so a # short display can give either plot the height (equal split by default). - self.trace_canvas = MplCanvas() - self.marg_canvas = MplCanvas() + self.trace_view = ViewPanel("map", signed=True) + self.marg_view = ViewPanel("line") self.plot_splitter = QSplitter(Qt.Orientation.Vertical) - self.plot_splitter.addWidget(with_toolbar(self.trace_canvas)) - self.plot_splitter.addWidget(with_toolbar(self.marg_canvas)) + self.plot_splitter.addWidget(self.trace_view) + self.plot_splitter.addWidget(self.marg_view) self.plot_splitter.setStretchFactor(0, 1) self.plot_splitter.setStretchFactor(1, 1) self.plot_splitter.setChildrenCollapsible(True) self.set_plot_area(self.plot_splitter) + # The marginals are built on the display's axis (see _marginal_view). + display_hub().changed.connect(self._draw) self.apply_help() @@ -432,58 +425,30 @@ def _draw(self) -> None: self._draw_marginal(lam, lam_nm, trace, in_band, data) def _draw_trace(self, tau_fs, lam_nm, trace, in_band) -> None: - """Trace image with the preprocess-page colour scheme and trust-scaled vmax.""" - self.trace_canvas.render( - lambda fig: self._plot_trace( - fig.add_subplot(111), tau_fs, lam_nm, trace, in_band - ) - ) - - @staticmethod - def _plot_trace(ax, tau_fs, lam_nm, trace, in_band) -> None: - """Draw the trace image into ``ax`` (the body of :meth:`_draw_trace`).""" - # Scale to the in-trust-region peak so amplified out-of-band noise (the - # (λ/µm)^exp tilt blows up the long-λ tail) cannot wash out the signal. - band = trace[in_band] if np.any(in_band) else trace - vmax = float(np.nanmax(np.abs(band))) if band.size else 1.0 - signed_pcolormesh( - ax, - tau_fs, - lam_nm, - trace, - db=False, - cmap_pos=cmap_white("viridis"), - vmax=vmax if vmax > 0 else None, - ) - # Clip the wavelength axis to the in-band signal so the view tracks it. - sig = lam_nm[in_band & (trace.max(axis=1) > 1e-2 * (vmax or 1.0))] - if sig.size: - ax.set_ylim(sig.min(), sig.max()) - ax.set_xlabel("Delay (fs)") - ax.set_ylabel("Wavelength (nm)") - ax.set_title("Measured trace") + """Trace image, scaled by the trust region (see ``raw_trace_map``).""" + del in_band # the map recomputes the band from the trust region + self.trace_view.show_view(raw_trace_map(tau_fs, lam_nm, trace, self._trust_nm)) def _draw_marginal(self, lam, lam_nm, trace, in_band, data) -> None: - """Trace marginal against the spectrum (see :meth:`_plot_marginal`).""" - self.marg_canvas.render( - lambda fig: self._plot_marginal( - fig.add_subplot(111), lam, lam_nm, trace, in_band, data - ) - ) + """Draw the trace marginal against the spectrum and the prediction.""" + self.marg_view.show_view(self._marginal_view(lam, lam_nm, trace, in_band, data)) - def _plot_marginal(self, ax, lam, lam_nm, trace, in_band, data) -> None: - """Draw the marginal comparison into ``ax``; reads the display combos live.""" + def _marginal_view(self, lam, lam_nm, trace, in_band, data) -> LineView: + """Build the marginal comparison; reads the display combos live.""" lo, hi = self._trust_nm # nm log = self.scale_combo.currentText() == "log" - freq = self.axis_combo.currentText() == "frequency" + # Frequency, the space the marginal identities live in, unless the + # display asks for wavelength; built here on either axis, so the + # display's own conversion leaves it as it is. + freq = current_display().axis != "wavelength" # Marginals are frequency densities. On the frequency axis we plot the # ω-density vs ν=ω/2π (undistorted). On the wavelength axis we plot the # λ-density vs λ; the λ Jacobian (×1/λ²) then blue-shifts a broad marginal - # even when its frequency centroid matches — see the 'marginal axis' help. - # When the SHG marginal correction is active, dim the raw marginal and - # overlay the corrected one (which, by construction, equals the predicted - # SHG autoconvolution — see _draw the dashed C0 curve below). + # even when its frequency centroid matches — see the page's help. + # When the SHG marginal correction is active, the raw marginal is drawn + # faint and the corrected one (which, by construction, equals the + # predicted SHG autoconvolution) is overlaid in the trace colour, dashed. correcting = self.marginal_correct_check.isChecked() and ( self._marginal_correct_available(data) ) @@ -492,14 +457,14 @@ def _plot_marginal(self, ax, lam, lam_nm, trace, in_band, data) -> None: x_meas, y_meas = wlfreq(lam) / _TWO_PI_PHZ, marg * lam**2 else: x_meas, y_meas = lam_nm, marg - ax.plot( - x_meas, - _peak_norm(y_meas, in_band), - label="trace marginal", - c="C0", - alpha=0.45 if correcting else 1.0, - ) - + curves = [ + CurveView( + np.asarray(x_meas, float), + _peak_norm(y_meas, in_band), + "faint" if correcting else "retrieved", + "trace marginal", + ) + ] interaction = data["interaction"] pred = None if data.get("Ilam_spec") is not None: @@ -510,7 +475,14 @@ def _plot_marginal(self, ax, lam, lam_nm, trace, in_band, data) -> None: x_sp, y_sp = wlfreq(lam_spec) / _TWO_PI_PHZ, Ilam_spec * lam_spec**2 else: x_sp, y_sp = lam_spec / 1e-9, Ilam_spec - ax.plot(x_sp, _peak_norm(y_sp, sl_band), label="spectrum", c="C1", lw=1) + curves.append( + CurveView( + np.asarray(x_sp, float), + _peak_norm(y_sp, sl_band), + "measured", + "spectrum", + ) + ) pred = self._predict(lam_spec, Ilam_spec, interaction) if pred is not None: if freq: @@ -519,53 +491,38 @@ def _plot_marginal(self, ax, lam, lam_nm, trace, in_band, data) -> None: else: x_pr, y_pr = pred["lam_nm"], pred["marg_lam"] pb = (x_pr >= lo) & (x_pr <= hi) - ax.plot(x_pr, _peak_norm(y_pr, pb), label="predicted", c="C3", ls="--") + predicted = _peak_norm(y_pr, pb) + curves.append(CurveView(x_pr, predicted, "predicted", "predicted")) if correcting: - # the correction makes the trace marginal equal the predicted - # SHG autoconvolution; show that snap in the trace colour. - ax.plot( - x_pr, - _peak_norm(y_pr, pb), - c="C0", - ls="--", - lw=1.5, - label="trace marginal (corrected)", + curves.append( + CurveView( + x_pr, predicted, "corrected", "trace marginal (corrected)" + ) ) - # Centroid check: vertical lines. The spectrum centroid (C1) sits on the - # predicted-marginal centroid (C3) for PG/TG/SD, but at half it for SHG - # (whose marginal is centred at 2ω̄_S). + # Centroid check: vertical rules. The spectrum centroid sits on the + # predicted-marginal centroid for PG/TG/SD, but at half it for SHG (whose + # marginal is centred at 2ω̄_S). c_meas_nm, sigma_meas = self._measured_moments(lam, marg, in_band) to_x = (lambda nm: float(wlfreq(nm * 1e-9) / _TWO_PI_PHZ)) if freq else float + marks = [] if c_meas_nm is not None: - ax.axvline(to_x(c_meas_nm), c="C0", ls=":", lw=1, label="meas centroid") + marks.append((to_x(c_meas_nm), "retrieved")) if pred is not None: - ax.axvline( - to_x(pred["spectrum_centroid_nm"]), - c="C1", - ls="--", - lw=1, - label="spec centroid", - ) - ax.axvline( - to_x(pred["centroid_nm"]), c="C3", ls=":", lw=1, label="pred centroid" - ) - - self._annotate_checks(ax, c_meas_nm, sigma_meas, pred, interaction) - - if log: - ax.set_yscale("log") - ax.set_ylim(1e-3, 1.5) - else: - ax.set_ylim(0.0, 1.1) - if freq: - ax.set_xlim(self._f_lo(), self._f_hi()) - ax.set_xlabel("Frequency (PHz)") - else: - ax.set_xlim(lo, hi) - ax.set_xlabel("Wavelength (nm)") - ax.set_ylabel("Intensity (a.u.)") - ax.legend(fontsize="small") + marks.append((to_x(pred["spectrum_centroid_nm"]), "measured")) + marks.append((to_x(pred["centroid_nm"]), "predicted")) + + self._annotate_checks(c_meas_nm, sigma_meas, pred, interaction) + return LineView( + curves=tuple(curves), + title="Marginal against the spectrum", + xlabel="Frequency (PHz)" if freq else "Wavelength (nm)", + ylabel="Intensity (a.u.)", + xrange=(self._f_lo(), self._f_hi()) if freq else (lo, hi), + yrange=(1e-3, 1.5) if log else (0.0, 1.1), + log_y=log, + marks=tuple(marks), + ) def _f_lo(self) -> float: """Low frequency edge (PHz) of the trust region (from its long-λ bound).""" @@ -654,8 +611,8 @@ def _measured_moments(self, lam, marg, in_band): c = centroid(omega, weights) return float(wlfreq(c) / 1e-9), rms_width(omega, weights) - def _annotate_checks(self, ax, c_meas_nm, sigma_meas, pred, interaction) -> None: - """Write the numeric centroid/width QC summary to the status line + plot.""" + def _annotate_checks(self, c_meas_nm, sigma_meas, pred, interaction) -> None: + """Write the numeric centroid/width QC summary to the ready line.""" if pred is None or c_meas_nm is None: self.set_status("No independent spectrum: showing the trace marginal only.") return diff --git a/croak/gui/stage_preprocess.py b/croak/gui/stage_preprocess.py index ce0a26c..351306b 100644 --- a/croak/gui/stage_preprocess.py +++ b/croak/gui/stage_preprocess.py @@ -24,24 +24,25 @@ from dataclasses import replace from typing import TypedDict -from PyQt6 import sip -from PyQt6.QtCore import QTimer -from PyQt6.QtWidgets import QHBoxLayout, QPushButton, QWidget +import shiboken6 +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QPushButton, QWidget -from .. import io, plotting +from .. import io +from ..plotting.screen import FILTER_PANELS, filter_views from ..preprocess import suggested_tau_exclude from ..session import pipeline from .base import Stage -from .canvas import ( - MplCanvas, +from .controls import ( add_wide_row, check, combo, dspin, group, spin, - with_toolbar, ) +from .sheet import DerivedGrid +from .view_panel import ViewPanel from .widgets import RangeControl, SciSpinBox from .worker import PreprocessWorker @@ -58,6 +59,28 @@ class _RangeKW(TypedDict): _RANGE_KW: _RangeKW = {"decimals": 1, "step": 0.1, "slider_scale": 10.0} +TAPER_ADVICE_LEVEL = 0.01 +"""The fraction of the trace the regrid's edge taper may remove before Prepare +says so: :func:`croak.preprocess.regrid`'s default ``taper_warn_level``, so the +page warns exactly when the library does.""" + +NUMBERS = ( + ("Grid", "Frequency samples × delay samples of the retrieval grid."), + ("Δt", "The grid's time step: the shortest feature it can show."), + ("Time window", "The grid's time span, N × Δt: the longest pulse it can hold."), + ( + "λ span", + "The pulse's wavelength range the grid covers (λ range under Grid); an " + "SHG trace's signal lies at half these wavelengths.", + ), + ( + "Taper loss", + "The share of the trace the grid's edge taper removes; above 1 % the " + "λ range clips the trace.", + ), +) +"""The numbers over Prepare's maps: name and tooltip.""" + class StagePreprocess(Stage): HELP_TITLE = "Preprocess" @@ -199,7 +222,6 @@ def __init__(self, state): self._seeding = False # Off-thread preprocessing: the plotting redraw (not the math) is the # bottleneck, but both used to block the UI thread on every change. - self._view = plotting.FrogFilterView() self._req_id = 0 # bumped per recompute; stale worker results are ignored self._workers: list[PreprocessWorker] = [] self._pending: tuple | None = None # (params, data) for the exact refine @@ -407,11 +429,38 @@ def __init__(self, state): ) self.reset_btn.clicked.connect(self._reset_defaults) self.controls.addWidget(self.reset_btn) - self.controls.addWidget(self.status_label) self.controls.addStretch(1) - self.canvas = MplCanvas(figsize=(9, 5)) - self.set_plot_area(with_toolbar(self.canvas)) + # The filter preview: measured, filtered and regridded traces in dB and + # linear. The wavelength-domain maps share their axes, as do all six + # delay axes, so a zoom on one follows on the others. + self.views = { + key: ViewPanel("map", signed=not db) + for key, _title, _which, _domain, db in FILTER_PANELS + } + grid = QGridLayout() + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(14) + grid.setVerticalSpacing(10) + for index, view in enumerate(self.views.values()): + grid.addWidget(view, index // 3, index % 3) + first = self.views["measured_db"].plot + for key, view in self.views.items(): + if key == "measured_db": + continue + view.plot.setYLink(first) + if not key.startswith("regridded"): + view.plot.setXLink(first) + self.views["regridded_lin"].plot.setXLink(self.views["regridded_db"].plot) + plots = QWidget() + plots.setLayout(grid) + # Over the maps, what the grid settings give: the numbers to read while + # moving the λ range and trange sliders (see _show_numbers). + self.numbers = DerivedGrid(columns=len(NUMBERS)) + for name, tip in NUMBERS: + self.numbers.add(name, tip) + self.body.addWidget(self.numbers) + self.set_plot_area(plots) self.apply_help() @@ -621,7 +670,7 @@ def apply(self, *, block: bool = False): try: td = pipeline.build_tracedata(params, data) # exact, full-resolution except Exception as exc: - self.set_status(f"Preprocess failed: {exc}") + self.set_status(f"Preprocess failed: {exc}", "error") return self._show_tracedata(td) return @@ -643,36 +692,56 @@ def _discard_worker(self, worker): """Drop our reference to a finished worker (kept alive while running).""" if worker in self._workers: self._workers.remove(worker) - # The worker is parented to this stage, so when the wizard rebuilds its + # The worker is parented to this stage, so when the window rebuilds its # stages (``deleteLater`` on each) between the thread finishing and this # queued slot running, Qt has already destroyed it with its parent. - if not sip.isdeleted(worker): + if shiboken6.isValid(worker): worker.deleteLater() + def _show_numbers(self, td) -> None: + """Show what the grid gives: its size, step, window, band and taper loss.""" + grid = td.grid + lost = td.taper_loss + self.numbers.set("Grid", f"{grid.n} × {td.delays.size}") + self.numbers.set("Δt", f"{grid.dt / 1e-15:.3g} fs") + self.numbers.set("Time window", f"{grid.n * grid.dt / 1e-15:.4g} fs") + self.numbers.set( + "λ span", f"{td.lam_min / 1e-9:.0f}–{td.lam_max / 1e-9:.0f} nm" + ) + self.numbers.set( + "Taper loss", + f"{lost:.1%}" if lost >= 0.001 else "< 0.1%", + "warn" if lost > TAPER_ADVICE_LEVEL else "ok", + ) + def _show_tracedata(self, td): """Store and draw a freshly computed trace (main thread).""" self.state.set_tracedata(td) - if self._view.attached: - # Fast path: the artists exist, so push the data and repaint. - self._view.update(td) - self.canvas.draw_idle() + self._show_numbers(td) + for key, view in filter_views(td).items(): + self.views[key].show_view(view) + grid = f"Regridded to {td.grid.n} × {td.delays.size}." + if td.taper_loss > TAPER_ADVICE_LEVEL: + self.set_status(f"{grid} The λ grid clips the trace.", "warn") + self._advise_taper(td) else: - self.canvas.render(self._replot_view) - self.set_status( - f"Regridded to {td.grid.n}×{td.delays.size}. Proceed to Retrieve." - ) + self.set_status(f"{grid} Proceed to Retrieve.") + self.advice.clear() - def _replot_view(self, fig) -> None: - """(Re)build the filter view in ``fig`` and fill it from the current trace. + def _advise_taper(self, td): + """Say that the λ grid cuts live signal, and what to do about it. - Handed to :meth:`~croak.gui.canvas.MplCanvas.render`, which re-runs it when - a resize changes the text density; the view is rebuilt on the cleared - figure and refilled from ``state.tracedata`` (nothing to fill if none yet). + The library's own warning goes to the console, where a GUI user never + sees it; the clipped signal is discarded and the retrieved field is left + free at those frequencies, so the page says it where the user looks. """ - self._view.attach(fig) - td = self.state.tracedata - if td is not None: - self._view.update(td) + self.advice.say( + f"The λ grid removes {td.taper_loss:.0%} of the trace", + f"The grid ({td.lam_min / 1e-9:.0f}–{td.lam_max / 1e-9:.0f} nm) ends " + "where the trace still has signal, so its edge taper discards that " + "signal and leaves the retrieved field unconstrained there. Widen the " + "λ range (nm) under Grid until the trace decays inside it.", + ) def _on_preprocess_done(self, td, req_id, was_fast): if req_id != self._req_id: @@ -692,4 +761,5 @@ def _on_preprocess_done(self, td, req_id, was_fast): def _on_preprocess_failed(self, message, req_id): if req_id != self._req_id: return - self.set_status(f"Preprocess failed: {message}") + self.set_status(f"Preprocess failed: {message}", "error") + self.numbers.clear() # the last grid's numbers no longer hold diff --git a/croak/gui/stage_retrieve.py b/croak/gui/stage_retrieve.py index 749a5ba..90bbe20 100644 --- a/croak/gui/stage_retrieve.py +++ b/croak/gui/stage_retrieve.py @@ -1,4 +1,4 @@ -"""Stage 3 — run the retrieval (threaded) and show the result on pages of panels. +"""Retrieve — run the retrieval (threaded) and show it live on the overview. Post-retrieval λ/τ filtering can be toggled on the result pane and re-plots the stored result without re-running the retrieval. @@ -7,46 +7,47 @@ from __future__ import annotations import math -import os import time import numpy as np -from PyQt6.QtCore import QTimer -from PyQt6.QtWidgets import ( +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, QPushButton, QWidget, ) -from .. import materials, plotting, save, smearing +from .. import materials, plotting, smearing from ..constants import C +from ..plotting.screen import convergence_view from ..processing import ( ProcessedResult, - TruthPulse, peak_wavelength, post_filter, process_result, resolve_time_direction, - spectral_fwhm, ) from ..retrieve import ALGORITHMS, algorithm_params from ..session.params import reg_defaults, reg_family from ..session.pipeline import extra_param_centres, smearing_kernel from ..truth_metrics import truth_errors -from . import settings -from .base import Stage -from .canvas import check, collapsible, combo, dspin, group, spin -from .panel_pages import RetrievalPages +from .base import RunControls, Stage +from .controls import check, combo, dspin, group, spin +from .display import current as current_display from .readout import DASH, RetrievalReadout +from .retrieval_views import RetrievalPanels +from .view_panel import ViewPanel from .widgets import SciSpinBox from .worker import RetrievalWorker +#: The height of the numbers and convergence strip under the plots (logical px). +_STRIP_HEIGHT = 150 #: How often the elapsed-time read-out ticks during a run (ms). _ELAPSED_TICK_MS = 500 -#: Shortest interval (ms) between redraws of the convergence curve before the first -#: full preview: a fast solver can report thousands of iterations a second, and -#: ten frames a second is all the eye needs. +#: Shortest interval (ms) between redraws of the convergence curve during a run: a +#: fast solver can report thousands of iterations a second, and ten frames a +#: second is all the eye needs. _CONVERGENCE_REDRAW_MS = 100 _INITIAL_GUESSES = { @@ -59,14 +60,15 @@ } -class StageRetrieve(Stage): +class StageRetrieve(RunControls, Stage): HELP_TITLE = "Retrieve" HELP_INTRO = ( - "Run the phase-retrieval algorithm on the preprocessed trace. The result " - "is shown on three pages of panels — Traces, Pulse and Diagnostics — each " - "of which can be popped out into its own window; Save… writes the full " - "twelve-panel overview. Post-retrieval λ/τ filtering re-plots the stored " - "result without re-running the retrieval." + "Run the phase-retrieval algorithm on the preprocessed trace. The live " + "overview shows the traces, the pulse and its spectrum as it converges; " + "the full pages of panels (Traces, Pulse, Diagnostics) are under Analyse, " + "where each can be exported or popped out. Save… writes the twelve-panel " + "overview. Post-retrieval λ/τ filtering re-plots the stored result " + "without re-running the retrieval." ) HELP = [ ( @@ -132,12 +134,12 @@ class StageRetrieve(Stage): ), ( "Live full-plot preview", - "Redraw the visible result page (retrieved trace, pulse, " - "spectrum, marginals… — the other pages redraw when selected) " - "as the retrieval converges, throttled to about once a " - "second, instead of only the convergence curve. Useful for " - "long LM runs. Pressing Stop keeps the latest full plot on " - "screen. Turn off for the cheaper convergence-only curve.", + "Redraw the overview (traces, pulse and spectrum) as the " + "retrieval converges, up to four times a second and less " + "often when drawing is slow, instead of only the convergence " + "curve. Useful for long LM runs. Pressing Stop keeps the " + "latest full plot on screen. Turn off for the cheaper " + "convergence-only curve.", ), ], ), @@ -322,31 +324,18 @@ class StageRetrieve(Stage): ), ], ), - ( - "Display", - [ - ( - "Spectrum axis", - "Draw the spectrum panel against frequency (PHz — the space " - "the retrieval works in, where |E(ω)|² of a symmetric pulse is " - "symmetric) or wavelength (nm — the spectrometer's axis, with " - "the λ² Jacobian applied). Both are unit-peak densities in " - "their own variable. Remembered between launches; the " - "exported twelve-panel PDF follows it.", - ), - ], - ), ] def __init__(self, state): - super().__init__(state) + # The longest form: offer "Find a setting" over it. + super().__init__(state, find=True) + # The numbers beneath the plots say how a run goes, so the form has no + # ready line; what else the page has to say is a notice (set_status). + self.status_label.hide() p = state.retrieve self._worker = None self._base_result = None # unfiltered retrieval, for post-filter toggles self._elapsed = None # wall-clock time of the last retrieval (s) - # Whether a live full-plot preview has taken over the canvas this run; until - # the first preview arrives we draw the cheap convergence curve. - self._live_full = False # Last regularisation weights used with each solver family, so switching # solver to compare and back does not throw away hand-tuned values. The # families are kept apart because the same number does not mean the same @@ -594,7 +583,11 @@ def __init__(self, state): form.addRow("Quadrature nodes", self.smear_nodes_spin) self.controls.addWidget(box) - self.focal_box, form = group("Chromatic focal mixture (advanced)") + self.focal_box, form = group( + "Chromatic focal mixture (advanced)", + folded=True, + settings_key="retrieve/open_focal", + ) box = self.focal_box self.focal_check = check( "Enable focal mixture", @@ -706,7 +699,9 @@ def __init__(self, state): self.collection_diam_spin.setEnabled(p.collection == "manual") self.controls.addWidget(box) - self.extra_box, form = group("Extra fit parameters") + self.extra_box, form = group( + "Extra fit parameters", folded=True, settings_key="retrieve/open_extra" + ) box = self.extra_box self.fit_thickness_check = check( "Fit medium thickness", @@ -762,7 +757,9 @@ def __init__(self, state): form.addRow("Delay origin", self.delay_origin_combo) self.controls.addWidget(box) - self.reg_box, form = group("Regularisation") + self.reg_box, form = group( + "Regularisation", folded=True, settings_key="retrieve/open_regularisation" + ) box = self.reg_box # Scientific-notation spin boxes (decade-aware stepping) so weights well # below 0.001 — common with LM — can be entered, e.g. 5e-4. @@ -802,7 +799,9 @@ def __init__(self, state): form.addRow("τ window hi (fs)", self.reg_time_hi_spin) self.controls.addWidget(box) - self.global_box, form = group("Global search (cma-es)") + self.global_box, form = group( + "Global search (cma-es)", folded=True, settings_key="retrieve/open_global" + ) box = self.global_box self.strategy_combo = combo( ["cma", "sep-cma", "de"], @@ -839,34 +838,12 @@ def __init__(self, state): form.addRow(self.pf_tau) self.controls.addWidget(box) - box, form = group("Display") - self.spectrum_axis_combo = combo( - plotting.SPECTRAL_AXES, - settings.text("retrieve/spectrum_axis", "frequency"), - self._on_spectrum_axis, - ) - form.addRow("Spectrum axis", self.spectrum_axis_combo) - self.controls.addWidget(box) - - btns = QWidget() - bl = QHBoxLayout(btns) - bl.setContentsMargins(0, 0, 0, 0) - self.run_btn = QPushButton("Run") - self.run_btn.clicked.connect(self._run) - self.stop_btn = QPushButton("Stop") - self.stop_btn.clicked.connect(self._stop) - self.stop_btn.setEnabled(False) - self.save_btn = QPushButton("Save…") - self.save_btn.clicked.connect(self._save) - self.save_btn.setEnabled(False) + # Retrieve, Stop and Save are the top bar's primary action and Save + # session; only what has no place there stays in the form. self.reset_btn = QPushButton("Reset") self.reset_btn.setToolTip("Restore the default retrieval settings.") self.reset_btn.clicked.connect(self._reset_defaults) - bl.addWidget(self.run_btn) - bl.addWidget(self.stop_btn) - bl.addWidget(self.save_btn) - bl.addWidget(self.reset_btn) - self.controls.addWidget(btns) + self.controls.addWidget(self.reset_btn) # Retarget: rerun this exact session on a different dataset folder (same # file names, calibration kept fixed). On its own row so the label fits. @@ -878,22 +855,23 @@ def __init__(self, state): self.retarget_btn.clicked.connect(self._retarget) self.retarget_btn.setEnabled(False) self.controls.addWidget(self.retarget_btn) - self.controls.addWidget(self.status_label) self.controls.addStretch(1) - # Figure on top (it takes every spare pixel), numeric read-out beneath it - # at its natural height — see croak.gui.readout. - # Pages of panels on top (they take every spare pixel; see - # croak.gui.panel_pages), the numeric read-out beneath at its natural - # height, folding away with a click and remembering that. - self.pages = RetrievalPages() + # The live overview on top (it takes every spare pixel; see + # croak.gui.retrieval_views); beneath it, the run's numbers beside its + # convergence, which on a slow retrieval says whether to wait. The full + # pages of panels are on Analyse. + self.pages = RetrievalPanels() self.readout = RetrievalReadout() - self.readout_strip = collapsible( - "Numeric read-out", - self.readout, - settings_key="retrieve/readout_expanded", - ) - self.set_plot_area(self.pages, self.readout_strip) + self.convergence = ViewPanel("line") + strip = QWidget() + strip.setFixedHeight(_STRIP_HEIGHT) + row = QHBoxLayout(strip) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(24) + row.addWidget(self.readout, stretch=3) + row.addWidget(self.convergence, stretch=2) + self.set_plot_area(self.pages, strip) self._update_extras_readout(None) self.apply_help() @@ -1396,7 +1374,6 @@ def on_enter(self): # clear the retrieval is still valid — no re-run needed to proceed. if self.state.result is not None and self._base_result is None: self._base_result = self.state.result - self.save_btn.setEnabled(True) self.retarget_btn.setEnabled(True) self.pf_lam.setEnabled(True) self.pf_tau.setEnabled(True) @@ -1451,13 +1428,12 @@ def _run(self): self.set_status( "No compatible previous result to reuse; using the default guess." ) - self.run_btn.setEnabled(False) - self.stop_btn.setEnabled(True) - self.save_btn.setEnabled(False) + self.set_running(True) self.retarget_btn.setEnabled(False) - self._live_full = False self._conv_timer.stop() + self.advice.clear() self.pages.clear() + self.convergence.show_view(convergence_view([])) self._worker = RetrievalWorker( td, p, @@ -1488,23 +1464,29 @@ def _stop(self): if self._worker is not None: self._worker.request_stop() + def act(self, kind: str) -> bool: + """Retrieve or stop from the window's primary action (never moves on).""" + if kind == "retrieve": + self._run() + return False + if kind == "stop": + self._stop() + return False + return super().act(kind) + def _on_progress(self, iteration, R, best_R): self._errors.append(R) - self.set_status(f"iter {iteration}: R = {R:.4%} (best {best_R:.4%})") # Cheap: this fires every iteration and has no processed result to draw on. self.readout.set_scalars({"iteration": str(iteration), "error": f"{R:.4%}"}) - # Until the first full-plot preview arrives (or for convergence-only - # solvers), animate the convergence curve on the visible page — at most - # every _CONVERGENCE_REDRAW_MS, however fast the iterations come. Once a - # live full plot has taken over, leave it to :meth:`_on_preview`. - if not self._live_full and not self._conv_timer.isActive(): + # Animate the convergence curve at most every _CONVERGENCE_REDRAW_MS, + # however fast the iterations come. + if not self._conv_timer.isActive(): self._conv_timer.start() def _draw_convergence(self) -> None: """Show the run's error history so far (debounced by ``_conv_timer``).""" - if self._live_full or not self._errors: - return - self.pages.show_progress(self._errors) + if self._errors: + self.convergence.show_view(convergence_view(self._errors)) def _on_preview(self, result): """Show a throttled in-progress result on the pages. @@ -1519,8 +1501,6 @@ def _on_preview(self, result): td = self.state.tracedata if td is None: return - self._conv_timer.stop() - self._live_full = True energy = self.state.load.energy_j or None started = time.perf_counter() with np.errstate(divide="ignore", invalid="ignore"): @@ -1533,9 +1513,6 @@ def _on_preview(self, result): # Processing and presenting ran on the GUI thread (holding the GIL): # tell the worker what it cost so the cadence keeps within its duty. self._worker.report_preview_cost(time.perf_counter() - started) - self.set_status( - f"iter {len(result.errors)}: R = {result.error:.4%} (live preview)" - ) def _on_finished(self, result, elapsed): self._finalise(result, elapsed) @@ -1545,11 +1522,12 @@ def _on_stopped(self, result, elapsed): The retrieval was aborted mid-run, so the snapshot is a legitimate (if non-converged) result: store it, enable Save/post-filters, and keep its - full plot on screen — labelled "Stopped" rather than "Done". + full plot on screen, saying that it is a stopped run's. """ - self._finalise(result, elapsed, done_label="Stopped") + self._finalise(result, elapsed) + self.set_status("Stopped: the run's latest result is kept.") - def _finalise(self, result, elapsed, *, done_label="Done"): + def _finalise(self, result, elapsed): """Shared completion path for a finished or stopped retrieval.""" # An SHG trace is invariant under E(t) -> E*(-t), so the solver returns # whichever of the two branches it landed in. When the session carries a @@ -1562,33 +1540,35 @@ def _finalise(self, result, elapsed, *, done_label="Done"): # The worker timed the run itself; prefer its value over the ticking clock. self._elapsed = float(elapsed) self._elapsed_timer.stop() - self.run_btn.setEnabled(True) - self.stop_btn.setEnabled(False) - self.save_btn.setEnabled(True) + self.set_running(False) self.retarget_btn.setEnabled(True) self.pf_lam.setEnabled(True) self.pf_tau.setEnabled(True) - self._update_view(done_label=done_label) + self._update_view() def _on_post_filter(self, _=None): self.state.retrieve.post_filter_lam = self.pf_lam.isChecked() self.state.retrieve.post_filter_tau = self.pf_tau.isChecked() self._update_view() - def _on_spectrum_axis(self, axis: str) -> None: - """Remember the spectrum panel's abscissa and re-present the stored result.""" - settings.set_text("retrieve/spectrum_axis", axis) - self._update_view() + def overview(self) -> plotting.RetrievalPlotData | None: + """The current retrieval as the overview's plot data (``None`` before one).""" + if self.state.result is None: + return None + return self._plot_data(self.state.result, self.state.processed) def _plot_data(self, result, pr) -> plotting.RetrievalPlotData: """Bundle ``result`` and its processed form for the panels. Draws against the session's trace data (measured trace, independent - spectrum, wavelength window), the known truth if any, and the pulse energy. + spectrum, wavelength window), the known truth if any, and the pulse energy, + with the display settings' spectral axis and logarithmic range, which the + figures drawn from it (Analyse's pages, the saved ``retrieval.pdf``) follow. """ td = self.state.tracedata if td is None: raise RuntimeError("no trace data to plot the retrieval against") + display = current_display() return plotting.retrieval_plot_data( result, measured=td.trace, @@ -1598,22 +1578,19 @@ def _plot_data(self, result, pr) -> plotting.RetrievalPlotData: truth=self.state.truth, energy=self.state.load.energy_j or None, processed=pr, - spectral_axis=self.spectrum_axis_combo.currentText(), + spectral_axis="wavelength" if display.axis == "wavelength" else "frequency", + tracedb=display.db_range, ) def _show(self, result, pr) -> None: """Show ``result`` on the pages and refresh the numeric read-out from ``pr``.""" self.pages.set_data(self._plot_data(result, pr)) - self._update_curve_readout(pr, self.state.truth) + self._update_pulse_readout(pr) self._update_extras_readout(result) self._update_truth_error_readout(result, self.state.truth) - def _update_view(self, *, done_label="Done"): - """(Re)apply the post-filters to the stored result and redraw — no re-run. - - ``done_label`` heads the status summary ("Done" on completion, "Stopped" - when the user aborted the run). - """ + def _update_view(self): + """(Re)apply the post-filters to the stored result and redraw — no re-run.""" if self._base_result is None: return td = self.state.tracedata @@ -1634,6 +1611,11 @@ def _update_view(self, *, done_label="Done"): ) self.state.set_result(result, pr) self._show(result, pr) + # The whole history, with the hand-overs between a solver's stages; a + # result read back from a file has none. + self.convergence.show_view( + convergence_view(result.errors, result.stage_boundaries) + ) # A result rehydrated from file carries no error history, so there is no # honest iteration count to show. self.readout.set_scalars( @@ -1643,49 +1625,13 @@ def _update_view(self, *, done_label="Done"): "elapsed": DASH if self._elapsed is None else f"{self._elapsed:.2f} s", } ) - # Mirror the terminal summary report: error, retrieved & transform- - # limited FWHM, iteration count and wall-clock time. - parts = [ - f"{done_label}. R = {result.error:.4%}", - f"retrieved FWHM {pr.fwhm_retr / 1e-15:.2f} fs", - f"transform-limited {pr.fwhm_tl / 1e-15:.2f} fs", - f"{len(result.errors)} iters", - ] - # Report any fitted extra parameters (thickness / delay-zero offset / - # geometric-smearing width). - if result.thickness is not None: - parts.append(f"thickness {result.thickness / 1e-6:.2f} µm") - if result.tau0: - parts.append(f"τ₀ {result.tau0 / 1e-15:.2f} fs") - if result.smear_scale is not None: - parts.append(self._smear_summary(result.smear_scale, td)) - if self._elapsed is not None: - parts.append(f"{self._elapsed:.2f} s") - self.set_status(" | ".join(parts)) - - def _smear_summary(self, smear_scale: float, td) -> str: - """Summarise a fitted smearing strength as a delay width plus its factor. - - The fit optimises one dimensionless multiplier on the kernel widths, which - scale linearly with it (:meth:`croak.smearing.SmearingKernel.scaled`), so - the physical delay width is ``smear_scale × sigma_delta`` of the kernel - the run actually used. The nominal mask geometry is deliberately left - alone — it is the stored truth, not a readout to be overwritten. - """ - sigma_fs = self._smear_width_fs(smear_scale, td) - if sigma_fs is None: - # The kernel was switched off after the run (this view is re-rendered - # by the post-filter toggles), so there is no width to convert to. - return f"smearing ×{smear_scale:.3f}" - return f"smearing σ {sigma_fs:.2f} fs (×{smear_scale:.3f})" def _smear_width_fs(self, smear_scale: float, td) -> float | None: """The modelled smearing delay width (fs) at ``smear_scale``, or ``None``. ``None`` when there is no kernel to measure — smearing switched off, or an SHG trace, which has no three-arm form and so no smearing model at all — so - callers can fall back to reporting the bare multiplier or nothing. Shared by - the status line and the read-out so the two cannot drift apart. + the read-out can fall back to reporting the bare multiplier or nothing. """ try: kernel = smearing_kernel(self.state.retrieve, td) @@ -1715,68 +1661,19 @@ def _fs(value_s: float | None) -> str: return DASH return plotting.sig3(value_s / 1e-15) - @staticmethod - def _power(value_w: float | None, scale: float) -> str: - """Format a power against the column's shared SI prefix.""" - if value_w is None or not np.isfinite(value_w): - return DASH - return plotting.sig3(value_w / scale) - - def _update_curve_readout(self, pr: ProcessedResult, truth: TruthPulse | None): - """Fill the per-curve table from a processed result (+ optional truth). - - Row/column coverage follows what is physically defined: the measured - spectrum carries no phase, so it has no temporal envelope; the - transform-limited pulse is the retrieved ``|E(ω)|`` with the phase removed, - so its spectrum *is* the retrieved one and is left blank rather than - duplicated. Peak powers need a measured pulse energy. - """ - truth_power = truth.peak_power(pr.energy) if truth is not None else None - powers = [ - p - for p in (pr.peak_power, pr.peak_power_tl, truth_power) - if p is not None and np.isfinite(p) - ] - # One SI prefix for the whole column, so the numbers stay comparable. - scale, unit = plotting.si_power(max(powers)) if powers else (1.0, None) - self.readout.set_power_unit(unit) - + def _update_pulse_readout(self, pr: ProcessedResult) -> None: + """Show the retrieved spectrum's peak wavelength and the pulse's FWHM.""" # The wavelength axis is descending and can hold a zero crossing; the - # spectral helpers mask that off (see croak.processing.peak_wavelength). + # spectral helper masks that off (see croak.processing.peak_wavelength). with np.errstate(divide="ignore", invalid="ignore"): - self.readout.set_cells( - "R", - { - "lam_peak": self._nm(peak_wavelength(pr.wavelength, pr.Ilam)), - "lam_fwhm": self._nm(spectral_fwhm(pr.wavelength, pr.Ilam)), - "fwhm": self._fs(pr.fwhm_retr), - "peak_power": self._power(pr.peak_power, scale), - }, - ) - meas = {} - if pr.Iw_meas is not None: - meas = { - "lam_peak": self._nm(peak_wavelength(pr.wavelength, pr.Iw_meas)), - "lam_fwhm": self._nm(spectral_fwhm(pr.wavelength, pr.Iw_meas)), - } - self.readout.set_cells("M", meas) - self.readout.set_cells( - "TL", + lam_peak = self._nm(peak_wavelength(pr.wavelength, pr.Ilam)) + fwhm = self._fs(pr.fwhm_retr) + self.readout.set_scalars( { - "fwhm": self._fs(pr.fwhm_tl), - "peak_power": self._power(pr.peak_power_tl, scale), - }, - ) - truth_cells = {} - if truth is not None: - truth_cells = { - "fwhm": self._fs(truth.fwhm), - "peak_power": self._power(truth_power, scale), + "lam_peak": lam_peak if lam_peak == DASH else f"{lam_peak} nm", + "fwhm": fwhm if fwhm == DASH else f"{fwhm} fs", } - if truth.lam is not None and truth.Iw is not None: - truth_cells["lam_peak"] = self._nm(peak_wavelength(truth.lam, truth.Iw)) - truth_cells["lam_fwhm"] = self._nm(spectral_fwhm(truth.lam, truth.Iw)) - self.readout.set_cells("truth", truth_cells) + ) def _update_truth_error_readout(self, result, truth) -> None: """Show the known-truth errors, or dashes when there is no known pulse. @@ -1810,7 +1707,7 @@ def _update_truth_error_readout(self, result, truth) -> None: self.readout.set_scalars(values) def _update_extras_readout(self, result): - """Show τ₀ / thickness / smearing, each marked ``(fit)`` or ``(fixed)``. + """Show τ₀ / thickness / smearing, each named ``fit`` or ``fixed``. ``result`` is the retrieval to report (``None`` before any run, which shows the nominal values the forward model would use). A fitted extra is one the @@ -1824,50 +1721,62 @@ def _update_extras_readout(self, result): thickness_m, _tau0, smear_scale, _smear_delta = extra_param_centres(p, None) if result is not None and result.thickness is not None: - thickness = f"{result.thickness / 1e-6:.2f} µm (fit)" + thickness = (f"{result.thickness / 1e-6:.2f} µm", "fit") elif p.dispersive: - thickness = f"{thickness_m / 1e-6:.2f} µm (fixed)" + thickness = (f"{thickness_m / 1e-6:.2f} µm", "fixed") else: - thickness = DASH # no slab is modelled at all + thickness = (DASH, "") # no slab is modelled at all # A fitted τ₀ that lands exactly on zero is indistinguishable from a fixed # one in the result; the status line makes the same trade. if result is not None and result.tau0: - tau0 = f"{result.tau0 / 1e-15:.2f} fs (fit)" + tau0 = (f"{result.tau0 / 1e-15:.2f} fs", "fit") else: - tau0 = "0.00 fs (fixed)" + tau0 = ("0.00 fs", "fixed") - smear = DASH + smear = (DASH, "") if result is not None and result.smear_scale is not None: smear_scale, fitted = result.smear_scale, True - smear = f"×{smear_scale:.3f} (fit)" + smear = (f"×{smear_scale:.3f}", "fit") else: fitted = False if p.smearing and td is not None: sigma_fs = self._smear_width_fs(smear_scale, td) if sigma_fs is not None: marker = "fit" if fitted else "fixed" - smear = f"{sigma_fs:.2f} fs (×{smear_scale:.3f}, {marker})" + smear = (f"{sigma_fs:.2f} fs", f"×{smear_scale:.3f} {marker}") - self.readout.set_scalars( - {"tau0": tau0, "thickness": thickness, "smearing": smear} - ) + shown = {"tau0": tau0, "thickness": thickness, "smearing": smear} + self.readout.set_scalars({key: value for key, (value, _) in shown.items()}) + self.readout.set_notes({key: note for key, (_, note) in shown.items()}) def _on_failed(self, message): - self.run_btn.setEnabled(True) - self.stop_btn.setEnabled(False) + self.set_running(False) self._elapsed_timer.stop() self._conv_timer.stop() - # Keep the last true iteration/error/elapsed; blank the curve table, whose - # numbers would otherwise be silently stale. + # Blank the numbers, which would otherwise be silently stale. self.readout.clear() self._update_extras_readout(None) - self.set_status("Stopped." if message == "stopped" else f"Failed: {message}") + if message == "stopped": + self.set_status("Stopped.") + else: + self.set_status(f"Failed: {message}", "error") + + def set_status(self, text: str, state: str = "ok") -> None: + """Say ``text`` at the foot of the window; a failure also over the plots. + + Retrieve has no ready line (its numbers say how a run goes), so what it + has to say is a passing notice, and a failed retrieval stays in the + advice box until the next run starts. + """ + self.message.emit(text) + if state == "error": + self.advice.say("The retrieval failed", text) def _retarget(self): - """Ask for a new dataset folder and hand off to the wizard to replay it. + """Ask for a new dataset folder and hand off to the window to replay it. - The wizard rewrites the dataset-folder load paths (keeping calibration + The window rewrites the dataset-folder load paths (keeping calibration fixed) and reruns load → preprocess → retrieve with the current settings. """ folder = QFileDialog.getExistingDirectory(self, "Select new dataset folder") @@ -1875,25 +1784,3 @@ def _retarget(self): return self.set_status(f"Retargeting to {folder} …") self.state.retarget_requested.emit(folder) - - def _save(self): - if self.state.result is None: - return - folder = QFileDialog.getExistingDirectory(self, "Select output folder") - if not folder: - return - save.save_result( - self.state.result, - os.path.join(folder, "result.h5"), - processed=self.state.processed, - truth=self.state.truth, - force=True, - ) - save.save_options(self.state.to_options(), os.path.join(folder, "options.toml")) - # The PDF is the full twelve-panel overview drawn afresh at its design size - # (each tab's toolbar Save writes that page instead). - data = self._plot_data(self.state.result, self.state.processed) - plotting.plot_retrieval_page(data, plotting.RETRIEVAL_OVERVIEW).savefig( - os.path.join(folder, "retrieval.pdf"), dpi=600 - ) - self.set_status(f"Saved result.h5, options.toml, retrieval.pdf to {folder}") diff --git a/croak/gui/stage_simulated.py b/croak/gui/stage_simulated.py index 1c2cc59..7f18f19 100644 --- a/croak/gui/stage_simulated.py +++ b/croak/gui/stage_simulated.py @@ -17,10 +17,11 @@ from __future__ import annotations import numpy as np -from PyQt6.QtCore import QTimer -from PyQt6.QtWidgets import ( +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import ( QComboBox, QFileDialog, + QGridLayout, QHBoxLayout, QLineEdit, QPushButton, @@ -35,22 +36,18 @@ read_simulated_window_keys, read_simulated_z_positions, ) -from ..maths import wlfreq -from ..plotting import cmap_white, signed_pcolormesh +from ..plotting.screen import simulated_trace_views from ..session.params import SimulatedLoadParams from ..session.pipeline import ( assemble_simulated_load_data, assemble_simulated_tracedata, ) from .base import Stage -from .canvas import MplCanvas, add_wide_row, check, combo, group, with_toolbar +from .controls import add_wide_row, check, combo, group from .stage_load import seed_preproc_defaults +from .view_panel import ViewPanel from .widgets import RangeControl -# Angular frequency (rad/s) -> ordinary frequency (PHz), matching the -# marginal-check stage's frequency axis so the two previews read identically. -_TWO_PI_PHZ = 2.0 * np.pi * 1e15 - # TG-FROG keeps the carrier (no doubling), so only the χ³ interactions apply. _INTERACTIONS = ["pg", "sd"] # Reference spectrum: the post-mask "beamlet" (the beam that gates) or the @@ -175,22 +172,24 @@ class StageSimulated(Stage): "preprocess (filtering + regrid) stages. Fully raw: no " "third-order correction; the window/thickness, delay-sign and " "known-spectrum choices still apply. Use it to sanity-check the " - "retrieval free of any preprocessing doubt — Back from Retrieve " - "reaches Preprocess for a regridded comparison.", + "retrieval free of any preprocessing doubt. For a regridded " + "comparison, untick it here and Continue again.", ), ], ), ] - def __init__(self, state, wizard): + def __init__(self, state, adopt_geometry): super().__init__(state) - self.wizard = wizard + # Retrieve's StageRetrieve.apply_simulated_geometry: adopt the file's + # slab thickness and mask on an interactive load (see advance). + self.adopt_geometry = adopt_geometry self._defaults = SimulatedLoadParams() # the path the file-driven selectors (trace window + thickness) were last # populated for, so re-entering the stage keeps the user's choices self._options_path: str | None = None # Nothing can be loaded until a file previews successfully; drives the - # wizard footer's forward button (see can_advance). + # Data page's Continue (see can_advance). self._can_load = False # debounce rapid control changes into one reload/preview @@ -281,7 +280,7 @@ def __init__(self, state, wizard): add_wide_row(form, "load λ band (nm)", self.band) # Skip the marginal-check + preprocess stages and load the as-simulated # ω-density trace on its native grid straight into the retrieval. "&&" is - # Qt's escape for a literal ampersand in a widget label (see help._plain). + # Qt's escape for a literal ampersand in a widget label (see sheet.plain). self.raw_direct = check( "Skip filtering && regrid (raw → retrieval)", self._defaults.raw_direct, @@ -290,13 +289,24 @@ def __init__(self, state, wizard): form.addRow(self.raw_direct) self.controls.addWidget(box) - # Navigation (Back / "Load & continue →") and Help live in the shared - # wizard chrome, via the entry-page hooks below. - self.controls.addWidget(self.status_label) + # Continue (which calls advance) and Help live in the window's top bar. self.controls.addStretch(1) - self.canvas = MplCanvas(figsize=(9, 7)) - self.set_plot_area(with_toolbar(self.canvas)) + self.views = { + "lin": ViewPanel("map", signed=True), + "db": ViewPanel("map"), + "delay": ViewPanel("line"), + "spectral": ViewPanel("line"), + } + grid = QGridLayout() + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(14) + grid.setVerticalSpacing(10) + for index, view in enumerate(self.views.values()): + grid.addWidget(view, index // 2, index % 2) + plots = QWidget() + plots.setLayout(grid) + self.set_plot_area(plots) self.apply_help() @@ -460,13 +470,13 @@ def _on_band(self, lo, hi): def _on_raw_direct(self, _checked: bool) -> None: # Only changes what Load does (not the preview), so just relabel the - # wizard's forward button (see next_label). - self.wizard.refresh_nav() + # forward action. + self.changed.emit() def _set_can_load(self, can: bool) -> None: - """Record whether a trace previewed, and refresh the wizard's Next button.""" + """Record whether a trace previewed, and refresh the window's Continue.""" self._can_load = can - self.wizard.refresh_nav() + self.changed.emit() def _pick_file(self): fn, _ = QFileDialog.getOpenFileName( @@ -526,89 +536,19 @@ def _preview(self): if self.thickness.isEnabled(): self.set_status( f"Loaded the {self.thickness.currentText()} slice — review the " - "trace, then Load & continue to filter it." + "trace, then Continue to check and filter it." ) else: - self.set_status("Review the trace, then Load & continue to filter it.") + self.set_status("Review the trace, then Continue to check and filter it.") def _draw(self, data): - # The simulation file stores an ω-density; the loader divides it by λ² so - # it can ride the (spectrometer) wavelength pipeline. Here we display the - # file-native ω-density on a frequency axis (×λ², ν = ω/2π) so the preview - # matches the file, the marginal-check screen and the retrieval — see - # stage_marginal_check.py. λ ascends as ν descends, so reorder to ascending ν. - lam = data["lam"] # m, ascending - delays = data["scanaxis"] / 1e-15 - freq = wlfreq(lam) / _TWO_PI_PHZ # PHz, descending in lam order - order = np.argsort(freq) # -> ascending frequency - freq = freq[order] - trace = (data["trace"] * lam[:, None] ** 2)[order] # ω-density - # Re-normalise to unit peak: ×λ² rescales the (unit-peak λ-density) trace - # to a tiny absolute level, which the dB view (log10 ∈ [−tracedb, 0]) - # would otherwise clip entirely. - peak = float(np.nanmax(np.abs(trace))) - if peak > 0: - trace = trace / peak - # rows with signal set the frequency limits; the peak row always - # qualifies, so this is never empty (a blank trace shows its full range) - sig = freq[trace.max(axis=1) > 1e-2] if peak > 0 else freq - - def plot(fig): - axd = fig.subplot_mosaic("ab\ncd") - # linear and dB views with the filtering-stage colour scheme (white→viridis - # positive, white→red negative). Simulated traces carry no detector noise, - # so the linear vmax is the full-range peak (no trust window needed). - cmap_pos = cmap_white("viridis") - signed_pcolormesh( - axd["a"], - delays, - freq, - trace, - db=False, - cmap_pos=cmap_pos, - vmax=1.0 if peak > 0 else None, - ) - axd["a"].set_title("Simulated FROG trace (lin)") - signed_pcolormesh( - axd["b"], delays, freq, trace, db=True, tracedb=40.0, cmap_pos=cmap_pos - ) - axd["b"].set_title("Simulated FROG trace (dB)") - for key in ("a", "b"): - axd[key].set_xlabel("Delay (fs)") - axd[key].set_ylabel("Frequency (PHz)") - axd[key].set_ylim(sig.min(), sig.max()) - # delay marginal - tmarg = trace.sum(axis=0) - tmarg = tmarg / tmarg.max() if tmarg.max() > 0 else tmarg - axd["c"].plot(delays, tmarg, c="C0") - axd["c"].set_xlabel("Delay (fs)") - axd["c"].set_ylabel("Delay marginal") - # spectral marginal (ω-density) + the known input spectrum, if provided - wmarg = trace.sum(axis=1) - wmarg = wmarg / wmarg.max() if wmarg.max() > 0 else wmarg - axd["d"].plot(freq, wmarg, c="C0", label="trace marginal") - if data["lam_spec"] is not None and data["Ilam_spec"] is not None: - lam_s = data["lam_spec"] - f_s = wlfreq(lam_s) / _TWO_PI_PHZ - os_ = np.argsort(f_s) - spec = (data["Ilam_spec"] * lam_s**2)[os_] # ω-density - spec = spec / spec.max() if spec.max() > 0 else spec - axd["d"].plot(f_s[os_], spec, c="C1", lw=1, label="input") - axd["d"].legend(fontsize="small") - axd["d"].set_xlabel("Frequency (PHz)") - axd["d"].set_ylabel("Spectral marginal (ω-density)") - axd["d"].set_xlim(sig.min(), sig.max()) - - self.canvas.render(plot) + """Preview the scan: linear and dB maps, delay and spectral marginals.""" + for view, shown in zip( + self.views.values(), simulated_trace_views(data), strict=True + ): + view.show_view(shown) # -- load (the entry-page forward action) ------------------------------- - def next_label(self) -> str: - # "Skip filtering & regrid" changes where Load lands, so say so. The "&&" - # is Qt's escape for a literal ampersand in a button label (a single "&" - # is swallowed as a mnemonic marker, rendering "Load continue →"). - if self.raw_direct.isChecked(): - return "Load raw → Retrieve →" - return "Load && continue →" def can_advance(self) -> bool: return self._can_load @@ -616,11 +556,11 @@ def can_advance(self) -> bool: def advance(self): """Load the chosen simulated scan and hand it to the next stage. - Wired to the wizard footer's forward button. Normally the trace goes into + The Data page's Continue calls it. Normally the trace goes into the marginal-check stage; with "Skip filtering & regrid" ticked the - as-simulated trace is built on its native grid and the wizard jumps - straight to Retrieve. On failure the reason is shown in the status label - and the page stays put. + as-simulated trace is built on its native grid and the session goes + straight to Retrieve (:attr:`advanced` says which). On failure the reason + is shown in the status label and the page stays put. """ params = self._params() try: @@ -643,20 +583,20 @@ def advance(self): # chosen slice, mask hole diameter and spacing). Only on this # interactive path: a restored session keeps its own saved retrieval # settings, which the user may have chosen deliberately. - note = self.wizard.stages[3].apply_simulated_geometry(self.state.geometry) + note = self.adopt_geometry(self.state.geometry) if note: self.set_status(note) if params.raw_direct: # Build the as-simulated trace on its native grid and go straight to # retrieval, skipping the marginal-check + preprocess stages. The - # normal load_data above stays set (it carries the truth overlay and - # lets Back re-run the regrid path for a comparison). + # normal load_data above stays set (it carries the truth overlay, and + # unticking the option re-runs the regridded path for a comparison). try: td = assemble_simulated_tracedata(params) except Exception as exc: self.set_status(f"Raw load failed: {exc}") return self.state.set_tracedata(td) - self.wizard.enter_retrieve_from_simulated() + self.advanced.emit("retrieve") else: - self.wizard.enter_marginal_from_simulated() + self.advanced.emit("check") diff --git a/croak/gui/stage_synthetic.py b/croak/gui/stage_synthetic.py index 0745278..90419a0 100644 --- a/croak/gui/stage_synthetic.py +++ b/croak/gui/stage_synthetic.py @@ -17,7 +17,8 @@ import math import numpy as np -from PyQt6.QtCore import QTimer +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QGridLayout, QWidget from .. import dispersion from ..constants import C @@ -25,20 +26,20 @@ from ..grid import Grid from ..interactions import INTERACTIONS, get_interaction from ..materials import MATERIALS +from ..plotting.screen import pulse_field_views, raw_trace_map from ..processing import TruthPulse from ..pulses import gaussian_pulse, sech_pulse from .base import Stage -from .canvas import ( - MplCanvas, +from .controls import ( add_wide_row, check, combo, dspin, group, spin, - with_toolbar, ) from .stage_load import seed_preproc_defaults +from .view_panel import ViewPanel from .widgets import RangeControl, ValueControl # dispersion slider ranges follow StageDispersion (fs² / fs³ / fs⁴) @@ -51,9 +52,8 @@ class StageSynthetic(Stage): """Synthetic-pulse / synthetic-FROG generator (reached from the menu).""" - def __init__(self, state, wizard): + def __init__(self, state): super().__init__(state) - self.wizard = wizard self._last = None # cache of the last computed (grid, ew, omega0) # debounce rapid slider changes into one recompute @@ -172,15 +172,26 @@ def __init__(self, state, wizard): add_wide_row(form, "Poisson counts", self.noise_pois) self.controls.addWidget(box) - # Navigation (Back / "Generate →") lives in the shared wizard footer, via - # the entry-page hooks below. - self.controls.addWidget(self.status_label) + # Continue lives in the window's top bar; the Data page calls advance. self.controls.addStretch(1) self._seed_sampling() - self.canvas = MplCanvas(figsize=(9, 7)) - self.set_plot_area(with_toolbar(self.canvas)) + self.views = { + "temporal": ViewPanel("line", right=True), + "spectral": ViewPanel("line", right=True), + "spectrogram": ViewPanel("map"), + "trace": ViewPanel("map", signed=True), + } + grid = QGridLayout() + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(14) + grid.setVerticalSpacing(10) + for index, view in enumerate(self.views.values()): + grid.addWidget(view, index // 2, index % 2) + plots = QWidget() + plots.setLayout(grid) + self.set_plot_area(plots) # -- lifecycle ---------------------------------------------------------- def on_enter(self): @@ -433,119 +444,57 @@ def _recompute(self): self.set_status(f"Generation failed: {exc}") return self._last = (grid, ew, omega0) - # show the trace as it will be handed on (with any noise applied) - self._draw(grid, ew, omega0, lam_sig, delays, self._add_noise(trace)) - self.set_status("Adjust parameters, then Generate to run retrieval.") + # Show the trace as Continue hands it on: sampled onto the λ window, with + # any noise. The raw trace spans the whole simulation grid, far wider + # than the signal, and drawn as it is it shows a featureless stripe. + lam, meas = self._measured(lam_sig, delays, trace) + if meas.max() <= 0: + self.set_status( + "Trace is empty in the chosen λ window; widen the range.", "warn" + ) + return + self._draw(grid, ew, omega0, lam, delays, meas / meas.max()) + self.set_status("Adjust the pulse and the trace, then Continue to check it.") - def _draw(self, grid, ew, omega0, lam_sig, delays, trace): - def plot(fig): - axd = fig.subplot_mosaic("ab\ncd") - self._plot_temporal(axd["a"], grid, ew) - self._plot_spectral(axd["b"], grid, ew, omega0) - self._plot_spectrogram(axd["c"], grid, ew, omega0) - self._plot_frog(axd["d"], lam_sig, delays, trace) + def _measured(self, lam_sig, delays, trace): + """Sample the trace onto the user's λ window, as a spectrometer would. - self.canvas.render(plot) + Returns ``(lam, trace)``: the window's wavelengths (m) and the trace on + them, with the chosen noise; zero outside the simulated band. + """ + lo, hi = self._wl[0] * 1e-9, self._wl[1] * 1e-9 + lam = np.linspace(lo, hi, int(self.nwl.value())) + meas = np.empty((lam.size, delays.size)) + for j in range(delays.size): + meas[:, j] = np.interp(lam, lam_sig, trace[:, j], left=0.0, right=0.0) + return lam, self._add_noise(meas) - @staticmethod - def _masked_phase(intensity, field, frac=1e-3): - phase = np.unwrap(np.angle(field)) - mask = intensity < intensity.max() * frac - phase = phase - phase[int(np.argmax(intensity))] - phase[mask] = np.nan - return phase - - def _plot_temporal(self, ax, grid, ew): - Et = grid.ifft(ew) - inten = np.abs(Et) ** 2 - inten = inten / inten.max() if inten.max() > 0 else inten - t = grid.t / 1e-15 - ax.plot(t, inten, c="C0") - axp = ax.twinx() - axp.plot(t, self._masked_phase(inten, Et), c="C1", lw=1) - axp.set_ylabel("Phase (rad)", color="C1") - # zoom to the pulse - sig = t[inten > 1e-3] - if sig.size: - pad = 0.2 * (sig.max() - sig.min() + 1e-9) - ax.set_xlim(sig.min() - pad, sig.max() + pad) - ax.set_xlabel("Time (fs)") - ax.set_ylabel("Intensity") - ax.set_title("Temporal") - - def _plot_spectral(self, ax, grid, ew, omega0): - inten = np.abs(ew) ** 2 - inten = inten / inten.max() if inten.max() > 0 else inten - omega_abs = grid.omega + omega0 - pos = omega_abs > 0 - lam = 2 * math.pi * C / omega_abs[pos] / 1e-9 - order = np.argsort(lam) - lam = lam[order] - Ip = inten[pos][order] - phase = self._masked_phase(inten, ew)[pos][order] - ax.plot(lam, Ip, c="C0") - axp = ax.twinx() - axp.plot(lam, phase, c="C1", lw=1) - axp.set_ylabel("Phase (rad)", color="C1") - sig = lam[Ip > 1e-3] - if sig.size: - pad = 0.2 * (sig.max() - sig.min() + 1e-9) - ax.set_xlim(sig.min() - pad, sig.max() + pad) - ax.set_xlabel("Wavelength (nm)") - ax.set_ylabel("Intensity") - ax.set_title("Spectral") - - def _plot_spectrogram(self, ax, grid, ew, omega0, n_t=160): - """A Gabor spectrogram of the field (time × wavelength).""" - Et = grid.ifft(ew) - t = grid.t - inten = np.abs(Et) ** 2 - sig = t[inten > inten.max() * 1e-3] if inten.max() > 0 else t - tmin = sig.min() - 0.3 * (sig.max() - sig.min() + 1e-15) - tmax = sig.max() + 0.3 * (sig.max() - sig.min() + 1e-15) - tcs = np.linspace(tmin, tmax, n_t) - fw = max((tmax - tmin) / 30.0, grid.dt * 4) - S = np.empty((grid.n, n_t)) - for i, tc in enumerate(tcs): - gate = np.exp(-0.5 * ((t - tc) / fw) ** 2) - sp = np.fft.fftshift(np.fft.fft(np.fft.ifftshift(Et * gate))) - S[:, i] = np.abs(sp) ** 2 - omega_abs = grid.omega + omega0 - pos = omega_abs > 0 - S = S[pos] * (omega_abs[pos] ** 2)[:, None] - lam = 2 * math.pi * C / omega_abs[pos] / 1e-9 - order = np.argsort(lam) - S = S[order] - lam = lam[order] - S = S / S.max() if S.max() > 0 else S - ax.pcolormesh(tcs / 1e-15, lam, S, shading="auto") - lam_sig = lam[(S.max(axis=1) > 1e-2)] - if lam_sig.size: - ax.set_ylim(lam_sig.min(), lam_sig.max()) - ax.set_xlabel("Time (fs)") - ax.set_ylabel("Wavelength (nm)") - ax.set_title("Spectrogram") - - def _plot_frog(self, ax, lam_sig, delays, trace): - ax.pcolormesh(delays / 1e-15, lam_sig / 1e-9, trace, shading="auto") - sig = lam_sig[(trace.max(axis=1) > 1e-2)] / 1e-9 - if sig.size: - ax.set_ylim(sig.min(), sig.max()) - ax.set_xlabel("Delay (fs)") - ax.set_ylabel("Wavelength (nm)") - ax.set_title("Synthetic FROG trace") + def _draw(self, grid, ew, omega0, lam_sig, delays, trace): + """Show the pulse, its spectrum and spectrogram, and its trace.""" + temporal, spectral, gram = pulse_field_views(grid, ew, omega0) + self.views["temporal"].show_view(temporal) + self.views["spectral"].show_view(spectral) + self.views["spectrogram"].show_view(gram) + self.views["trace"].show_view( + raw_trace_map( + delays / 1e-15, + lam_sig / 1e-9, + trace, + # a synthetic trace has no out-of-band noise: trust all of it + (0.0, float("inf")), + title="Synthetic FROG trace", + ) + ) # -- generate (the entry-page forward action) --------------------------- - def next_label(self) -> str: - return "Generate →" def advance(self): """Build the synthetic trace from the current settings and move on. - Wired to the wizard footer's forward button. On success the trace (and the - generating field, as the known ground truth) is put on the shared state and - the wizard advances to the marginal-check stage; on failure the reason is - shown in the status label and the page stays put. + The Data page's Continue calls it. On success the trace (and the + generating field, as the known ground truth) is put on the shared state + and :attr:`advanced` sends the session to the marginal check; on failure + the reason is shown in the status label and the page stays put. """ try: grid, ew, omega0 = self._pulse() @@ -554,13 +503,7 @@ def advance(self): self.set_status(f"Generation failed: {exc}") return interaction = self.interaction.currentText() - # sample onto the user's measurement wavelength axis (emulate a spectrometer) - lo, hi = self._wl[0] * 1e-9, self._wl[1] * 1e-9 - lam_user = np.linspace(lo, hi, int(self.nwl.value())) - meas = np.empty((lam_user.size, delays.size)) - for j in range(delays.size): - meas[:, j] = np.interp(lam_user, lam_sig, trace[:, j], left=0.0, right=0.0) - meas = self._add_noise(meas) + lam_user, meas = self._measured(lam_sig, delays, trace) m = meas.max() if m <= 0: self.set_status("Trace is empty in the chosen λ window; widen the range.") @@ -583,4 +526,4 @@ def advance(self): self.state.load.interaction = interaction self.state.set_load_data(data) seed_preproc_defaults(self.state) - self.wizard.enter_marginal_from_synthetic() + self.advanced.emit("check") diff --git a/croak/gui/stage_uncertainty.py b/croak/gui/stage_uncertainty.py index 8f1138a..6ce4e8e 100644 --- a/croak/gui/stage_uncertainty.py +++ b/croak/gui/stage_uncertainty.py @@ -11,11 +11,10 @@ from __future__ import annotations -import os +from pathlib import Path -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( - QFileDialog, +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( QHBoxLayout, QListWidget, QListWidgetItem, @@ -23,11 +22,14 @@ QWidget, ) -from .. import plotting, save +from .. import plotting +from ..plotting.screen import uncertainty_views from ..processing import TruthPulse -from ..uncertainty import combine_uncertainties -from .base import Stage -from .canvas import MplCanvas, check, combo, dspin, group, spin, with_toolbar +from ..session.files import UNCERTAINTY_FIGURE +from ..uncertainty import UncertaintyResult, combine_uncertainties +from .base import RunControls, Stage +from .controls import check, combo, dspin, group, spin +from .view_panel import ViewPanel _METHODS = ["parametric", "delay", "frequency", "thickness", "covariance"] _NOISE_SOURCES = ["residual", "manual"] @@ -39,7 +41,7 @@ def _truth_fwhm(truth: TruthPulse | None) -> float | None: return None if truth is None else truth.fwhm -class StageUncertainty(Stage): +class StageUncertainty(RunControls, Stage): """Estimate and display the statistical uncertainty of the retrieved FWHM.""" HELP_TITLE = "Uncertainty" @@ -122,51 +124,40 @@ def __init__(self, state): form.addRow("Interval", self.interval) self.controls.addWidget(box) - row = QWidget() - rl = QHBoxLayout(row) - rl.setContentsMargins(0, 0, 0, 0) - self.run_btn = QPushButton("Estimate") - self.run_btn.clicked.connect(self._run) - self.stop_btn = QPushButton("Stop") - self.stop_btn.clicked.connect(self._stop) - self.stop_btn.setEnabled(False) - rl.addWidget(self.run_btn) - rl.addWidget(self.stop_btn) - self.controls.addWidget(row) - # Retained estimates: each completed run is kept so several independent - # contributions can be combined into a total before saving. Tick the ones to - # combine (don't mix redundant statistical estimators); click one to view it. + # contributions can be combined into a total (Save session keeps them all). + # Tick the ones to combine (don't mix redundant statistical estimators); + # click one to view it. est_box, est_form = group("Estimates") self.est_list = QListWidget() self.est_list.setMaximumHeight(110) self.est_list.currentItemChanged.connect(self._on_select_estimate) self.est_list.itemChanged.connect(lambda _item: self._sync_enabled()) est_form.addRow(self.est_list) - est_row = QWidget() - erl = QHBoxLayout(est_row) - erl.setContentsMargins(0, 0, 0, 0) self.combine_btn = QPushButton("Combine ticked") self.combine_btn.setToolTip( "Combine the ticked independent estimates in quadrature into a total." ) self.combine_btn.clicked.connect(self._combine) - self.save_btn = QPushButton("Save…") - self.save_btn.setToolTip( - "Append all estimates (intervals + temporal bands) to result.h5." - ) - self.save_btn.clicked.connect(self._save) - erl.addWidget(self.combine_btn) - erl.addWidget(self.save_btn) - est_form.addRow(est_row) + est_form.addRow(self.combine_btn) self.controls.addWidget(est_box) self.controls.addStretch(1) - self.controls.addWidget(self.status_label) self._sync_enabled() - self.canvas = MplCanvas(figsize=(11, 4.5)) - self.set_plot_area(with_toolbar(self.canvas)) + # Up to three panels side by side: a histogram (or FWHM and error against + # thickness) and the temporal confidence band; unused ones are hidden. + self.views = [ViewPanel("line") for _ in range(3)] + row = QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(14) + for view in self.views: + row.addWidget(view) + view.hide() + plots = QWidget() + plots.setLayout(row) + self.set_plot_area(plots) + self._shown: UncertaintyResult | None = None self.apply_help() # -- control bindings --------------------------------------------------- @@ -208,7 +199,6 @@ def _sync_enabled(self): self.noise_sigma.setEnabled(needs_noise and u.noise_source == "manual") self.thickness_sigma.setEnabled(u.method == "thickness") self.combine_btn.setEnabled(len(self._checked_methods()) >= 2) - self.save_btn.setEnabled(bool(self.state.uncertainty_results)) # -- retained estimates ------------------------------------------------- def _label(self, method: str, u) -> str: @@ -275,38 +265,28 @@ def _combine(self): self._draw(combined) self.set_status(combined.summary()) - def _save(self): - if self.state.result is None or not self.state.uncertainty_results: - self.set_status("Run an estimate first.") - return - folder = QFileDialog.getExistingDirectory(self, "Select output folder") - if not folder: - return - path = os.path.join(folder, "result.h5") - save.save_result( - self.state.result, - path, - processed=self.state.processed, - truth=self.state.truth, - force=True, - ) - save.save_uncertainty(self.state.uncertainty_results, path, force=True) - save.save_options(self.state.to_options(), os.path.join(folder, "options.toml")) - saved = "result.h5 (+uncertainty), options.toml" - # Estimates restored from a saved session may not have been plotted yet; - # the PDF documents the figure on screen, so there is nothing to export. - if self.canvas.rendered: - self.canvas.save_figure(os.path.join(folder, "uncertainty.pdf"), dpi=300) - saved += ", uncertainty.pdf" - self.set_status(f"Saved {saved} to {folder}") + def save_extras(self, folder: Path) -> list[str]: + """Write the figure of the estimate on screen (Save session appends every + estimate to its ``result.h5``). + + Nothing if none is drawn, or if the one drawn no longer holds: a new + retrieval clears the session's estimates + (:meth:`~croak.gui.state.WizardState.set_result`), and its figure would + then sit beside a ``result.h5`` without it. + """ + shown = self._shown + current = self.state.uncertainty_results.values() + if shown is None or not any(estimate is shown for estimate in current): + return [] + figure = plotting.plot_uncertainty(shown, truth=_truth_fwhm(self.state.truth)) + figure.savefig(folder / UNCERTAINTY_FIGURE, dpi=300) + return [UNCERTAINTY_FIGURE] # -- lifecycle ---------------------------------------------------------- def on_enter(self): if self.state.result is None: - self.run_btn.setEnabled(False) self.set_status("Retrieve a pulse first.") return - self.run_btn.setEnabled(True) self._refresh_estimates() if self.state.uncertainty_result is not None: self._draw(self.state.uncertainty_result) @@ -319,12 +299,11 @@ def _run(self): if self.state.result is None or self.state.tracedata is None: self.set_status("Retrieve a pulse first.") return - # Import here so the rest of the GUI imports even if PyQt-less envs build + # Import here so the rest of the GUI imports even if Qt-less envs build # the worker lazily; the worker pulls in the (heavier) solver stack. from .worker import UncertaintyWorker - self.run_btn.setEnabled(False) - self.stop_btn.setEnabled(True) + self.set_running(True) self.set_status("Estimating…") self._worker = UncertaintyWorker( self.state.result, @@ -342,27 +321,38 @@ def _stop(self): if self._worker is not None: self._worker.request_stop() + def act(self, kind: str) -> bool: + """Estimate or stop from the window's primary action (never moves on).""" + if kind == "estimate": + self._run() + return False + if kind == "stop-estimate": + self._stop() + return False + return super().act(kind) + def _on_progress(self, done, total, error): self.set_status(f"replicate {done}/{total}: R = {error:.4%}") def _on_finished(self, uresult): - self.run_btn.setEnabled(True) - self.stop_btn.setEnabled(False) + self.set_running(False) self.state.set_uncertainty_result(uresult) self._refresh_estimates() self._draw(uresult) self.set_status(uresult.summary()) def _on_failed(self, message): - self.run_btn.setEnabled(True) - self.stop_btn.setEnabled(False) + self.set_running(False) if message == "stopped": self.set_status("Stopped.") else: self.set_status(f"Uncertainty estimation failed: {message}") def _draw(self, uresult): - truth = _truth_fwhm(self.state.truth) - self.canvas.render( - lambda fig: plotting.plot_uncertainty(uresult, truth=truth, fig=fig) - ) + views = uncertainty_views(uresult, truth=_truth_fwhm(self.state.truth)) + for panel, view in zip(self.views, views, strict=False): + panel.show_view(view) + panel.show() + for panel in self.views[len(views) :]: + panel.hide() + self._shown = uresult diff --git a/croak/gui/stage_welcome.py b/croak/gui/stage_welcome.py deleted file mode 100644 index 0ebf867..0000000 --- a/croak/gui/stage_welcome.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Welcome / main-menu page: the first screen the wizard shows. - -Offers the entry points into the application: load experimental traces for -retrieval (the normal Stage 1 pathway), load a numerically simulated trace -(``scansave`` HDF5 → preprocess), load a previous retrieval (the saved -``options.toml`` replay), or generate a synthetic trace. The page only drives -navigation; the actual work lives in the wizard and the stages it routes to. -""" - -from __future__ import annotations - -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( - QLabel, - QPushButton, - QSizePolicy, - QVBoxLayout, - QWidget, -) - -__all__ = ["StageWelcome"] - -_WELCOME = ( - "Welcome to croak — a pulse-retrieval workbench.
" - "Choose how you would like to begin." -) - - -class StageWelcome(QWidget): - """A simple centred menu with the entry-point buttons. - - Takes the owning :class:`~croak.gui.wizard.Wizard` so the buttons can drive - navigation (``start_load``/``load_simulated``/``load_previous``/ - ``show_synthetic``). - """ - - def __init__(self, wizard): - super().__init__() - self.wizard = wizard - - outer = QVBoxLayout(self) - outer.addStretch(1) - - title = QLabel("croak") - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - title.setStyleSheet("font-size: 42px; font-weight: bold; color: #22324a;") - outer.addWidget(title) - - msg = QLabel(_WELCOME) - msg.setAlignment(Qt.AlignmentFlag.AlignCenter) - msg.setStyleSheet("font-size: 15px; padding: 8px 0 24px 0;") - outer.addWidget(msg) - - for label, slot in ( - ("Load experimental traces for retrieval", wizard.start_load), - ("Load simulated trace", wizard.load_simulated), - ("Load previous retrieval", wizard.load_previous), - ("Generate synthetic trace", wizard.show_synthetic), - ): - btn = QPushButton(label) - btn.setMinimumHeight(56) - btn.setMaximumWidth(420) - btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - btn.setStyleSheet("font-size: 15px; padding: 10px;") - btn.clicked.connect(slot) - wrap = QWidget() - wl = QVBoxLayout(wrap) - wl.setContentsMargins(0, 0, 0, 0) - wl.setAlignment(Qt.AlignmentFlag.AlignCenter) - wl.addWidget(btn) - outer.addWidget(wrap) - - outer.addStretch(2) diff --git a/croak/gui/state.py b/croak/gui/state.py index 0145f3f..c74c77d 100644 --- a/croak/gui/state.py +++ b/croak/gui/state.py @@ -3,7 +3,7 @@ ``WizardState`` (a ``QObject``) holds the four stages' parameters and the pipeline outputs (``load_data`` → ``tracedata`` → ``result`` → ``processed``), emits change signals, and implements cascading invalidation so editing an -upstream stage clears the downstream results — the wizard can then never show a +upstream stage clears the downstream results — the window can then never show a retrieved pulse that belongs to a superseded set of load/preprocess parameters. """ @@ -12,7 +12,7 @@ from dataclasses import fields from typing import TYPE_CHECKING -from PyQt6.QtCore import QObject, pyqtSignal +from PySide6.QtCore import QObject, Signal from ..session.options import SessionOptions from ..session.params import ( @@ -45,18 +45,17 @@ class WizardState(QObject): - """Holds wizard parameters and pipeline outputs, with change signals.""" - - stage_changed = pyqtSignal(int) - load_data_changed = pyqtSignal() - tracedata_changed = pyqtSignal() - result_changed = pyqtSignal() - uncertainty_changed = pyqtSignal() - status = pyqtSignal(str) - # Request the wizard to point the current session at a new dataset folder + """Holds the session's parameters and pipeline outputs, with change signals.""" + + load_data_changed = Signal() + tracedata_changed = Signal() + result_changed = Signal() + uncertainty_changed = Signal() + status = Signal(str) + # Request the window to point the current session at a new dataset folder # (its argument) and replay load → preprocess → retrieve. Emitted by the - # Retrieve stage's "Retarget…" button; handled in croak.gui.wizard.Wizard. - retarget_requested = pyqtSignal(str) + # Retrieve stage's "Retarget…" button; handled in croak.gui.window.MainWindow. + retarget_requested = Signal(str) def __init__(self): super().__init__() @@ -75,7 +74,6 @@ def __init__(self): self.dispersion = DispersionParams() self.uncertainty = UncertaintyParams() - self._stage = 1 self.file_datasets: FileDatasets | None = None # FROG file datasets self.load_data: dict | None = None # lam, tau, trace, lam_spec, Ilam_spec self.tracedata: TraceData | None = None @@ -119,41 +117,13 @@ def geometry(self): return self.load_data.get("geometry") return None - # -- navigation --------------------------------------------------------- - @property - def stage(self) -> int: - return self._stage - - @stage.setter - def stage(self, value: int) -> None: - value = max(1, min(6, value)) - if value != self._stage: - self._stage = value - self.stage_changed.emit(value) - - def ready(self, stage: int) -> bool: - """Whether the Next button should be enabled for ``stage``. - - Stage order: Load(1) → Marginal check(2) → Preprocess(3) → Retrieve(4) → - Dispersion(5) → Uncertainty(6). The marginal-check and preprocess stages - only need a loaded trace; the later stages need a completed retrieval. - """ - return { - 1: self.load_data is not None, - 2: self.load_data is not None, - 3: self.load_data is not None, - 4: self.result is not None, - 5: self.result is not None, - 6: True, - }.get(stage, False) - # -- outputs with cascading invalidation -------------------------------- @property def has_data(self) -> bool: """Whether the session holds work a new session would discard. True once a trace is loaded (or generated) or a retrieval exists; the - wizard asks before leaving such a session for the welcome menu. + window asks before a new session replaces it. """ return ( self.load_data is not None @@ -213,7 +183,6 @@ def reset(self) -> None: self.uncertainty_results = {} self.preproc_defaults = None self.trace_sig = None - self._stage = 1 self.load_data_changed.emit() self.tracedata_changed.emit() self.result_changed.emit() @@ -240,7 +209,7 @@ def from_options(self, options: dict) -> None: The stage widgets bind to these dataclass *instances* by reference, so we copy the parsed field values into the existing objects rather than - replacing them (the wizard then rebuilds the controls to reflect them). + replacing them (the window then rebuilds the pages to reflect them). """ parsed = SessionOptions.from_dict(options) self.entry = parsed.entry diff --git a/croak/gui/theme.py b/croak/gui/theme.py new file mode 100644 index 0000000..7cbc030 --- /dev/null +++ b/croak/gui/theme.py @@ -0,0 +1,348 @@ +"""The window's look: light and calm, with white plots, in IBM Plex Sans. + +Every colour is a named token of :class:`Palette`, and the style sheet is built +from those tokens, so one change of a token restyles every widget that uses it. +Widgets take their style from their *role*, which is their ``objectName`` +(:func:`label`, :func:`button`, :func:`frame`), and from dynamic properties such +as ``current`` or ``warn`` that :func:`set_flag` changes and re-applies. Nothing +else in the GUI calls ``setStyleSheet``. + +The look follows the solprop front end's, so the two LupoLab applications read +alike. The fonts are IBM Plex Sans, carried in ``assets/fonts`` under the SIL +Open Font License (``assets/fonts/LICENSE.txt``). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton, QWidget + +__all__ = [ + "FONT_FAMILY", + "PALETTE", + "Palette", + "button", + "frame", + "label", + "load_fonts", + "repolish", + "set_flag", + "stylesheet", +] + +_log = logging.getLogger(__name__) + +ASSETS = Path(__file__).parent / "assets" + +NOTICE_MS = 20_000 +"""How long a passing notice stays in the status bar, ms.""" +"""Where the fonts and the drawn assets live.""" + +FONT_FAMILY = "IBM Plex Sans" +"""The window's font family.""" + +# The type scale, in logical pixels. Plot ticks take the application font's +# PLOT size; widgets take theirs from the style sheet. +BODY_PX = 13 +SMALL_PX = 12 +PLOT_PX = 11 +H1_PX = 28 +H2_PX = 15 +VALUE_PX = 19 + + +@dataclass(frozen=True) +class Palette: + """The window's colours, by what they are for.""" + + ink: str = "#1b2430" + """Text.""" + muted: str = "#5a6675" + """Secondary text: help, units, plot tick labels.""" + heading: str = "#3d4957" + """Small headings and unselected rail items.""" + disabled: str = "#a4adb8" + """Disabled text.""" + axis: str = "#b4bcc6" + """Plot axes.""" + accent: str = "#0b6e99" + """The primary action, focus and the selection.""" + accent_hover: str = "#095d82" + """The primary action under the pointer.""" + accent_off: str = "#b9c4cc" + """A disabled primary action.""" + selected_bg: str = "#e8f1f5" + """What is selected: its background.""" + selected_ink: str = "#0a5a7d" + """What is selected: its text; also links.""" + hover_bg: str = "#f1f3f5" + """A flat control under the pointer.""" + button_hover_bg: str = "#f6f7f8" + """A plain button under the pointer.""" + hairline: str = "#e3e6ea" + """Lines between the window's regions.""" + border: str = "#cdd3da" + """Inputs and plain buttons.""" + tile_border: str = "#dfe3e8" + """Home's tiles.""" + popup_border: str = "#d7dce2" + """Popups and dashed placeholders.""" + track: str = "#e8ebee" + """Slider grooves and scroll-bar handles.""" + text_selection: str = "#bcdbe8" + """Selected text in an input.""" + ok: str = "#1c7a4a" + """A ready line that is ready.""" + warn: str = "#8a5200" + """A ready line with a warning.""" + error: str = "#b3261e" + """A ready line that is not ready, and errors.""" + advice_border: str = "#e6cfa8" + """The amber advice box's edge.""" + advice_bg: str = "#fdf8ee" + """The amber advice box.""" + background: str = "#ffffff" + """Everything else.""" + + +PALETTE = Palette() +"""The palette the window uses.""" + + +def stylesheet(p: Palette = PALETTE) -> str: + """Return the window's style sheet in palette ``p``. + + Parameters + ---------- + p : Palette, optional + The colours. + + Returns + ------- + str + A Qt style sheet, addressing widgets by role (object name). + """ + chevron = (ASSETS / "chevron.svg").as_posix() + input_box = ( + f"border: 1px solid {p.border}; border-radius: 5px; padding: 3px 6px;" + f" background: {p.background}; color: {p.ink};" + f" selection-background-color: {p.text_selection}; selection-color: {p.ink};" + ) + focus = f"border: 2px solid {p.accent}; padding: 2px 5px;" + flat = "border: 0; border-radius: 6px; background: none;" + selected = f"background: {p.selected_bg}; color: {p.selected_ink};" + rules = [ + # the frame + f"* {{ font-size: {BODY_PX}px; }}", + f"QMainWindow, QWidget#page, QScrollArea, QStackedWidget," + f" QScrollArea > QWidget > QWidget {{ background: {p.background}; }}", + f"QWidget#page QLabel {{ color: {p.ink}; }}", + f"QFrame#top {{ border-bottom: 1px solid {p.hairline};" + f" background: {p.background}; }}", + f"QPushButton#brand {{ border: 0; background: none; padding: 4px;" + f" font-size: {H2_PX}px; font-weight: 600; color: {p.ink}; }}", + f"QLabel#context {{ color: {p.muted}; }}", + f"QFrame#divider {{ background: {p.hairline}; }}", + f"QPushButton#stage {{ {flat} padding: 6px 14px; color: {p.muted};" + f" font-weight: 500; }}", + f"QPushButton#stage:hover {{ background: {p.hover_bg}; }}", + f'QPushButton#stage[current="true"] {{ {selected} }}', + f"QPushButton#stage:disabled {{ color: {p.disabled}; background: none; }}", + f"QPushButton#primary {{ border: 0; border-radius: 6px; padding: 7px 14px;" + f" min-width: 96px; background: {p.accent}; color: #fff; font-weight: 500; }}", + f"QPushButton#primary:hover {{ background: {p.accent_hover}; }}", + f"QPushButton#primary:disabled {{ background: {p.accent_off}; }}", + f"QToolButton#overflow {{ {flat} padding: 5px 8px; font-size: 16px;" + f" color: {p.muted}; }}", + f"QToolButton#overflow:hover {{ background: {p.hover_bg}; }}", + "QToolButton#overflow::menu-indicator { image: none; }", + # headings and text + f"QLabel#h1 {{ font-size: {H1_PX}px; font-weight: 600; }}", + f"QLabel#h2 {{ font-size: {H2_PX}px; font-weight: 600; }}", + f"QLabel#h3 {{ font-weight: 600; color: {p.heading}; }}", + f"QLabel#help, QLabel#note, QLabel#pageNote {{ color: {p.muted};" + f" font-size: {SMALL_PX}px; }}", + f"QLabel#control, QLabel#plotTitle {{ color: {p.heading};" + f" font-size: {SMALL_PX}px; font-weight: 500; }}", + f"QLabel#rowLabel {{ color: {p.ink}; }}", + f"QLabel#rowLabel:disabled {{ color: {p.disabled}; }}", + # Home + f"QFrame#tile {{ border: 1px solid {p.tile_border}; border-radius: 10px;" + f" background: {p.background}; min-height: 78px; }}", + f"QFrame#tile:hover {{ border-color: {p.accent}; background: #fafcfd; }}", + "QLabel#tileTitle { font-weight: 600; background: none; }", + f"QLabel#tileText {{ color: {p.muted}; background: none; }}", + "QFrame#item { border-radius: 6px; background: none; }", + f"QFrame#item:hover {{ background: {p.hover_bg}; }}", + "QLabel#itemTitle { font-weight: 500; background: none; }", + f"QLabel#itemText {{ color: {p.muted}; font-size: {SMALL_PX}px;" + " background: none; }", + # buttons + f"QPushButton#plain, QToolButton#plain {{ border: 1px solid {p.border};" + f" border-radius: 6px; padding: 5px 12px; background: {p.background};" + f" color: {p.ink}; }}", + f"QPushButton#plain:hover, QToolButton#plain:hover" + f" {{ background: {p.button_hover_bg}; }}", + f"QPushButton#plain:disabled, QToolButton#plain:disabled" + f" {{ color: {p.disabled}; }}", + "QToolButton#plain::menu-indicator { image: none; }", + f"QPushButton#link {{ border: 0; padding: 0; background: none;" + f" color: {p.selected_ink}; text-decoration: underline;" + f" font-size: {SMALL_PX}px; }}", + f"QPushButton#link:disabled {{ color: {p.disabled}; }}", + # sheets + f"QFrame#form {{ border-right: 1px solid {p.hairline};" + f" background: {p.background}; }}", + f"QToolButton#sectionHeader {{ border: 0; background: none;" + f" padding: 6px 0 4px 0; font-weight: 600; color: {p.heading}; }}", + f"QToolButton#sectionHeader:disabled {{ color: {p.disabled}; }}", + f"QLineEdit, QComboBox, QAbstractSpinBox {{ {input_box} }}", + f"QLineEdit:focus, QComboBox:focus, QAbstractSpinBox:focus {{ {focus} }}", + f"QLineEdit:disabled, QComboBox:disabled, QAbstractSpinBox:disabled" + f" {{ color: {p.disabled}; border-color: {p.hairline}; }}", + "QComboBox::drop-down { border: 0; width: 20px; }", + f'QComboBox::down-arrow {{ image: url("{chevron}");' + " width: 12px; height: 12px; }", + f"QComboBox QAbstractItemView {{ border: 1px solid {p.popup_border};" + f" background: {p.background}; color: {p.ink}; outline: 0;" + f" selection-background-color: {p.selected_bg};" + f" selection-color: {p.selected_ink}; }}", + # Spin boxes step with the wheel and the arrow keys; the arrow buttons + # are hidden so a spin box is the same height and look as a combo. + "QAbstractSpinBox::up-button, QAbstractSpinBox::down-button" + " { width: 0; height: 0; border: 0; }", + "QLineEdit, QComboBox, QAbstractSpinBox { min-height: 18px; }", + # thin scroll bars that do not compete with the content + "QScrollBar:vertical { width: 10px; background: transparent; margin: 0; }", + "QScrollBar:horizontal { height: 10px; background: transparent; margin: 0; }", + f"QScrollBar::handle {{ background: {p.track}; border-radius: 4px;" + " min-height: 24px; min-width: 24px; margin: 2px; }", + f"QScrollBar::handle:hover {{ background: {p.border}; }}", + "QScrollBar::add-line, QScrollBar::sub-line { width: 0; height: 0; }", + "QScrollBar::add-page, QScrollBar::sub-page { background: none; }", + f"QCheckBox {{ color: {p.ink}; spacing: 6px; }}", + f"QCheckBox:disabled {{ color: {p.disabled}; }}", + f"QLabel#derivedLabel {{ color: {p.muted}; font-size: {SMALL_PX}px; }}", + f"QLabel#derivedValue {{ font-size: {VALUE_PX}px; font-weight: 500; }}", + f'QLabel#derivedValue[warn="true"] {{ color: {p.warn}; }}', + f'QLabel#derivedValue[error="true"] {{ color: {p.error}; }}', + f"QLabel#ready {{ color: {p.ok}; font-weight: 500; }}", + f'QLabel#ready[warn="true"] {{ color: {p.warn}; }}', + f'QLabel#ready[error="true"] {{ color: {p.error}; }}', + f"QFrame#advice {{ border: 1px solid {p.advice_border}; border-radius: 8px;" + f" background: {p.advice_bg}; }}", + f"QFrame#placeholder {{ border: 1px dashed {p.popup_border};" + f" border-radius: 8px; background: {p.background}; }}", + # sliders + f"QSlider::groove:horizontal {{ height: 6px; border-radius: 3px;" + f" background: {p.track}; }}", + f"QSlider::sub-page:horizontal {{ border-radius: 3px;" + f" background: {p.accent}; }}", + f"QSlider::handle:horizontal {{ width: 16px; height: 16px; margin: -5px 0;" + f" border-radius: 8px; background: {p.accent}; }}", + f"QSlider::sub-page:horizontal:disabled, QSlider::handle:horizontal:disabled" + f" {{ background: {p.accent_off}; }}", + # Analyse + f"QFrame#rail {{ border-right: 1px solid {p.hairline};" + f" background: {p.background}; }}", + f"QPushButton#railItem {{ {flat} text-align: left; padding: 7px 10px;" + f" color: {p.heading}; }}", + f"QPushButton#railItem:hover {{ background: {p.hover_bg}; }}", + f'QPushButton#railItem[current="true"] {{ {selected} font-weight: 500; }}', + f"QPushButton#railItem:disabled {{ color: {p.disabled}; background: none; }}", + f"QFrame#panel {{ border-left: 1px solid {p.hairline};" + f" background: {p.background}; }}", + f"QFrame#displayBar {{ border-top: 1px solid {p.hairline};" + f" background: {p.background}; }}", + f"QFrame#segments {{ border: 1px solid {p.border}; border-radius: 6px;" + f" background: {p.background}; }}", + f"QPushButton#segment {{ border: 0; border-radius: 5px; padding: 4px 8px;" + f" background: none; color: {p.ink}; font-size: {SMALL_PX}px; }}", + f'QPushButton#segment[current="true"] {{ {selected} font-weight: 500; }}', + f"QPushButton#segment:disabled {{ color: {p.disabled}; }}", + f"QListWidget {{ border: 1px solid {p.border}; border-radius: 5px;" + f" background: {p.background}; color: {p.ink}; outline: 0; }}", + f"QListWidget::item:selected {{ {selected} }}", + # the drawer + f"QFrame#drawer {{ border-left: 1px solid {p.hairline};" + f" background: {p.background}; }}", + f"QPlainTextEdit#drawerText {{ border: 0; background: {p.background};" + f' color: {p.ink}; font-family: "Menlo", "Consolas", monospace;' + f" font-size: {SMALL_PX}px; }}", + ] + return "\n".join(rules) + + +def load_fonts() -> bool: + """Make IBM Plex Sans the application's font, the plots' ticks included. + + A missing or unreadable font file is a cosmetic failure: the window keeps + the system font and is logged, not stopped. + + Returns + ------- + bool + Whether every weight loaded. + """ + paths = sorted((ASSETS / "fonts").glob("*.ttf")) + ids = [QFontDatabase.addApplicationFont(str(path)) for path in paths] + if not ids or -1 in ids: + _log.warning("IBM Plex Sans did not load; the system font is used instead") + return False + font = QFont(FONT_FAMILY) + font.setPixelSize(PLOT_PX + 1) + QApplication.setFont(font) + return True + + +def repolish(widget: QWidget) -> None: + """Re-apply the style sheet to ``widget`` after one of its properties changed.""" + style = widget.style() + style.unpolish(widget) + style.polish(widget) + widget.update() + + +def set_flag(widget: QWidget, name: str, on: bool) -> None: + """Set the boolean style property ``name`` of ``widget`` and restyle it. + + Parameters + ---------- + widget : QWidget + The widget. + name : str + The property the style sheet tests, e.g. ``"current"``, ``"warn"``. + on : bool + Its value. + """ + if widget.property(name) == on: + return + widget.setProperty(name, on) + repolish(widget) + + +def label(text: str, role: str = "", *, wrap: bool = False) -> QLabel: + """Return a label styled as ``role`` (``"h1"``, ``"help"``, …).""" + widget = QLabel(text) + if role: + widget.setObjectName(role) + widget.setWordWrap(wrap) + return widget + + +def button(text: str, role: str = "plain") -> QPushButton: + """Return a push button styled as ``role`` (``"primary"``, ``"plain"``, …).""" + widget = QPushButton(text) + widget.setObjectName(role) + return widget + + +def frame(role: str) -> QFrame: + """Return an empty frame styled as ``role`` (``"form"``, ``"rail"``, …).""" + widget = QFrame() + widget.setObjectName(role) + return widget diff --git a/croak/gui/view_panel.py b/croak/gui/view_panel.py new file mode 100644 index 0000000..eab54ed --- /dev/null +++ b/croak/gui/view_panel.py @@ -0,0 +1,319 @@ +"""A titled plot of one screen view, which can leave the window as a figure. + +:class:`ViewPanel` shows a view of :mod:`croak.plotting.screen` on a pyqtgraph +plot (:func:`draw_line_view`, :func:`draw_map_view`), on the spectral axis and +logarithmic range of the display settings (:mod:`croak.gui.display`), and +redraws it when they change. A right-click offers the +plot as a publication figure, drawn by matplotlib from the same view +(:mod:`croak.plotting.export`), or as an image of the screen. +:func:`page_figure` lays out every plot a page shows as one figure, and +:func:`export_figure` asks where to save one. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import cast + +import numpy as np +from matplotlib.figure import Figure +from PySide6.QtCore import QPoint, Qt +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QHBoxLayout, + QMainWindow, + QMenu, + QVBoxLayout, + QWidget, +) + +from ..plotting import screen as sc +from ..plotting.display import displayed +from ..plotting.export import save_figure, views_figure +from ..plotting.screen import ROLE_STYLE +from .display import current, hub +from .plots import PEN_WIDTH, Curve, LinePlot, MapPlot, curve_reader, map_reader +from .theme import NOTICE_MS, label, set_flag + +__all__ = [ + "ViewPanel", + "draw_line_view", + "draw_map_view", + "export_figure", + "file_name", + "page_figure", +] + +FIGURE_FILTERS = "PDF (*.pdf);;SVG (*.svg);;PNG (*.png)" +"""The figure formats offered: two vector ones first, then a bitmap.""" + +REFERENCE_WIDTH = 0.9 +"""The width of a reference curve (the known pulse), logical px.""" + + +def draw_line_view(plot: LinePlot, view: sc.LineView) -> None: + """Show ``view`` on ``plot``: curves, labels, ranges and marks.""" + curves = [] + for c in view.curves: + colour, dashed, under = ROLE_STYLE[c.role] + width = REFERENCE_WIDTH if under else PEN_WIDTH + curves.append( + Curve(c.x, c.y, c.name, colour, dashed, c.right, width, under, c.points) + ) + plot.set_labels(view.xlabel, view.ylabel) + if view.right_label is not None: + plot.set_right_label(view.right_label) + plot.set_curves(curves, log_y=view.log_y) + plot.clear_extras() + for span in view.spans: + colour = ROLE_STYLE[span.role][0] + plot.add_span(span.low, span.high, colour, span.opacity, vertical=span.vertical) + for band in view.bands: + plot.add_band( + band.x, band.low, band.high, ROLE_STYLE[band.role][0], band.opacity + ) + for y, role in view.levels: + plot.add_level(y, ROLE_STYLE[role][0]) + plot.fit() + if view.bars is not None: + bars = view.bars + plot.add_bars(bars.edges, bars.counts, ROLE_STYLE[bars.role][0]) + plot.set_view( + (float(bars.edges[0]), float(bars.edges[-1])), + (0.0, 1.05 * float(np.max(bars.counts)) or 1.0), + ) + if view.xrange is not None or view.yrange is not None: + (x0, x1), (y0, y1) = plot.box.viewRange() + xr = view.xrange or (x0, x1) + yr = view.yrange or (y0, y1) + if view.log_y and view.yrange is not None: # a log axis works in decades + yr = (float(np.log10(yr[0])), float(np.log10(yr[1]))) + plot.set_view(xr, yr) + if view.right_range is not None: + plot.set_right_range(*view.right_range) + plot.set_marks([(x, ROLE_STYLE[role][0]) for x, role in view.marks]) + + +def draw_map_view(plot: MapPlot, view: sc.MapView) -> None: + """Show ``view`` on ``plot``, then narrow it to the view's window.""" + plot.set_labels(view.xlabel, view.ylabel) + plot.show_map(view.values, view.x, view.y, view.levels) + (x0, x1), (y0, y1) = plot.box.viewRange() + plot.set_view(view.xrange or (x0, x1), view.yrange or (y0, y1)) + + +def _name(axis_label: str) -> str: + """Return an axis label's quantity for the readout: "Delay (fs)" → "Delay".""" + return axis_label.split(" (")[0] + + +class ViewPanel(QWidget): + """A titled plot that shows a screen view (:mod:`croak.plotting.screen`). + + Parameters + ---------- + kind : {"map", "line"} + The plot it holds. + colours : str + A map's colour map. + signed, white : bool + A map's colour scale (see :class:`~croak.gui.plots.MapPlot`). + right : bool + A line plot with a right-hand axis. + + Attributes + ---------- + view : MapView or LineView or None + What the plot shows now, as the display settings show it; ``None`` + before anything is drawn. + """ + + def __init__( + self, + kind: str, + *, + colours: str = "viridis", + signed: bool = False, + white: bool = True, + right: bool = False, + ) -> None: + super().__init__() + self.colours = colours + self.white = white + self.view: sc.MapView | sc.LineView | None = None + self._source: tuple[sc.MapView | sc.LineView, str] | None = None + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + head = QHBoxLayout() + self.title = label("", "plotTitle") + self.notes = label("", "pageNote") + self.notes.setAlignment(Qt.AlignmentFlag.AlignRight) + head.addWidget(self.title) + head.addStretch(1) + head.addWidget(self.notes) + layout.addLayout(head) + self.plot: MapPlot | LinePlot + if kind == "map": + self.plot = MapPlot(colours=colours, signed=signed, white=white) + else: + self.plot = LinePlot(right_label="" if right else None) + layout.addWidget(self.plot, stretch=1) + self.plot.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.plot.customContextMenuRequested.connect(self.show_menu) + hub().changed.connect(self.redisplay) + + def show_view(self, view: sc.MapView | sc.LineView, notes: str = "") -> None: + """Draw ``view``; ``notes`` (or a line view's own) go beside the title.""" + self._source = (view, notes) + view = displayed(view, current()) + self.view = view + self.title.setText(view.title) + if isinstance(view, sc.MapView): + plot = cast(MapPlot, self.plot) + draw_map_view(plot, view) + plot.reader = map_reader(plot, _name(view.xlabel), _name(view.ylabel)) + self.notes.setText(notes) + set_flag(self.notes, "warn", False) + return + line = cast(LinePlot, self.plot) + draw_line_view(line, view) + line.reader = curve_reader(line, _name(view.xlabel)) + self.notes.setText(notes or " ".join(text for text, _ in view.notes)) + set_flag(self.notes, "warn", any(warn for _, warn in view.notes)) + + def redisplay(self) -> None: + """Draw the shown view again, with the display settings now in force.""" + if self._source is not None: + self.show_view(*self._source) + + def say(self, title: str, text: str) -> None: + """Show ``text`` in place of a map (there is nothing to draw).""" + self.view = None + self._source = None + self.title.setText(title) + cast(MapPlot, self.plot).say(text) + self.notes.setText("") + + # -- leaving the window --------------------------------------------------- + def figure(self) -> Figure | None: + """This plot as a publication figure; ``None`` while it shows nothing.""" + if self.view is None: + return None + return views_figure( + [[self.view]], colours=[[self.colours]], white=[[self.white]] + ) + + def show_menu(self, where: QPoint) -> QMenu: + """Offer the plot as a figure or an image, at ``where`` on the plot.""" + menu = QMenu(self) + export = menu.addAction("Export this plot as a figure…", self.export_figure) + export.setEnabled(self.view is not None) + menu.addSeparator() + menu.addAction("Save as an image…", self.save_image) + menu.addAction("Copy as an image", self.copy_image) + menu.popup(self.plot.mapToGlobal(where)) + return menu + + def export_figure(self) -> str: + """Ask where to save this plot as a figure; return the path, or ``""``.""" + name = file_name(self.title.text()) + return export_figure(self, self.figure(), name, self._say) + + def _say(self, text: str) -> None: + """Show ``text`` in the status bar of the window, if it has one.""" + window = self.window() + if isinstance(window, QMainWindow): + window.statusBar().showMessage(text, NOTICE_MS) + + def save_image(self) -> str: + """Ask where to save the plot as drawn on screen (PNG); return the path.""" + path, _ = QFileDialog.getSaveFileName( + self, + "Save the plot as an image", + f"{file_name(self.title.text())}.png", + "PNG (*.png)", + ) + if path: + self.plot.grab().save(path) + return path + + def copy_image(self) -> None: + """Put the plot, as drawn on screen, on the clipboard.""" + QApplication.clipboard().setPixmap(self.plot.grab()) + + +def file_name(title: str) -> str: + """Return a file name for a title, or ``"figure"`` for none. + + Examples + -------- + >>> file_name("Measured trace (log)") + 'measured_trace_log' + """ + words = "".join(c if c.isalnum() else " " for c in title.lower()).split() + return "_".join(words) or "figure" + + +def export_figure( + parent: QWidget, figure: Figure | None, name: str, say: Callable[[str], None] +) -> str: + """Ask where to save ``figure`` (PDF, SVG or PNG) and save it there. + + Parameters + ---------- + parent : QWidget + The dialog's parent. + figure : matplotlib.figure.Figure or None + The figure; ``None`` when there is nothing to save, which is said. + name : str + The suggested file name, without its extension. + say : callable + Shows a short notice (what was saved, or why nothing was). + + Returns + ------- + str + The path saved to, or ``""`` if nothing was saved. + """ + if figure is None: + say("There is nothing to save as a figure yet.") + return "" + path, _ = QFileDialog.getSaveFileName( + parent, "Save as a figure", f"{name}.pdf", FIGURE_FILTERS + ) + if not path: + return "" + save_figure(figure, path) + say(f"Saved {Path(path).name}") + return path + + +def page_figure(page: QWidget) -> Figure | None: + """Every plot ``page`` shows, as one figure laid out as on the screen. + + The plots are taken in rows by their top edge, left to right; a plot that + is hidden or shows nothing is left out. ``None`` if no plot shows anything. + """ + panels = [ + panel + for panel in page.findChildren(ViewPanel) + if panel.view is not None and panel.isVisibleTo(page) + ] + if not panels: + return None + rows: dict[int, list[ViewPanel]] = {} + for panel in panels: + corner = panel.mapTo(page, QPoint(0, 0)) + rows.setdefault(corner.y(), []).append(panel) + ordered = [ + sorted(row, key=lambda panel: panel.mapTo(page, QPoint(0, 0)).x()) + for _, row in sorted(rows.items()) + ] + return views_figure( + [[cast(sc.MapView | sc.LineView, p.view) for p in row] for row in ordered], + colours=[[p.colours for p in row] for row in ordered], + white=[[p.white for p in row] for row in ordered], + ) diff --git a/croak/gui/widgets.py b/croak/gui/widgets.py index 14803bf..7f73dc2 100644 --- a/croak/gui/widgets.py +++ b/croak/gui/widgets.py @@ -1,6 +1,8 @@ """Reusable composite widgets: a range control and a single-value control. -Both pair a horizontal slider with spin box(es). Programmatic updates block the +Both pair a horizontal slider with spin box(es) sized to the numbers they show +(:class:`CompactSpinBox`), so the slider has the rest of the row. Programmatic +updates block the child widgets' signals so seeding values never feeds back into the model; the slider updates continuously while dragging, and the spin boxes commit on Enter / focus-out. @@ -10,19 +12,65 @@ import math -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QValidator -from PyQt6.QtWidgets import ( +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QValidator +from PySide6.QtWidgets import ( QDoubleSpinBox, QHBoxLayout, QSizePolicy, QSlider, - QVBoxLayout, QWidget, ) from superqt import QRangeSlider -__all__ = ["RangeControl", "ValueControl", "SciSpinBox"] +__all__ = ["CompactSpinBox", "RangeControl", "SciSpinBox", "ValueControl"] + +SPIN_MIN_CHARS = 4 +"""The fewest characters a slider's spin box makes room for.""" +SPIN_MAX_CHARS = 7 +"""The most: a longer number (an extreme of a wide range) scrolls in its box.""" +SPIN_PADDING = 22 +"""A spin box's frame, padding and text margins, logical px (see the theme).""" + + +def spin_chars(low: float, high: float, decimals: int) -> int: + """Characters needed to show any value in ``[low, high]`` with ``decimals``. + + Clamped to :data:`SPIN_MIN_CHARS` … :data:`SPIN_MAX_CHARS`. + + Examples + -------- + >>> spin_chars(-60.0, 60.0, 0), spin_chars(-20000.0, 20000.0, 2) + (4, 7) + """ + widest = max(len(f"{value:.{decimals}f}") for value in (low, high)) + return min(max(widest, SPIN_MIN_CHARS), SPIN_MAX_CHARS) + + +class CompactSpinBox(QDoubleSpinBox): + """A spin box as wide as the numbers its range holds, and no wider. + + Qt sizes a spin box to the text of its range's ends, so a wide range (TOD + to ±10⁵ fs³) takes most of a row from the slider beside it. This one makes + room for :func:`spin_chars` digits in its own font instead. + """ + + def __init__(self) -> None: + super().__init__() + self.chars = SPIN_MIN_CHARS + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + def fit(self) -> None: + """Size to the current range and decimals.""" + self.chars = spin_chars(self.minimum(), self.maximum(), self.decimals()) + self.updateGeometry() + + def sizeHint(self) -> QSize: # noqa: N802 — Qt override + width = self.fontMetrics().horizontalAdvance("0" * self.chars) + return QSize(width + SPIN_PADDING, super().sizeHint().height()) + + def minimumSizeHint(self) -> QSize: # noqa: N802 — Qt override + return self.sizeHint() class SciSpinBox(QDoubleSpinBox): @@ -100,13 +148,13 @@ def _unblock(*widgets): class RangeControl(QWidget): - """A min/max range control: two spin boxes above a double-handled slider. + """A min/max range control: a double-handled slider between two spin boxes. ``changed(lo, hi)`` fires when the user drags the slider or commits a spin box. Programmatic :meth:`set_bounds`/:meth:`set_values` are signal-safe. """ - changed = pyqtSignal(float, float) + changed = Signal(float, float) def __init__( self, @@ -125,8 +173,8 @@ def __init__( # rather than collapsing to the spins' preferred width. self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.lo_spin = QDoubleSpinBox() - self.hi_spin = QDoubleSpinBox() + self.lo_spin = CompactSpinBox() + self.hi_spin = CompactSpinBox() for sp in (self.lo_spin, self.hi_spin): sp.setDecimals(decimals) sp.setSingleStep(step) @@ -146,20 +194,19 @@ def __init__( self.hi_spin.valueChanged.connect(self._spins_changed) self.slider.valueChanged.connect(self._slider_changed) - layout = QVBoxLayout(self) + # One row, the ends at the ends: low value, slider, high value. + layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - row = QWidget() - rl = QHBoxLayout(row) - rl.setContentsMargins(0, 0, 0, 0) - rl.addWidget(self.lo_spin) - rl.addWidget(self.hi_spin) - layout.addWidget(row) - layout.addWidget(self.slider) + layout.addWidget(self.lo_spin) + layout.addWidget(self.slider, stretch=1) + layout.addWidget(self.hi_spin) def set_bounds(self, lo: float, hi: float) -> None: _block(self.lo_spin, self.hi_spin, self.slider) self.lo_spin.setRange(lo, hi) self.hi_spin.setRange(lo, hi) + self.lo_spin.fit() + self.hi_spin.fit() self.slider.setRange(int(round(lo * self._scale)), int(round(hi * self._scale))) _unblock(self.lo_spin, self.hi_spin, self.slider) @@ -201,7 +248,7 @@ class ValueControl(QWidget): ``changed(value)`` fires on slider drag or spin commit. """ - changed = pyqtSignal(float) + changed = Signal(float) def __init__( self, @@ -219,7 +266,7 @@ def __init__( # and easy to nudge, rather than collapsing to the spin's preferred width. self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.spin = QDoubleSpinBox() + self.spin = CompactSpinBox() self.spin.setDecimals(decimals) self.spin.setSingleStep(step) self.spin.setKeyboardTracking(False) @@ -240,6 +287,7 @@ def __init__( def set_bounds(self, lo: float, hi: float) -> None: _block(self.spin, self.slider) self.spin.setRange(lo, hi) + self.spin.fit() self.slider.setRange(int(round(lo * self._scale)), int(round(hi * self._scale))) _unblock(self.spin, self.slider) diff --git a/croak/gui/window.py b/croak/gui/window.py new file mode 100644 index 0000000..ac913b3 --- /dev/null +++ b/croak/gui/window.py @@ -0,0 +1,627 @@ +"""The workflow window: Home, then Data, Check, Prepare, Retrieve and Analyse. + +The top bar carries the brand (back to Home), what the session is, the five +stage buttons, the primary action (with Retrieve again beside it once a +retrieval is done) and an overflow menu (⋯). Which stages open and what the +actions do are the pure rules of +:mod:`croak.gui.workflow`; this module applies them to widgets. A drawer on the +right shows the session's settings as ``options.toml`` or as a Python script. + +The pages are built by a factory from the shared :class:`WizardState`, and rebuilt +for each new session, since their controls bind to the state's parameter objects. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QCloseEvent, QIcon, QKeySequence +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QFrame, + QHBoxLayout, + QMainWindow, + QMenu, + QMessageBox, + QPlainTextEdit, + QPushButton, + QStackedWidget, + QToolButton, + QVBoxLayout, + QWidget, +) + +from .. import save +from ..scripting import generate_script +from ..session import files as session_files +from ..session import retarget +from . import settings +from .about import About +from .branding import app_icon +from .display import DisplayBar +from .home import ENTRY_TILES, Home +from .stage import PageBase +from .state import WizardState +from .theme import ( + ASSETS, + NOTICE_MS, + button, + frame, + label, + load_fonts, + set_flag, + stylesheet, +) +from .workflow import ( + STAGES, + Action, + Progress, + Stage, + blocked_reason, + next_stage, + primary_action, + secondary_action, +) + +if TYPE_CHECKING: + from .pages import DataPage + from .stage_preprocess import StagePreprocess + from .stage_retrieve import StageRetrieve + +__all__ = ["DRAWER_WIDTH", "MainWindow", "PageFactory"] + +PageFactory = Callable[[WizardState], Mapping[Stage, PageBase]] +"""Builds a session's stage pages from the shared state.""" + +DRAWER_WIDTH = 440 +"""The width of the settings drawer, logical px.""" +TOP_BAR_HEIGHT = 52 +"""The height of the top bar, logical px.""" + +_ENTRY_TITLES = {entry: title for entry, title, _ in ENTRY_TILES} + + +class MainWindow(QMainWindow): + """The croak window. + + Parameters + ---------- + make_pages : PageFactory, optional + Builds the Data, Check, Prepare, Retrieve and Analyse pages for a session + (:func:`croak.gui.pages.default_pages` by default). + state : WizardState, optional + The shared session state (a fresh one by default). + """ + + def __init__( + self, make_pages: PageFactory | None = None, state: WizardState | None = None + ): + super().__init__() + load_fonts() + self.setWindowTitle("croak") + self.setWindowIcon(app_icon()) + settings.restore_window(self) + self.state = state or WizardState() + if make_pages is None: + from .pages import default_pages + + make_pages = default_pages + self.make_pages = make_pages + self.pages: dict[Stage, PageBase] = {} + self.stage = Stage.HOME + self.session_path = "" + + central = QWidget() + central.setObjectName("page") + self.setCentralWidget(central) + column = QVBoxLayout(central) + column.setContentsMargins(0, 0, 0, 0) + column.setSpacing(0) + column.addWidget(self._make_top()) + middle = QHBoxLayout() + middle.setContentsMargins(0, 0, 0, 0) + middle.setSpacing(0) + self.stack = QStackedWidget() + self.home = Home() + self.home.start.connect(self.new_session) + self.home.open_session.connect(self.open_session) + self.home.about.connect(self.show_about) + self.stack.addWidget(self.home) + self.about = About() + self.stack.addWidget(self.about) + middle.addWidget(self.stack, stretch=1) + middle.addWidget(self._make_drawer()) + column.addLayout(middle, stretch=1) + self._make_menus() + # The foot of the window: notices on the left and, in a session, the + # display settings every plot follows on the right. + status = self.statusBar() + self.display_bar = DisplayBar() + status.addPermanentWidget(self.display_bar) + status.hide() + status.messageChanged.connect(lambda _text: self._show_status()) + self.setStyleSheet(stylesheet()) + self.state.retarget_requested.connect(self.retarget) + for signal in ( + self.state.load_data_changed, + self.state.tracedata_changed, + self.state.result_changed, + self.state.uncertainty_changed, + ): + signal.connect(self.refresh) + self.show_home() + + # -- building ------------------------------------------------------------- + def _make_top(self) -> QFrame: + top = frame("top") + top.setFixedHeight(TOP_BAR_HEIGHT) + bar = QHBoxLayout(top) + bar.setContentsMargins(16, 0, 16, 0) + bar.setSpacing(12) + self.brand = button("croak", "brand") + self.brand.setToolTip("Home") + self.brand.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.brand.clicked.connect(self.go_home) + self.context = label("", "context") + bar.addWidget(self.brand) + bar.addWidget(self.context) + bar.addStretch(1) + # Home leads the stage row, where navigation is looked for; the brand + # also goes Home, but nobody guesses that a name is a button. + self.home_button = button("Home", "stage") + self.home_button.setIcon(QIcon(str(ASSETS / "home.svg"))) + self.home_button.setToolTip("Home: start or reopen a session") + self.home_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.home_button.clicked.connect(self.go_home) + bar.addWidget(self.home_button) + divider = frame("divider") + divider.setFixedSize(1, 20) + bar.addWidget(divider) + self.stage_buttons: dict[Stage, QPushButton] = {} + for stage in STAGES: + b = button(stage.title, "stage") + b.setFocusPolicy(Qt.FocusPolicy.NoFocus) + b.clicked.connect(lambda _=False, stage=stage: self.go(stage)) + bar.addWidget(b) + self.stage_buttons[stage] = b + bar.addStretch(1) + # Beside the primary action, the one step that is not the way on + # (Retrieve again, once a retrieval is done). + self.secondary = button("") + self.secondary.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.secondary.clicked.connect(self.do_secondary) + bar.addWidget(self.secondary) + self.primary = button("", "primary") + self.primary.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.primary.clicked.connect(self.do_primary) + bar.addWidget(self.primary) + self.overflow = QToolButton() + self.overflow.setObjectName("overflow") + self.overflow.setText("⋯") + self.overflow.setToolTip("More") + self.overflow.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.overflow.setFocusPolicy(Qt.FocusPolicy.NoFocus) + bar.addWidget(self.overflow) + return top + + def _make_drawer(self) -> QFrame: + self.drawer = frame("drawer") + self.drawer.setFixedWidth(DRAWER_WIDTH) + layout = QVBoxLayout(self.drawer) + layout.setContentsMargins(16, 12, 16, 12) + head = QHBoxLayout() + self.drawer_title = label("", "h3") + copy = button("Copy") + copy.clicked.connect(self.copy_drawer) + close = button("Close") + close.clicked.connect(self.drawer.hide) + head.addWidget(self.drawer_title, stretch=1) + head.addWidget(copy) + head.addWidget(close) + layout.addLayout(head) + self.drawer_text = QPlainTextEdit() + self.drawer_text.setObjectName("drawerText") + self.drawer_text.setReadOnly(True) + layout.addWidget(self.drawer_text, stretch=1) + self.drawer.hide() + return self.drawer + + def _action( + self, + text: str, + slot: Callable[[], object], + shortcut: QKeySequence | None = None, + ) -> QAction: + action = QAction(text, self) + if shortcut is not None: + action.setShortcut(shortcut) + action.triggered.connect(lambda _=False: slot()) + return action + + def _make_menus(self) -> None: + keys = QKeySequence.StandardKey + self.actions_: dict[str, QAction] = { + "home": self._action("Home", self.go_home), + "open": self._action( + "Open a session…", + lambda: self.open_session(""), + QKeySequence(keys.Open), + ), + "save": self._action( + "Save session…", self.save_session, QKeySequence(keys.Save) + ), + "options": self._action( + "Show the settings (options.toml)", self.show_options + ), + "python": self._action("Show as a Python script", self.show_python), + "figure": self._action( + "Export this page as a figure…", + self.export_figure, + QKeySequence("Ctrl+E"), + ), + "help": self._action( + "Help for this stage", self.show_help, QKeySequence(keys.HelpContents) + ), + "about": self._action("About croak", self.show_about), + } + # On macOS, Qt moves it to the application menu, where About belongs. + self.actions_["about"].setMenuRole(QAction.MenuRole.AboutRole) + overflow = QMenu(self) + for key in ("save", "open", "figure"): + overflow.addAction(self.actions_[key]) + overflow.addSeparator() + for key in ("options", "python"): + overflow.addAction(self.actions_[key]) + overflow.addSeparator() + overflow.addAction(self.actions_["help"]) + overflow.addAction(self.actions_["about"]) + self.overflow.setMenu(overflow) + bar = self.menuBar() + file_menu = bar.addMenu("&File") + for key in ("home", "open", "save", "figure"): + file_menu.addAction(self.actions_[key]) + view_menu = bar.addMenu("&View") + for key in ("options", "python"): + view_menu.addAction(self.actions_[key]) + help_menu = bar.addMenu("&Help") + for key in ("help", "about"): + help_menu.addAction(self.actions_[key]) + + def _build_pages(self) -> None: + """Replace the stage pages with fresh ones bound to the state's parameters.""" + for page in self.pages.values(): + self.stack.removeWidget(page) + page.deleteLater() + self.pages = dict(self.make_pages(self.state)) + missing = set(STAGES) - set(self.pages) + if missing: + names = ", ".join(sorted(s.value for s in missing)) + raise ValueError(f"the page factory built no page for {names}") + for stage, page in self.pages.items(): + self.stack.addWidget(page) + page.changed.connect(self.refresh) + page.message.connect(self.notify) + page.moved_on.connect(lambda stage=stage: self.move_on(stage)) + + # -- the route ------------------------------------------------------------ + def progress(self) -> Progress: + """What the session holds, for the route rules.""" + if not self.pages: + return Progress() + state = self.state + data = self.pages[Stage.DATA] + return Progress( + entry=state.entry, + data_ready=data.can_advance(), + has_load_data=state.load_data is not None, + has_tracedata=state.tracedata is not None, + has_result=state.result is not None, + raw_direct=state.entry == "simulated" and state.simulated.raw_direct, + retrieving=self.pages[Stage.RETRIEVE].busy(), + estimating=self.pages[Stage.ANALYSE].busy(), + ) + + def current_action(self) -> Action: + """The primary action the top bar offers now.""" + page = self.pages.get(self.stage) + key = page.page_key() if page is not None else "" + return primary_action(self.stage, self.progress(), key) + + def refresh(self) -> None: + """Re-apply the route rules to the top bar.""" + progress = self.progress() + in_session = bool(self.pages) + for stage, b in self.stage_buttons.items(): + reason = blocked_reason(stage, progress) if self.pages else "no session" + b.setVisible(in_session) + b.setEnabled(not reason) + b.setToolTip(reason) + set_flag(b, "current", stage is self.stage) + set_flag(self.home_button, "current", self.stage is Stage.HOME) + action = self.current_action() + self.primary.setVisible(bool(action.kind)) + self.primary.setText(action.text) + self.primary.setEnabled(action.enabled) + second = secondary_action(self.stage, progress) + self.secondary.setVisible(bool(second.kind)) + self.secondary.setText(second.text) + self.secondary.setEnabled(second.enabled) + entry = _ENTRY_TITLES.get(progress.entry, "") + name = Path(self.session_path).parent.name if self.session_path else "" + self.context.setText(" · ".join(p for p in (entry, name) if p)) + self.actions_["save"].setEnabled(progress.has_result) + for key in ("options", "python"): + self.actions_[key].setEnabled(bool(progress.entry)) + self.actions_["figure"].setEnabled(self.stage in self.pages) + self.display_bar.setVisible(in_session) + self._show_status() + + def _show_status(self) -> None: + """Show the foot of the window while it has a notice or a session.""" + status = self.statusBar() + status.setVisible(bool(self.pages) or bool(status.currentMessage())) + + def go(self, stage: Stage) -> bool: + """Show ``stage`` if it can be opened; say why not otherwise.""" + outside = stage in (Stage.HOME, Stage.ABOUT) + if outside or not self.pages: + reason = "" if outside else "no session" + else: + reason = blocked_reason(stage, self.progress()) + if reason: + self.notify(reason) + return False + self.stage = stage + if stage is Stage.HOME: + self.home.show_recent(settings.recent_sessions()) + self.stack.setCurrentWidget(self.home) + elif stage is Stage.ABOUT: + self.stack.setCurrentWidget(self.about) + else: + page = self.pages[stage] + self.stack.setCurrentWidget(page) + page.on_enter() + self.refresh() + return True + + def show_home(self) -> None: + """Show Home, keeping any open session.""" + self.go(Stage.HOME) + + def go_home(self) -> None: + """Show Home (the session stays open until a new one starts).""" + self.show_home() + + def export_figure(self) -> str: + """Save the page on screen as a publication figure; return the path.""" + page = self.pages.get(self.stage) + return page.export_figure() if page is not None else "" + + def show_about(self) -> None: + """Show About, keeping any open session.""" + self.go(Stage.ABOUT) + + def do_primary(self) -> None: + """Do what the primary action says.""" + action = self.current_action() + if not action.enabled: + return + if action.kind == "save": + self.save_session() + return + page = self.pages[self.stage] + if page.act(action.kind) and action.kind == "continue": + self.go(next_stage(self.stage, self.progress())) + self.refresh() + + def do_secondary(self) -> None: + """Do what the action beside the primary one says.""" + action = secondary_action(self.stage, self.progress()) + if not action.enabled: + return + self.pages[self.stage].act(action.kind) + self.refresh() + + def move_on(self, stage: Stage) -> None: + """Go to the stage after ``stage``, whose page handed its work on.""" + self.go(next_stage(stage, self.progress())) + + # -- sessions --------------------------------------------------------------- + def confirm_discard(self) -> bool: + """Ask before a new session replaces one holding data; True to go ahead.""" + if not self.state.has_data: + return True + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Start a new session?") + box.setText("The current session's trace and retrieval will be discarded.") + box.setInformativeText("Save session… in the ⋯ menu keeps them.") + discard = box.addButton("Discard", QMessageBox.ButtonRole.DestructiveRole) + box.addButton(QMessageBox.StandardButton.Cancel) + box.exec() + return box.clickedButton() is discard + + def new_session(self, entry: str) -> bool: + """Start a session from ``entry`` and open its Data stage.""" + if not self.confirm_discard(): + return False + self.state.reset() + self.state.entry = entry + self.session_path = "" + self._build_pages() + self.go(Stage.DATA) + return True + + def open_session(self, path: str) -> bool: + """Reopen the session saved at ``path`` (``""`` asks for the file).""" + if not path: + path, _ = QFileDialog.getOpenFileName( + self, "Open a session", filter="Session settings (options.toml *.toml)" + ) + if not path: + return False + if not self.confirm_discard(): + return False + try: + options = save.load_options(path) + except (OSError, ValueError) as exc: + self.notify(f"Could not open {path}: {exc}") + return False + self.state.reset() + self.state.from_options(options) + self.session_path = path + settings.remember_session(path) + self._build_pages() + self.go(Stage.DATA) + result = Path(path).with_name(session_files.RESULT_FILE) + self.replay(str(result) if result.is_file() else "") + return True + + def replay(self, result_path: str = "") -> None: + """Bring a reopened session back: its trace, preparation and retrieval. + + The trace is reloaded with the saved options and prepared with the saved + settings. The retrieval is then taken from ``result_path`` (a sibling + ``result.h5``) when it was fitted to exactly this trace + (:func:`croak.session.files.result_mismatch`), and re-run otherwise. A + synthetic session cannot be replayed: its generator's settings are not + part of the saved options. + """ + state = self.state + data = cast("DataPage", self.pages[Stage.DATA]) + if state.entry == "synthetic": + data.entry_page.set_status( + "Options loaded, but the synthetic generator's settings are not " + "saved with a session — regenerate the trace to replay the rest.", + "warn", + ) + return + if not data.restore(): + return + prepare = cast("StagePreprocess", self.pages[Stage.PREPARE]) + if state.tracedata is None: + prepare._seed_all() + prepare.apply(block=True) + if state.tracedata is None: + self.go(Stage.PREPARE) + prepare.set_status( + "Options loaded and data restored; preprocess did not complete.", + "warn", + ) + return + retrieve = cast("StageRetrieve", self.pages[Stage.RETRIEVE]) + # Adopt the saved result before Retrieve is shown: entering it draws + # (and processes) whatever result the session holds. + note = self._rehydrate(result_path) if result_path else "no saved result" + self.go(Stage.RETRIEVE) + if not note: + retrieve.set_status( + "Options and saved retrieval loaded — no re-run needed." + ) + return + if result_path: + retrieve.set_status(f"Options loaded; re-running because {note}.") + retrieve._run() + + def _rehydrate(self, result_path: str) -> str: + """Adopt the saved result at ``result_path``; return why not, or ``""``.""" + tracedata = self.state.tracedata + try: + saved = save.load_result(result_path) + result = save.result_from_saved(saved, grid=tracedata.grid) # pyright: ignore[reportOptionalMemberAccess] + except (KeyError, ValueError, OSError) as exc: + return f"the saved result could not be read ({exc})" + reason = session_files.result_mismatch( + saved, + tracedata, # pyright: ignore[reportArgumentType] + self.state.retrieve, + ) + if not reason: + self.state.set_result(result) + return reason + + def retarget(self, folder: str) -> None: + """Point the session at the dataset in ``folder`` and replay it. + + The dataset-folder paths are rewritten (the shared calibration files + stay) by :func:`croak.session.retarget`; only a measured session has + such a folder, and for the others the reason is shown instead. + """ + try: + moved = retarget(self.state.session_options(), folder) + except ValueError as exc: + self.notify(str(exc)) + return + for field in self.state.load.dataset_path_fields(): + setattr(self.state.load, field, getattr(moved.load, field)) + self._build_pages() + self.go(Stage.DATA) + self.replay() + + def save_session(self) -> list[str]: + """Ask for a folder and save the session there; return the files written.""" + if self.state.result is None: + self.notify("Run a retrieval first: there is nothing to save yet.") + return [] + folder = QFileDialog.getExistingDirectory(self, "Save the session in") + if not folder: + return [] + written = session_files.save_session( + folder, + self.state.session_options(), + result=self.state.result, + processed=self.state.processed, + truth=self.state.truth, + uncertainty=self.state.uncertainty_results, + overview=self.pages[Stage.RETRIEVE].overview(), + ) + # What Analyse's pages add: the dispersed pulse, the uncertainty figure. + written += self.pages[Stage.ANALYSE].save_extras(Path(folder)) + self.session_path = str(Path(folder) / session_files.OPTIONS_FILE) + settings.remember_session(self.session_path) + self.notify(f"Saved {', '.join(written)} in {folder}") + self.refresh() + return written + + # -- the drawer and help ------------------------------------------------------ + def show_drawer(self, title: str, text: str) -> None: + """Open the drawer on ``text``.""" + self.drawer_title.setText(title) + self.drawer_text.setPlainText(text) + self.drawer.show() + + def show_options(self) -> None: + """Show the session's settings as the ``options.toml`` it would save.""" + self.show_drawer( + "Settings (options.toml)", save.format_options(self.state.to_options()) + ) + + def show_python(self) -> None: + """Show a Python script that replays the session headless.""" + self.show_drawer( + "As a Python script", generate_script(self.state.session_options()) + ) + + def copy_drawer(self) -> None: + """Put the drawer's text on the clipboard.""" + QApplication.clipboard().setText(self.drawer_text.toPlainText()) + self.notify("Copied.") + + def show_help(self) -> None: + """Open the help for the stage on screen.""" + page = self.pages.get(self.stage) + if page is None or page.show_help() is None: + self.notify("Choose a stage to see its help.") + + def notify(self, text: str) -> None: + """Show a passing notice in the status bar.""" + self.statusBar().showMessage(text, NOTICE_MS) + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 — Qt override + """Remember the window's geometry, then close.""" + settings.save_window(self) + super().closeEvent(event) diff --git a/croak/gui/wizard.py b/croak/gui/wizard.py deleted file mode 100644 index ae04932..0000000 --- a/croak/gui/wizard.py +++ /dev/null @@ -1,645 +0,0 @@ -"""The wizard main window. - -A welcome page, a synthetic-trace generator and a simulated-scan loader sit in -front of the six linear stages (Load → Marginal check → Preprocess → Retrieve → -Dispersion → Uncertainty). The welcome page routes to the entry points; the -linear stages and the two entry pages share a header and a footer nav bar (only -the welcome menu hides them), so every forward/backward control sits in the same -bottom-right corner. The synthetic and simulated loaders enter at the -marginal-check stage (their "stage 1"); the experimental Load is stage 1. -""" - -from __future__ import annotations - -import os - -import numpy as np -from PyQt6.QtGui import QCloseEvent -from PyQt6.QtWidgets import ( - QFileDialog, - QHBoxLayout, - QLabel, - QMainWindow, - QMessageBox, - QPushButton, - QStackedWidget, - QVBoxLayout, - QWidget, -) - -from .. import save -from ..session import retarget -from . import settings -from .base import Stage -from .branding import app_icon -from .stage_dispersion import StageDispersion -from .stage_load import ( - StageLoad, - assemble_load_data, - compute_preproc_defaults, - trace_signature, -) -from .stage_marginal_check import StageMarginalCheck -from .stage_preprocess import StagePreprocess -from .stage_retrieve import StageRetrieve -from .stage_simulated import ( - StageSimulated, - assemble_simulated_load_data, - assemble_simulated_tracedata, - simulated_signature, -) -from .stage_synthetic import StageSynthetic -from .stage_uncertainty import StageUncertainty -from .stage_welcome import StageWelcome -from .state import WizardState - -_TITLES = [ - "1. Load", - "2. Marginal check", - "3. Preprocess", - "4. Retrieve", - "5. Dispersion", - "6. Uncertainty", -] -_NSTAGES = len(_TITLES) -# the six linear stages live behind the welcome (0), synthetic (1) and -# simulated (2) entry pages -_STAGE_OFFSET = 3 - - -def _result_mismatch(saved, td, retrieve_params) -> str: - """Why a saved ``result.h5`` cannot be paired with ``td``, or ``""`` if it can. - - Reuse is only sound when the saved retrieval was fitted to *this* trace, so - the saved measured trace and frequency axis are compared with the current - ones element-wise rather than by shape. Both are stored exactly as the - retrieval saw them (``trace_meas`` is the array handed to - :func:`croak.processing.process_result`), so equality is the honest test and - any difference — a moved delay grid, a changed filter, a new calibration — - is grounds to re-run rather than to display a fit against data it never saw. - - A file too old to carry ``trace_meas`` cannot be verified at all, so it is - also refused: the fast path is an optimisation, and declining it costs only - a re-run, while wrongly taking it costs a wrong residual panel. - """ - cur = td.trace - meas = saved.get("trace_meas") - if meas is None: - return "the saved result does not store the trace it was fitted to" - meas = np.asarray(meas, float) - if meas.shape != np.shape(cur): - return ( - f"the saved result is on a {meas.shape[0]}x{meas.shape[1]} grid but " - f"this trace regrids to {np.shape(cur)[0]}x{np.shape(cur)[1]}" - ) - peak = float(np.max(np.abs(cur))) or 1.0 - if float(np.max(np.abs(meas - cur))) > 1e-9 * peak: - return "preprocessing no longer reproduces the trace the result was fitted to" - w_saved = saved.get("omega") - if w_saved is not None: - w_cur = np.asarray(td.grid.omega, float) - w_saved = np.asarray(w_saved, float) - span = float(np.max(np.abs(w_cur))) or 1.0 - if w_saved.shape != w_cur.shape or ( - float(np.max(np.abs(w_saved - w_cur))) > 1e-9 * span - ): - return "the saved result is on a different frequency grid" - algo = saved.get("algorithm") - if algo is not None and str(algo) != str(retrieve_params.solver): - return ( - f"the saved result came from {algo!s} but the loaded settings select " - f"{retrieve_params.solver}" - ) - return "" - - -class Wizard(QMainWindow): - """Welcome menu + synthetic generator + four-stage pulse-retrieval wizard.""" - - def __init__(self, state: WizardState | None = None): - super().__init__() - self.setWindowTitle("croak — pulse-retrieval wizard") - # run_wizard sets this application-wide, which already covers every window - # it opens; setting it here too means an embedder who built their own - # QApplication still gets the icon. - self.setWindowIcon(app_icon()) - # Un-maximised size: as the user last left it, else a default clamped to - # the screen (run_wizard maximises on top of this). - settings.restore_window(self) - self.state = state or WizardState() - - central = QWidget() - self.setCentralWidget(central) - layout = QVBoxLayout(central) - - # header bar: stage title (left) + a context-sensitive help button (right) - self._header_bar = QWidget() - self._header_bar.setStyleSheet("background:#22324a;") - hb = QHBoxLayout(self._header_bar) - hb.setContentsMargins(8, 4, 8, 4) - self.header = QLabel() - self.header.setStyleSheet("color:white; font-weight:bold;") - self.help_btn = QPushButton("? Help") - # The header bar is dark (#22324a); give the button an explicit - # high-contrast style so it doesn't disappear into the banner. - self.help_btn.setStyleSheet( - "QPushButton {" - " color:white; font-weight:bold; background:#3a557a;" - " border:1px solid white; border-radius:4px; padding:2px 10px; }" - "QPushButton:hover { background:#4a6a96; }" - ) - self.help_btn.setToolTip("Explain this stage's options") - self.help_btn.clicked.connect(self._show_help) - hb.addWidget(self.header, stretch=1) - hb.addWidget(self.help_btn) - layout.addWidget(self._header_bar) - - # stacked pages: welcome, synthetic, simulated, then the five linear stages - self.stack = QStackedWidget() - self.welcome = StageWelcome(self) - self.synthetic = StageSynthetic(self.state, self) - self.simulated = StageSimulated(self.state, self) - self.stages = [ - StageLoad(self.state), - StageMarginalCheck(self.state, self), - StagePreprocess(self.state), - StageRetrieve(self.state), - StageDispersion(self.state), - StageUncertainty(self.state), - ] - self.stack.addWidget(self.welcome) # index 0 - self.stack.addWidget(self.synthetic) # index 1 - self.stack.addWidget(self.simulated) # index 2 - for s in self.stages: # indices 3..8 - self.stack.addWidget(s) - layout.addWidget(self.stack, stretch=1) - - # footer - self.footer = QWidget() - fl = QHBoxLayout(self.footer) - self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.clicked.connect(self.close) - self.menu_btn = QPushButton("Main menu") - self.menu_btn.clicked.connect(self.leave_to_welcome) - self.back_btn = QPushButton("← Back") - self.back_btn.clicked.connect(self.go_back) - self.next_btn = QPushButton("Next →") - self.next_btn.clicked.connect(self.go_next) - fl.addWidget(self.cancel_btn) - fl.addWidget(self.menu_btn) - fl.addStretch(1) - fl.addWidget(self.back_btn) - fl.addWidget(self.next_btn) - layout.addWidget(self.footer) - - self.state.stage_changed.connect(self._refresh) - for sig in ( - self.state.load_data_changed, - self.state.tracedata_changed, - self.state.result_changed, - ): - sig.connect(self.refresh_nav) - self.state.retarget_requested.connect(self._retarget_session) - - # Which entry the current session came from lives on the state - # (``state.entry``): it selects the live loader params, so it is saved - # with the session and drives Back from preprocess as well. - # which page is on screen: "welcome", "synthetic"/"simulated" (the two - # entry pages) or "stage" (one of the six linear stages). The entry pages - # sit outside the linear sequence, so the footer's Back/Next cannot be - # driven from ``state.stage`` alone. - self._page = "welcome" - self.show_welcome() - - # -- page chrome -------------------------------------------------------- - def _chrome(self, visible: bool) -> None: - self._header_bar.setVisible(visible) - self.footer.setVisible(visible) - - def _entry_page(self) -> Stage | None: - """The entry page currently on screen, or ``None`` on any other page.""" - if self._page == "synthetic": - return self.synthetic - if self._page == "simulated": - return self.simulated - return None - - def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 — Qt override - """Remember the window geometry for the next launch, then close.""" - settings.save_window(self) - super().closeEvent(event) - - def _show_help(self) -> None: - """Open the help dialog for the page currently on screen.""" - page = self._entry_page() or self.stages[self.state.stage - 1] - page.show_help() - - def show_welcome(self) -> None: - self._page = "welcome" - self.stack.setCurrentIndex(0) - self._chrome(False) - - def leave_to_welcome(self) -> None: - """Return to the welcome menu, asking first if the session holds data. - - Every menu entry starts a new session, so a loaded trace or a retrieval - reached this way is discarded the moment the user picks one. Back at - Stage 1 (or on an entry page) and the Main menu button therefore confirm - before leaving; the data itself stays until a menu entry is chosen. - """ - if self.state.has_data and not self._confirm_discard(): - return - self.show_welcome() - - def _confirm_discard(self) -> bool: - """Ask whether to leave the current session behind; True to proceed.""" - box = QMessageBox(self) - box.setIcon(QMessageBox.Icon.Warning) - box.setWindowTitle("Leave this session?") - box.setText("Going back to the main menu leaves the current session behind.") - box.setInformativeText( - "Every menu entry starts a new session, so the loaded trace and any " - "retrieval will be discarded. Save… on the Retrieve stage keeps them." - ) - leave = box.addButton("Back to menu", QMessageBox.ButtonRole.DestructiveRole) - cancel = box.addButton(QMessageBox.StandardButton.Cancel) - box.setDefaultButton(cancel) - box.exec() - return box.clickedButton() is leave - - def show_synthetic(self) -> None: - self._new_session() - self.state.entry = "synthetic" - self._show_synthetic_page() - - def _show_synthetic_page(self) -> None: - """Display the synthetic generator without resetting in-progress data.""" - self._show_entry_page("synthetic", self.synthetic, "Generate synthetic trace") - - def load_simulated(self) -> None: - """Menu entry: load a numerically simulated FROG scan, then preprocess.""" - self._new_session() - self.state.entry = "simulated" - self._show_simulated_page() - - def _show_simulated_page(self) -> None: - """Display the simulated-scan loader without resetting in-progress data.""" - self._show_entry_page("simulated", self.simulated, "Load simulated scan") - - def _show_entry_page(self, page: str, entry: Stage, title: str) -> None: - """Show an entry page with the standard header/footer chrome. - - The entry pages are alternatives to Stage 1 rather than numbered stages, - so the header carries a plain title instead of "Stage N of M"; the footer - gives them the same bottom-right Back/Next as every other page, with Back - returning to the welcome menu and Next running the page's - :meth:`~croak.gui.base.Stage.advance`. - """ - self._page = page - self.stack.setCurrentWidget(entry) - self._chrome(True) - self.header.setText(title) - entry.on_enter() - self.refresh_nav() - - def enter_marginal_from_simulated(self) -> None: - """Show the marginal-check stage for the loaded simulated trace. - - The simulated loader is this session's "stage 1"; it hands the trace to - the marginal-check stage (stage 2), which then leads to preprocessing. - """ - self.goto_stage(2) - - def enter_retrieve_from_simulated(self) -> None: - """Jump straight to Retrieve for a raw, directly-loaded simulated trace. - - The "skip filtering & regrid" path on the simulated loader sets - ``state.tracedata`` itself (the as-simulated trace on its native grid), so - it bypasses the marginal-check (stage 2) and preprocess (stage 3) and - lands on Retrieve (stage 4). ``state.entry`` stays ``"simulated"``, so Back from - Retrieve still reaches Preprocess for a regridded comparison. - """ - self.goto_stage(4) - - def start_load(self) -> None: - """Menu entry 1: begin the experimental-trace workflow at Stage 1.""" - self._new_session() - self.state.entry = "experimental" - self.goto_stage(1) - - def load_previous(self) -> None: - """Menu entry 2: load a previous retrieval (saved ``options.toml``). - - The entry is not chosen here: it comes from the file (``state.entry``), - so a saved simulated session reloads through the simulated loader. - """ - self.state.reset() # _load_options repopulates from the file and rebuilds - self._load_options() - - def _new_session(self) -> None: - """Clear all data/settings and rebuild the stages for a fresh process.""" - self.state.reset() - self._recreate_stages() - - # -- navigation --------------------------------------------------------- - def goto_stage(self, stage: int) -> None: - """Show a linear stage, restoring the header/footer chrome.""" - self._chrome(True) - if self.state.stage != stage: - self.state.stage = stage # emits stage_changed -> _refresh - else: - self._refresh(stage) - - def go_next(self): - # On an entry page "Next" is that page's own forward action (generate the - # synthetic trace / load the simulated scan), which then routes onwards. - entry = self._entry_page() - if entry is not None: - if entry.can_advance(): - entry.advance() - return - if self.state.stage < _NSTAGES and self.state.ready(self.state.stage): - self.state.stage += 1 - - def go_back(self): - # Back from an entry page returns to the welcome menu (it is the session's - # "stage 1", so there is nowhere earlier to go). - if self._entry_page() is not None: - self.leave_to_welcome() - return - # Back from the marginal-check stage (stage 2) returns to whichever entry - # page is this session's "stage 1": the synthetic generator or the - # simulated loader for those entries, else the experimental Load (stage 1). - if self.state.stage == 2 and self.state.entry == "synthetic": - self._show_synthetic_page() - elif self.state.stage == 2 and self.state.entry == "simulated": - self._show_simulated_page() - elif self.state.stage > 1: - self.state.stage -= 1 - else: - self.leave_to_welcome() - - def enter_marginal_from_synthetic(self) -> None: - """Show the marginal-check stage for the generated trace. - - The synthetic generator is this session's "stage 1"; it hands the trace - to the marginal-check stage (stage 2), which then leads to preprocessing. - """ - self.goto_stage(2) - - def _refresh(self, stage: int): - self._page = "stage" - self._chrome(True) - idx = _STAGE_OFFSET + stage - 1 - self.stack.setCurrentIndex(idx) - self.header.setText(f"Stage {stage} of {_NSTAGES} — {_TITLES[stage - 1]}") - self.stages[stage - 1].on_enter() - self.refresh_nav() - - def refresh_nav(self): - """Re-label and re-enable the footer's Back/Next for the current page. - - Public because the entry pages call it when their readiness or forward - action changes (a file previews, "skip filtering & regrid" is toggled). - """ - self.back_btn.setEnabled(True) # Back at Stage 1 returns to the menu - entry = self._entry_page() - if entry is not None: - self.next_btn.setText(entry.next_label()) - self.next_btn.setEnabled(entry.can_advance()) - return - stage = self.state.stage - self.next_btn.setEnabled(stage < _NSTAGES and self.state.ready(stage)) - self.next_btn.setText("Finish" if stage == _NSTAGES else "Next →") - - # -- session persistence ------------------------------------------------ - def _load_options(self): - fn, _ = QFileDialog.getOpenFileName( - self, "Load options.toml", filter="TOML (*.toml)" - ) - if not fn: - return - self.state.from_options(save.load_options(fn)) - # The control widgets are bound one-way (widget -> params), so mutating - # the params alone leaves the UI showing the old values. Rebuild the - # stage pages from the freshly loaded state so every control reflects it. - self._rebuild_stages() - # A sibling result.h5 (written by the Retrieve stage's Save…) lets us - # reload the retrieved result without re-running the solver. - result_path = os.path.join(os.path.dirname(fn), "result.h5") - # Replay the saved settings through the pipeline so the GUI returns to - # (as near as possible) the saved state: same data, options and plots. - self._restore_session(result_path if os.path.exists(result_path) else None) - - def _restore_session(self, result_path: str | None = None): - """Reload the data and re-run preprocess, then retrieve (or reload a result). - - Reproduces the saved session end-to-end: it reloads the trace through the - loader the session was saved with (``state.entry``), without reseeding the - preprocess defaults so the saved windowing/filtering survives, and regrids - with the saved settings. If ``result_path`` points to a saved ``result.h5`` - whose grid matches the regridded trace, the retrieval is **rehydrated from - it** (no solver re-run); otherwise the retrieval is re-run to reproduce the - plots. If no trace file is set (or it can't be reloaded) it stops - gracefully with the controls still populated from the file. - """ - st = self.state - if st.entry == "synthetic": - # The synthetic generator's settings are not part of the saved session - # (they live only in its controls), so there is no trace to rebuild. - self._show_synthetic_page() - self.synthetic.set_status( - "Options loaded, but the synthetic generator's settings are not " - "saved with a session — regenerate the trace to replay the rest." - ) - return - loaded = ( - self._restore_simulated_load() - if st.entry == "simulated" - else self._restore_experimental_load() - ) - if not loaded: - return - # 2) regrid/filter with the saved preprocess settings — unless the loader - # already built the trace on its native grid (the raw simulated path). - if st.tracedata is None: - # stage order: Load(1), Marginal check(2), Preprocess(3), Retrieve(4), … - preproc_stage = self.stages[2] - preproc_stage._seed_all() - preproc_stage.apply(block=True) - if st.tracedata is None: - self.goto_stage(3) - self.set_status( - "Options loaded and data restored; preprocess did not complete." - ) - return - # 3) reproduce the retrieval: rehydrate the saved result if we can (no - # re-run), else re-run the solver to reproduce the plots. - if result_path is not None and self._reload_saved_result(result_path): - self.goto_stage(4) - self.set_status("Options and saved retrieval loaded — no re-run needed.") - return - self.goto_stage(4) - # Say why the saved result was not reused, so a re-run that returns - # slightly different numbers from the saved ones is not a mystery. - note = getattr(self, "_stale_result_note", "") - if result_path is not None and note: - self.set_status(f"Options loaded; re-running because {note}.") - self._stale_result_note = "" - self.stages[3]._run() - - def _restore_experimental_load(self) -> bool: - """Reload the measured trace with the saved dataset/unit selection. - - Returns ``True`` when ``state.load_data`` is set (the caller continues - with preprocess/retrieve); ``False`` after reporting why it stopped. - """ - st = self.state - if not st.load.frog_path: - self.goto_stage(1) - self.set_status("Options loaded. Choose a trace file and run the stages.") - return False - try: - data = assemble_load_data(st.load) - except Exception as exc: - self.goto_stage(1) - self.set_status(f"Options loaded, but reloading the trace failed: {exc}") - return False - # set_load_data (deliberately, unlike StageLoad._load_preview) does NOT - # reseed the preprocess defaults, so the saved grid/window/filter values - # are kept intact. Record the trace signature (and recover the data-seeded - # defaults for the Reset button) so a later reload of the same trace also - # preserves the restored settings. - st.set_load_data(data) - st.trace_sig = trace_signature(st.load) - st.preproc_defaults = compute_preproc_defaults(data) - self.stages[0]._draw(data) - return True - - def _restore_simulated_load(self) -> bool: - """Reload the simulated scan with the saved loader options. - - The simulated counterpart of :meth:`_restore_experimental_load`, mirroring - :meth:`~croak.gui.stage_simulated.StageSimulated.advance` but keeping the - saved preprocess settings. Its "skip filtering & regrid" option builds the - retrieval trace here, on the simulation's native grid, exactly as the - loader page does. Returns ``True`` when the data was restored. - """ - st = self.state - if not st.simulated.frog_path: - self._show_simulated_page() - self.simulated.set_status( - "Options loaded. Choose a simulated scan file and run the stages." - ) - return False - # The loader page's controls are built from the defaults, so reflect the - # restored options in them before anything re-reads them. - self.simulated.seed_widgets(st.simulated) - try: - data = assemble_simulated_load_data(st.simulated) - except Exception as exc: - self._show_simulated_page() - self.simulated.set_status( - f"Options loaded, but reloading the simulated scan failed: {exc}" - ) - return False - st.set_load_data(data) - st.trace_sig = simulated_signature(st.simulated) - st.preproc_defaults = compute_preproc_defaults(data) - self.simulated._draw(data) - if st.simulated.raw_direct: - try: - st.set_tracedata(assemble_simulated_tracedata(st.simulated)) - except Exception as exc: - self._show_simulated_page() - self.simulated.set_status( - f"Options loaded, but the raw simulated trace failed: {exc}" - ) - return False - return True - - def _reload_saved_result(self, result_path: str) -> bool: - """Rehydrate a saved ``result.h5`` onto the regridded trace (no re-run). - - Returns ``True`` when the result was loaded and set on the state (the - Retrieve stage draws it on entry); ``False`` (caller re-runs) when the - file is unreadable or its grid does not match the regridded trace. - - The check is not a formality, and it is deliberately a check on the - *data*, not just on the array shapes. Everything downstream pairs the - saved retrieval with the freshly preprocessed trace — the Retrieve - stage's residual panel divides one by the other — so the saved result is - only safe to adopt if that trace is the one it was actually fitted to. - A shape test alone would not establish this: any preprocessing change - that alters trace values without changing its size (a filtering default, - the edge taper, a recalibration) would slip through and be displayed as - a consistent fit when it is not. See :func:`_result_mismatch`. - """ - td = self.state.tracedata - if td is None: - return False - try: - saved = save.load_result(result_path) - result = save.result_from_saved(saved, grid=td.grid) - except KeyError, ValueError, OSError: - return False - self._stale_result_note = _result_mismatch(saved, td, self.state.retrieve) - if self._stale_result_note: - return False - # processed is recomputed by the Retrieve stage's on_enter/_update_view. - self.state.set_result(result) - return True - - def _retarget_session(self, base_dir: str) -> None: - """Point the current session at a new dataset folder and replay it. - - Triggered by the Retrieve stage's "Retarget…" button. Rewrites the - dataset-folder load paths to ``base_dir`` (keeping the shared calibration - files fixed) via :func:`croak.session.retarget`, refreshes the controls, - then reruns load → preprocess → retrieve with the current settings — so a - working retrieval can be applied to the next, similarly-laid-out dataset. - - Only experimental sessions have such a folder; for the others - :func:`croak.session.retarget` raises and the reason is shown rather than - replaying the session unchanged. - """ - try: - moved = retarget(self.state.session_options(), base_dir) - except ValueError as exc: - self.set_status(str(exc)) - return - # Copy the retargeted paths onto the live load params in place (the stage - # widgets bind to that instance); _recreate_stages then refreshes them. - for field in self.state.load.dataset_path_fields(): - setattr(self.state.load, field, getattr(moved.load, field)) - self._recreate_stages() - self._restore_session() - - def _recreate_stages(self): - """Rebuild the six stage widgets from ``state`` (no navigation). - - The welcome, synthetic and simulated pages (indices 0/1/2) are kept; only - the six linear stages behind them are recreated so their controls rebind - to the current parameter dataclasses. - """ - for s in self.stages: - self.stack.removeWidget(s) - s.deleteLater() - self.stages = [ - StageLoad(self.state), - StageMarginalCheck(self.state, self), - StagePreprocess(self.state), - StageRetrieve(self.state), - StageDispersion(self.state), - StageUncertainty(self.state), - ] - for s in self.stages: - self.stack.addWidget(s) - - def _rebuild_stages(self): - """Recreate the six stage pages and navigate to the current stage.""" - self._recreate_stages() - self.goto_stage(self.state.stage) - - def set_status(self, text: str): - self.stages[self.state.stage - 1].set_status(text) diff --git a/croak/gui/worker.py b/croak/gui/worker.py index 8bef93d..12cf50f 100644 --- a/croak/gui/worker.py +++ b/croak/gui/worker.py @@ -10,7 +10,7 @@ import time import numpy as np -from PyQt6.QtCore import QThread, pyqtSignal +from PySide6.QtCore import QThread, Signal from ..session import pipeline @@ -47,8 +47,8 @@ class PreprocessWorker(QThread): An error message and the originating ``request_id``. """ - finished_ok = pyqtSignal(object, int, bool) - failed = pyqtSignal(str, int) + finished_ok = Signal(object, int, bool) + failed = Signal(str, int) def __init__(self, params, data, request_id, parent=None, *, fast=False): super().__init__(parent) @@ -110,11 +110,11 @@ class RetrievalWorker(QThread): #: Python-heavy solver. PREVIEW_DUTY = 0.25 - progress = pyqtSignal(int, float, float) - preview = pyqtSignal(object) - finished_ok = pyqtSignal(object, float) - stopped = pyqtSignal(object, float) - failed = pyqtSignal(str) + progress = Signal(int, float, float) + preview = Signal(object) + finished_ok = Signal(object, float) + stopped = Signal(object, float) + failed = Signal(str) def __init__( self, @@ -236,9 +236,9 @@ class UncertaintyWorker(QThread): An error message on failure (excluding a user Stop). """ - progress = pyqtSignal(int, int, float) - finished_ok = pyqtSignal(object) - failed = pyqtSignal(str) + progress = Signal(int, int, float) + finished_ok = Signal(object) + failed = Signal(str) def __init__( self, result, tracedata, retrieve_params, uparams, dispersion=None, parent=None diff --git a/croak/gui/workflow.py b/croak/gui/workflow.py new file mode 100644 index 0000000..a7ed37f --- /dev/null +++ b/croak/gui/workflow.py @@ -0,0 +1,226 @@ +"""The window's route through a session, as rules without Qt. + +A session runs Home → Data → Check → Prepare → Retrieve → Analyse. Which stages can be +opened, and what the primary action in the top bar does (with, on Retrieve, a +second action beside it), depend only on what the session holds +(:class:`Progress`), so they are decided here, as pure functions the window +applies and the tests check directly. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from enum import StrEnum + +__all__ = [ + "STAGES", + "Action", + "Progress", + "Stage", + "blocked_reason", + "next_stage", + "primary_action", + "remember_recent", + "secondary_action", +] + +RECENT_LIMIT = 6 +"""How many recent sessions Home lists.""" + + +class Stage(StrEnum): + """A place in the window.""" + + HOME = "home" + ABOUT = "about" + DATA = "data" + CHECK = "check" + PREPARE = "prepare" + RETRIEVE = "retrieve" + ANALYSE = "analyse" + + @property + def title(self) -> str: + """The stage's name in the top bar.""" + return self.value.capitalize() + + +STAGES = (Stage.DATA, Stage.CHECK, Stage.PREPARE, Stage.RETRIEVE, Stage.ANALYSE) +"""The stages of a session, in order (Home and About are outside the session).""" + + +@dataclass(frozen=True) +class Progress: + """What the current session holds, as far as the route is concerned. + + Attributes + ---------- + entry : str + ``"experimental"``, ``"simulated"`` or ``"synthetic"``; ``""`` before a + session starts. + data_ready : bool + The Data sheet can hand on a trace (a file previews, a synthetic pulse + is set up). + has_load_data : bool + A trace has been loaded (``state.load_data``). + has_tracedata : bool + A retrieval trace exists (``state.tracedata``). + has_result : bool + A retrieval result exists. + raw_direct : bool + The simulated scan goes to Retrieve on its native grid, skipping Prepare. + retrieving : bool + A retrieval is running. + estimating : bool + An uncertainty estimate is running. + """ + + entry: str = "" + data_ready: bool = False + has_load_data: bool = False + has_tracedata: bool = False + has_result: bool = False + raw_direct: bool = False + retrieving: bool = False + estimating: bool = False + + +def blocked_reason(stage: Stage, progress: Progress) -> str: + """Why ``stage`` cannot be opened now, or ``""`` if it can. + + Examples + -------- + >>> blocked_reason(Stage.PREPARE, Progress(entry="experimental")) + 'Load a trace first.' + >>> blocked_reason(Stage.DATA, Progress(entry="synthetic")) + '' + """ + if stage in (Stage.HOME, Stage.ABOUT): + return "" + if not progress.entry: + return "Start a session from Home first." + if stage is Stage.DATA: + return "" + if stage is Stage.CHECK: + return "" if progress.has_load_data else "Load a trace first." + if stage is Stage.PREPARE: + if progress.raw_direct: + return ( + "This simulated scan is retrieved on its own grid " + "(Skip filtering & regrid), so there is nothing to prepare." + ) + return "" if progress.has_load_data else "Load a trace first." + if stage is Stage.RETRIEVE: + return "" if progress.has_tracedata else "Prepare the trace first." + return "" if progress.has_result else "Run a retrieval first." + + +def next_stage(stage: Stage, progress: Progress) -> Stage: + """The stage "Continue" leads to from ``stage``. + + A raw simulated scan skips the check and Prepare: it goes from Data (or, + if checked after all, from Check) straight to Retrieve. + + Raises + ------ + ValueError + For a stage with no "Continue" (Home, About, Analyse). + """ + if stage in (Stage.DATA, Stage.CHECK) and progress.raw_direct: + return Stage.RETRIEVE + if stage is Stage.DATA: + return Stage.CHECK + if stage is Stage.CHECK: + return Stage.PREPARE + if stage is Stage.PREPARE: + return Stage.RETRIEVE + if stage is Stage.RETRIEVE: + return Stage.ANALYSE + raise ValueError(f"{stage.title} has no next stage") + + +@dataclass(frozen=True) +class Action: + """The top bar's primary action. + + Attributes + ---------- + kind : str + What it does: ``"continue"``, ``"retrieve"``, ``"stop"``, ``"save"``, + ``"estimate"`` or ``"stop-estimate"``; ``""`` for no action. + text : str + The button's caption. + enabled : bool + Whether it can be pressed now. + """ + + kind: str + text: str + enabled: bool = True + + +def primary_action(stage: Stage, progress: Progress, page: str = "") -> Action: + """The primary action on ``stage`` (and Analyse ``page``) given ``progress``. + + Examples + -------- + >>> primary_action(Stage.RETRIEVE, Progress(entry="synthetic", retrieving=True)) + Action(kind='stop', text='Stop', enabled=True) + >>> primary_action(Stage.DATA, Progress(entry="synthetic")).enabled + False + """ + if stage in (Stage.HOME, Stage.ABOUT): + return Action("", "", enabled=False) + if stage is Stage.DATA: + return Action("continue", "Continue", enabled=progress.data_ready) + if stage is Stage.CHECK: + return Action("continue", "Continue", enabled=progress.has_load_data) + if stage is Stage.PREPARE: + return Action("continue", "Continue", enabled=progress.has_tracedata) + if stage is Stage.RETRIEVE: + if progress.retrieving: + return Action("stop", "Stop") + # With a result the next step is to look at it, as on every other + # stage; running again is the second action (secondary_action). + if progress.has_result: + return Action("continue", "Analyse") + return Action("retrieve", "Retrieve", enabled=progress.has_tracedata) + if page == "uncertainty": + if progress.estimating: + return Action("stop-estimate", "Stop") + return Action("estimate", "Estimate", enabled=progress.has_result) + return Action("save", "Save", enabled=progress.has_result) + + +def secondary_action(stage: Stage, progress: Progress) -> Action: + """The action beside the primary one: on Retrieve with a result, run again. + + Examples + -------- + >>> done = Progress(entry="synthetic", has_tracedata=True, has_result=True) + >>> secondary_action(Stage.RETRIEVE, done) + Action(kind='retrieve', text='Retrieve again', enabled=True) + >>> secondary_action(Stage.PREPARE, done).kind + '' + """ + if stage is Stage.RETRIEVE and progress.has_result and not progress.retrieving: + return Action("retrieve", "Retrieve again", enabled=progress.has_tracedata) + return Action("", "", enabled=False) + + +def remember_recent( + recent: Sequence[str], path: str, limit: int = RECENT_LIMIT +) -> list[str]: + """Put ``path`` first in the recent list, without repeats, at most ``limit``. + + Examples + -------- + >>> remember_recent(["a", "b", "c"], "b", limit=3) + ['b', 'a', 'c'] + >>> remember_recent(["a", "b"], "z", limit=2) + ['z', 'a'] + """ + if limit < 1: + raise ValueError(f"limit must be at least 1, not {limit}") + return [path, *(p for p in recent if p != path)][:limit] diff --git a/croak/plotting.py b/croak/plotting.py deleted file mode 100644 index 6bbf5c1..0000000 --- a/croak/plotting.py +++ /dev/null @@ -1,2661 +0,0 @@ -"""Matplotlib plotting for retrieval results. - -Provides custom colormaps (white at the low end; a red band for negatives), -reusable **single-axis** plotters (so the GUI embeds exactly the same code), and -**composite** figures: the 12-panel :func:`plot_retrieval`, the 6-panel -:func:`plot_frog_filter`, and :func:`plot_simulated_trace`. - -The retrieval summary is assembled from twelve independent **panels** -(``draw_(ax, data)`` over one :class:`RetrievalPlotData` bundle, registered in -:data:`RETRIEVAL_PANELS`). :func:`plot_retrieval` lays them out as the 3×4 -overview; :func:`plot_retrieval_page` draws one of the three 2×2 pages -(:data:`RETRIEVAL_PAGES`: Traces, Pulse, Diagnostics) the GUI shows as tabs. - -Figures are built with :external+matplotlib:class:`matplotlib.figure.Figure` directly -(no ``pyplot`` global state), so they embed cleanly in a Qt canvas and also save -standalone via ``fig.savefig(...)``. -""" - -from __future__ import annotations - -import contextlib -import functools -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Literal - -import numpy as np -from matplotlib.colors import ListedColormap -from matplotlib.figure import Figure -from numpy.typing import ArrayLike - -from .maths import wlfreq -from .processing import ( - ProcessedResult, - TruthPulse, - marginals, - process_result, - spectrogram, -) -from .result import RetrievalResult - -if TYPE_CHECKING: - from matplotlib.axes import Axes - from matplotlib.collections import QuadMesh - from matplotlib.colors import Colormap - from matplotlib.legend import Legend - from matplotlib.lines import Line2D - from matplotlib.text import Text - from numpy.typing import NDArray - - from .uncertainty import UncertaintyResult - -#: Edge-energy fraction (see :func:`croak.processing.edge_energy_fraction`) above -#: which the spectrum panel flags the retrieval in red. A sound retrieval on a -#: generous band sits two orders of magnitude below this; above it, re-run on a -#: wider grid. -EDGE_ENERGY_WARN = 0.01 - -__all__ = [ - "PlotScale", - "FULL_SCALE", - "FONT_SCALE_MIN", - "FONT_SCALE_STEP", - "COMPACT_BELOW", - "plot_scale", - "scaled_rc", - "cmap_white", - "cmap_negwhite", - "signed_pcolormesh", - "si_power", - "sig3", - "plot_residual", - "plot_temporal", - "plot_spectral", - "SPECTRAL_AXES", - "EDGE_ENERGY_WARN", - "plot_convergence", - "plot_marginal", - "plot_spectrogram", - "plot_retrieval", - "RetrievalPlotData", - "retrieval_plot_data", - "TraceArtists", - "ResidualArtists", - "TemporalArtists", - "SpectralArtists", - "ConvergenceArtists", - "LineArtists", - "MarginalArtists", - "SpectrogramArtists", - "PanelSpec", - "PageSpec", - "ColorbarSpec", - "RETRIEVAL_PANELS", - "RETRIEVAL_COLORBARS", - "RETRIEVAL_PAGES", - "RETRIEVAL_OVERVIEW", - "plot_retrieval_page", - "draw_measured_log", - "draw_retrieved_log", - "draw_measured_lin", - "draw_retrieved_lin", - "draw_temporal", - "draw_spectral", - "draw_convergence", - "draw_residual", - "draw_spectral_filter", - "draw_freq_marginal", - "draw_delay_marginal", - "draw_spectrogram", - "update_measured_log", - "update_retrieved_log", - "update_measured_lin", - "update_retrieved_lin", - "update_temporal", - "update_spectral", - "update_convergence", - "update_convergence_curve", - "update_residual", - "update_spectral_filter", - "update_freq_marginal", - "update_delay_marginal", - "update_spectrogram", - "RetrievalPageView", - "plot_frog_filter", - "filter_view_vmax", - "FrogFilterView", - "plot_simulated_trace", - "plot_fwhm_distribution", - "plot_intensity_band", - "plot_uncertainty", -] - -_TWOPI_PHZ = 2 * np.pi * 1e15 - -#: Abscissae the spectrum panel can be drawn against (see :func:`plot_spectral`). -SPECTRAL_AXES: tuple[str, ...] = ("frequency", "wavelength") -type SpectralAxis = Literal["frequency", "wavelength"] - - -def sig3(value: float) -> str: - """Format a number to three significant digits, for a read-out. - - Three significant digits keeps a read-out equally informative across the - ranges croak covers (sub-fs attosecond pulses through hundreds of fs; - nanometre-wide through hundred-nanometre-wide spectra), where a fixed number - of decimals is too coarse at one end and spuriously precise at the other. - Unlike the ``g`` format this keeps trailing zeros (so ``7.00``, not ``7``) and - never switches to exponent notation, both of which read badly beside a sibling - entry in a legend or a table column. - - Parameters - ---------- - value : float - The number to format, already in its display unit. - - Returns - ------- - str - The formatted number, with no unit attached. - - Examples - -------- - >>> sig3(9.512), sig3(7.0), sig3(0.85), sig3(953.2) - ('9.51', '7.00', '0.850', '953') - """ - if not np.isfinite(value) or value == 0.0: - return f"{value:.2f}" - # digits before the decimal point (<= 0 for |value| < 1), so 3 - that many is - # how many decimals are still needed to reach three significant digits. - integer_digits = int(np.floor(np.log10(abs(value)))) + 1 - return f"{value:.{max(0, 3 - integer_digits)}f}" - - -def _fs(seconds: float) -> str: - """Format a duration (s) as femtoseconds to three significant digits.""" - return sig3(seconds / 1e-15) - - -# --------------------------------------------------------------------------- -# On-screen density -# --------------------------------------------------------------------------- -# Matplotlib sizes text in points, so when a figure is squeezed into a small window -# the axes shrink and the text does not: on a 1366x768 laptop the twelve-panel -# retrieval summary spent 78 % of its area on titles, labels and padding. The GUI -# canvas (croak.gui.canvas.MplCanvas) therefore scales the rc font sizes by how far -# the canvas has fallen below the size its figure was designed for. The scale is a -# pure function of geometry, kept here so the plotting layer stays Qt-free and the -# thresholds are testable headlessly. - -#: Smallest fraction of the design font size that text may shrink to. Below ~70 % -#: a 10 pt label is 7 pt, the limit of comfortable legibility on a laptop panel. -FONT_SCALE_MIN = 0.7 -#: Quantisation step of :func:`plot_scale`. A window resize re-plots only when the -#: scale crosses a step, so a few pixels of drag never trigger a redraw. -FONT_SCALE_STEP = 0.05 -#: Below this scale :attr:`PlotScale.compact` is set, for plotting code that wants -#: to drop optional decoration (a second legend entry, a colorbar label) when tight. -COMPACT_BELOW = 0.9 - - -@dataclass(frozen=True) -class PlotScale: - """Text density of a figure relative to the size its layout was designed for. - - Parameters - ---------- - font : float - Multiplier applied to every rc font size (and text padding), in - ``[FONT_SCALE_MIN, 1.0]``. ``1.0`` is matplotlib's default 10 pt base. - - Examples - -------- - >>> PlotScale(0.85).compact - True - >>> FULL_SCALE.compact - False - """ - - font: float = 1.0 - - @property - def compact(self) -> bool: - """Whether the figure is tight enough to drop optional decoration.""" - return self.font < COMPACT_BELOW - - -#: The unscaled density: matplotlib's defaults. -FULL_SCALE = PlotScale() - - -def plot_scale( - width_in: float, height_in: float, design_in: tuple[float, float] -) -> PlotScale: - """Return the text density for a figure of the given size. - - The scale is the fraction of the design size that fits in the *tighter* - direction, ``min(width / design_width, height / design_height)``, quantised - to :data:`FONT_SCALE_STEP` and clamped to ``[FONT_SCALE_MIN, 1.0]``. A figure - at or above its design size is drawn at full scale; one squeezed to 60 % of - its design height gets the minimum. - - Parameters - ---------- - width_in, height_in : float - Current figure size in inches (``Figure.get_size_inches()``). On a Qt - canvas this is the logical pixel size / 100 on every display, because - matplotlib folds the device-pixel ratio into ``figure.dpi``. - design_in : tuple of float - ``(width, height)`` in inches the figure's layout was designed for — the - ``figsize`` a stage passes to its canvas. - - Returns - ------- - PlotScale - - Raises - ------ - ValueError - If any size is not positive. - - Examples - -------- - >>> plot_scale(9.91, 4.21, (12.0, 7.0)).font # Retrieve page at 1366x768 - 0.7 - >>> plot_scale(15.45, 7.33, (12.0, 7.0)).font # ... at 1920x1080 - 1.0 - """ - design_w, design_h = design_in - if min(width_in, height_in, design_w, design_h) <= 0: - raise ValueError( - f"figure and design sizes must be positive, got {width_in}x{height_in} " - f"in for a {design_w}x{design_h} in design" - ) - ratio = min(width_in / design_w, height_in / design_h) - # Quantise to the nearest step; round to two decimals so equal steps compare - # equal as floats (0.85, not 0.8500000000000001). - quantised = round(round(ratio / FONT_SCALE_STEP) * FONT_SCALE_STEP, 2) - return PlotScale(font=min(1.0, max(FONT_SCALE_MIN, quantised))) - - -# Matplotlib's default sizes spelled out in points. The rc defaults for the title -# and label keys are the *relative* strings 'large'/'medium', which cannot be -# multiplied; writing the resolved points also makes the scaling immune to a user -# matplotlibrc with absolute sizes. The four pads are included because text -# *padding* is a fixed-pixel cost too. -_SCALED_RC_BASE: dict[str, float] = { - "font.size": 10.0, - "axes.titlesize": 12.0, - "axes.labelsize": 10.0, - "xtick.labelsize": 10.0, - "ytick.labelsize": 10.0, - "legend.fontsize": 10.0, - "figure.titlesize": 12.0, - "axes.labelpad": 4.0, - "axes.titlepad": 6.0, - "xtick.major.pad": 3.5, - "ytick.major.pad": 3.5, -} - - -def scaled_rc(scale: PlotScale) -> dict[str, float]: - """Return rcParams overrides that shrink every font size and pad by ``scale``. - - Use with :external+matplotlib:func:`matplotlib.rc_context` *around the plotting - call*: matplotlib resolves relative sizes (``fontsize="small"``, the ``'large'`` - title default) against ``font.size`` when an artist is created, not when it is - drawn, so the context must enclose everything that creates text. - - Parameters - ---------- - scale : PlotScale - The density; :data:`FULL_SCALE` reproduces matplotlib's defaults. - - Returns - ------- - dict - ``{rc key: value in points}`` for the font-size and padding keys. - - Examples - -------- - >>> round(scaled_rc(PlotScale(0.7))["axes.titlesize"], 2) - 8.4 - """ - return {key: base * scale.font for key, base in _SCALED_RC_BASE.items()} - - -# --------------------------------------------------------------------------- -# Colormaps -# --------------------------------------------------------------------------- -def cmap_white(base: str = "viridis", *, n: int = 8, white: bool = True, N: int = 512): - """Build a base colormap with (optionally) a white point at the low end.""" - import matplotlib as mpl - - cm = mpl.colormaps[base] - vals = np.linspace(0, 1, n) - cols = cm(vals) - if white: - cols[0] = [1, 1, 1, 1] - xi = np.linspace(0, 1, N) - out = np.empty((N, 4)) - for k in range(4): - out[:, k] = np.interp(xi, vals, cols[:, k]) - return ListedColormap(out) - - -def cmap_negwhite( - base: str = "viridis", - *, - n: int = 8, - white: bool = True, - negfrac: float = 0.0, - N: int = 512, -): - """Like :func:`cmap_white` but with a red gradient for negative values.""" - if negfrac <= 0: - return cmap_white(base, n=n, white=white, N=N) - n_pos = max(round(N * (1 - negfrac)), 2) - n_neg = N - n_pos - pos = cmap_white(base, n=n, white=white, N=n_pos)(np.linspace(0, 1, n_pos)) - neg = np.empty((n_neg, 4)) - for i in range(n_neg): - t = i / max(n_neg - 1, 1) - neg[i] = [1.0, t, t, 1.0] - return ListedColormap(np.vstack([neg, pos])) - - -def _white_red(): - """White→red colormap used to render negative trace values by magnitude.""" - from matplotlib.colors import LinearSegmentedColormap - - return LinearSegmentedColormap.from_list("white_red", ["white", "red"]) - - -def _signed_magnitude( - C, *, db: bool, tracedb: float, vmax: float | None -) -> tuple[NDArray[np.float64], NDArray[np.bool_], float, float]: - """Split ``C`` into magnitude, negative mask and the shared ``(vmin, vmax)``. - - One code path for drawing and for updating in place. - """ - C = np.asarray(C, dtype=float) - neg = C < 0 - if db: - mag = np.clip(10 * np.log10(np.maximum(np.abs(C), 1e-30)), -tracedb, 0.0) - return mag, neg, -tracedb, 0.0 - mag = np.abs(C) - return mag, neg, 0.0, float(np.nanmax(mag)) if vmax is None else vmax - - -def signed_pcolormesh(ax, x, y, C, *, db, tracedb=30.0, cmap_pos, vmax=None): - """Pcolormesh that splits an image's sign onto two colormaps. - - Positive samples are drawn with ``cmap_pos`` (e.g. viridis); negative - samples are drawn by *magnitude* with a white→red map. Both maps share the - **same** scale (``[0, vmax]`` linear, ``[-tracedb, 0]`` dB), so a negative is - only as saturated as a positive of equal magnitude — small negatives stay - faint instead of dominating — and zero is white in both (pair ``cmap_pos`` - with :func:`cmap_white`). ``C`` follows the usual - :external+matplotlib:meth:`~matplotlib.axes.Axes.pcolormesh` convention — - shape ``(len(y), len(x))`` (a FROG ``(Nλ, Nτ)`` array already has this shape - for x = delay and y = wavelength). - - Returns ``(mappable_pos, mappable_neg)`` (the second is ``None`` when there - are no negative samples). - """ - mag, neg, vmin, vhi = _signed_magnitude(C, db=db, tracedb=tracedb, vmax=vmax) - mpos = ax.pcolormesh( - x, - y, - np.ma.masked_where(neg, mag), - vmin=vmin, - vmax=vhi, - cmap=cmap_pos, - rasterized=True, - shading="auto", - ) - mneg = None - if neg.any(): - mneg = ax.pcolormesh( - x, - y, - np.ma.masked_where(~neg, mag), - vmin=vmin, - vmax=vhi, - cmap=_white_red(), - rasterized=True, - shading="auto", - ) - return mpos, mneg - - -# --------------------------------------------------------------------------- -# Single-axis plotters (reused by the composites and the GUI) -# --------------------------------------------------------------------------- -def plot_residual(ax, omega, delays, resid, omega0, *, halfwidth=None, flim=None): - """Plot the measured−retrieved residual with a symmetric blue-white-red map. - - ``flim`` optionally bounds the frequency axis, as ``(f_min, f_max)`` in Hz. - """ - mr = float(np.max(np.abs(resid))) or 1.0 - freq = (np.asarray(omega) + omega0) / _TWOPI_PHZ - m = ax.pcolormesh( - np.asarray(delays) / 1e-15, - freq, - resid, - vmin=-mr, - vmax=mr, - cmap=cmap_white("bwr", n=32, white=False), - rasterized=True, - shading="auto", - ) - ax.set_xlabel("Delay (fs)") - ax.set_ylabel("Frequency (PHz)") - ax.set_title("Residuals") - if halfwidth is not None: - ax.set_xlim(-halfwidth / 1e-15, halfwidth / 1e-15) - if flim is not None: - ax.set_ylim(flim[0] / 1e15, flim[1] / 1e15) - return m - - -_POWER_PREFIXES: tuple[tuple[float, str], ...] = ( - (1e15, "PW"), - (1e12, "TW"), - (1e9, "GW"), - (1e6, "MW"), - (1e3, "kW"), - (1.0, "W"), - (1e-3, "mW"), - (1e-6, "µW"), -) - - -def si_power(watts: float) -> tuple[float, str]: - """Return ``(scale, unit)`` to display ``watts`` with an SI power prefix. - - Picks the largest prefix not exceeding ``|watts|`` (so e.g. ``1.2e7`` W → - ``(1e6, "MW")``). Non-finite or non-positive inputs fall back to plain watts; - values below the smallest prefix use that smallest prefix. - """ - w = abs(float(watts)) - if not np.isfinite(w) or w <= 0: - return 1.0, "W" - for scale, unit in _POWER_PREFIXES: - if w >= scale: - return scale, unit - return _POWER_PREFIXES[-1] - - -def _prepare_fig(fig, figsize): - """Return a cleared figure to draw a composite into (new if None).""" - if fig is None: - return Figure(figsize=figsize, layout="constrained") - fig.clear() - with contextlib.suppress(Exception): - fig.set_layout_engine("constrained") - return fig - - -def _combined_legend(ax, axp, *, loc: str) -> Legend: - """Draw one legend containing intensity and twin-axis phase curves. - - ``loc`` is a fixed corner: matplotlib's ``"best"`` re-evaluates on every draw - and made the legend jump between frames of the live preview. - """ - handles, labels = ax.get_legend_handles_labels() - phase_handles, phase_labels = axp.get_legend_handles_labels() - return ax.legend( - handles + phase_handles, - labels + phase_labels, - loc=loc, - handlelength=1.0, - frameon=False, - fontsize="small", - ) - - -# --------------------------------------------------------------------------- -# Panel artists -# --------------------------------------------------------------------------- -# Each retrieval panel returns the matplotlib artists it created, in a small frozen -# dataclass, so that a live view can later update them in place (set_data, -# set_array, set_text) instead of clearing and rebuilding the figure, and so that -# page-level decorations (the colorbars shared by a pair of trace panels) can find -# their mappables. eq=False: comparing artists by field value is never meaningful. - - -@dataclass(frozen=True, eq=False) -class TraceArtists: - """Artists of a trace-image panel. - - Attributes - ---------- - mesh_pos, mesh_neg : QuadMesh - The positive-sample image and, when the data hold negatives, the - white→red magnitude image of the negative samples (else ``None``). - label : Text or None - The bold error/shape annotation of the retrieved-linear panel. - """ - - mesh_pos: QuadMesh - mesh_neg: QuadMesh | None - label: Text | None = None - - -@dataclass(frozen=True, eq=False) -class ResidualArtists: - """Artists of the residual panel: its signed image (``None`` if no residual).""" - - mesh: QuadMesh | None - - -@dataclass(frozen=True, eq=False) -class TemporalArtists: - """Artists of the temporal panel. - - Attributes - ---------- - line_retr, line_tl : Line2D - Retrieved and transform-limited intensity (or power) curves. - line_phase : Line2D - Retrieved temporal phase on the twin axis ``ax_phase``. - ax_phase : Axes - The right-hand phase axis. - legend : Legend - The combined intensity + phase legend. - legend_retr, legend_tl : Text - The legend entries carrying the R and TL durations (they change with the - result, so a live update rewrites them in place). - line_truth, line_truth_phase : Line2D or None - Ground-truth intensity and phase overlays, when a truth was given. - """ - - line_retr: Line2D - line_tl: Line2D - line_phase: Line2D - ax_phase: Axes - legend: Legend - legend_retr: Text - legend_tl: Text - line_truth: Line2D | None = None - line_truth_phase: Line2D | None = None - - -@dataclass(frozen=True, eq=False) -class SpectralArtists: - """Artists of the spectrum panel. - - Attributes - ---------- - line_retr : Line2D - Retrieved spectral intensity. - line_phase, line_phase_fit : Line2D - Retrieved spectral phase and its dashed polynomial fit, on ``ax_phase``. - ax_phase : Axes - The right-hand phase axis. - legend : Legend - The combined intensity + phase legend. - text_dispersion, text_edge : Text - The GDD/TOD block and the edge-energy warning. - line_meas, line_truth, line_truth_phase : Line2D or None - Measured spectrum, truth spectrum and truth phase, when available. - """ - - line_retr: Line2D - line_phase: Line2D - line_phase_fit: Line2D - ax_phase: Axes - legend: Legend - text_dispersion: Text - text_edge: Text - line_meas: Line2D | None = None - line_truth: Line2D | None = None - line_truth_phase: Line2D | None = None - - -@dataclass(frozen=True, eq=False) -class ConvergenceArtists: - """Artists of the convergence panel: the error curve, stage rules, legend.""" - - line: Line2D - boundaries: tuple[Line2D, ...] = () - legend: Legend | None = None - - -@dataclass(frozen=True, eq=False) -class LineArtists: - """A single-curve panel (the spectral filter).""" - - line: Line2D - - -@dataclass(frozen=True, eq=False) -class MarginalArtists: - """Artists of a marginal panel: retrieved and (optional) measured curves.""" - - line_retr: Line2D - legend: Legend - line_meas: Line2D | None = None - - -@dataclass(frozen=True, eq=False) -class SpectrogramArtists: - """Artists of the spectrogram panel: its image.""" - - mesh: QuadMesh - - -type PanelArtists = ( - TraceArtists - | ResidualArtists - | TemporalArtists - | SpectralArtists - | ConvergenceArtists - | LineArtists - | MarginalArtists - | SpectrogramArtists -) - - -def _temporal_curves(pr: ProcessedResult, power_scale): - """Return ``(retr, tl, r_label, tl_label, ylabel, power_scale)`` for the pulse. - - In absolute power when ``pr.peak_power`` is known (deriving the SI prefix if - ``power_scale`` is None), else normalised. Shared by draw and update. - """ - if pr.peak_power is not None: - if power_scale is None: - power_scale = si_power(max(pr.peak_power, pr.peak_power_tl or 0.0)) - scale, unit = power_scale - retr = pr.It_retr * pr.peak_power / scale - tl = pr.It_tl * (pr.peak_power_tl or 0.0) / scale - r_label = f"R ({_fs(pr.fwhm_retr)} fs, {pr.peak_power / scale:.1f} {unit})" - tl_label = ( - f"TL ({_fs(pr.fwhm_tl)} fs, {(pr.peak_power_tl or 0.0) / scale:.1f} {unit})" - ) - return retr, tl, r_label, tl_label, f"Power ({unit})", power_scale - r_label = f"R ({_fs(pr.fwhm_retr)} fs)" - tl_label = f"TL ({_fs(pr.fwhm_tl)} fs)" - return pr.It_retr, pr.It_tl, r_label, tl_label, "Power (a.u.)", None - - -def _truth_intensity(truth: TruthPulse, energy, power_scale) -> NDArray[np.float64]: - """Return the truth's intensity on the temporal panel's scale. - - With a pulse energy the truth is shown in absolute power on the shared SI - scale: it carries the same energy, so its peak power is energy/∫(I/I_peak)dt - over its *own* duration (:meth:`~croak.processing.TruthPulse.peak_power`). - """ - peak_power = truth.peak_power(energy) - if peak_power is None: - return np.asarray(truth.It, dtype=float) - scale = power_scale[0] if power_scale is not None else 1.0 - return np.asarray(truth.It, dtype=float) * peak_power / scale - - -def _intensity_ylim(*curves) -> tuple[float, float]: - """``(0, 1.05 × max)`` over the given curves (``None`` entries ignored). - - Set explicitly because ``Axes.set_ylim`` switches autoscaling off, so an - in-place update could not rely on it; the 5 % headroom is matplotlib's - default margin. - """ - tops = [float(np.nanmax(c)) for c in curves if c is not None and np.size(c)] - top = max(tops, default=1.0) - return 0.0, 1.05 * top if top > 0 else 1.0 - - -def _phase_ylim(*curves) -> tuple[float, float] | None: - """Phase-axis limits centred on the shown phases with their span as margin. - - Finite samples of every non-empty curve count; ``None`` when nothing is shown. - """ - arrays = [np.asarray(c, dtype=float) for c in curves if c is not None] - arrays = [a[np.isfinite(a)] for a in arrays] - arrays = [a for a in arrays if a.size] - if not arrays: - return None - pm = np.concatenate(arrays) - d = pm.max() - pm.min() - mid = (pm.max() + pm.min()) / 2 - return mid - d - 1e-9, mid + d + 1e-9 - - -def _temporal_panel( - ax, - pr: ProcessedResult, - *, - halfwidth=None, - power_scale=None, - truth: TruthPulse | None = None, -) -> TemporalArtists: - """Draw the temporal panel into ``ax``; return its artists (see plot_temporal).""" - t = pr.t_over / 1e-15 - retr, tl, r_label, tl_label, ylabel, power_scale = _temporal_curves(pr, power_scale) - (line_retr,) = ax.plot(t, retr, c="C0", label=r_label) - # The TL pulse is peak-centred on its own oversampled axis (its peak index - # need not coincide with the retrieved pulse's), so plot it against t_tl — - # using t_over would shift its peak off t=0 by a sample or two. - (line_tl,) = ax.plot(pr.t_tl / 1e-15, tl, c="C1", label=tl_label) - line_truth = None - truth_curve = None - if truth is not None: - # The known pulse as a thin black line *under* the retrieved/TL curves, in - # physical units so it is independent of the retrieval grid. A retrieval - # is defined only up to the time-direction ambiguity, so the truth may - # appear shifted or flipped. Its legend entry carries its own FWHM so the - # three durations read side by side. - truth_curve = _truth_intensity(truth, pr.energy, power_scale) - (line_truth,) = ax.plot( - truth.t / 1e-15, - truth_curve, - c="k", - lw=0.8, - zorder=1, - label=f"truth ({_fs(truth.fwhm)} fs)", - ) - ax.set_xlabel("Time (fs)") - ax.set_ylabel(ylabel) - ax.set_ylim(*_intensity_ylim(retr, tl, truth_curve)) - ax.set_title("Retrieved pulse") - if halfwidth is not None: - ax.set_xlim(-halfwidth / 1e-15, halfwidth / 1e-15) - axp = ax.twinx() - (line_phase,) = axp.plot(t[pr.mask_t], pr.phi_t[pr.mask_t], c="C2") - axp.set_ylabel("Phase (rad)") - line_truth_phase = None - truth_phase = None - if truth is not None and truth.phi_t is not None: - truth_mask = np.asarray(truth.It) > 0.01 - truth_phase = np.asarray(truth.phi_t)[truth_mask] - (line_truth_phase,) = axp.plot( - np.asarray(truth.t)[truth_mask] / 1e-15, - truth_phase, - ":", - c="0.35", - lw=1.2, - label="truth phase", - ) - limits = _phase_ylim(pr.phi_t[pr.mask_t], truth_phase) - if limits is not None: - axp.set_ylim(*limits) - # Upper left: the pulse and its masked phase sit at the centre, the wings are - # empty, and a fixed corner cannot jump between live frames. - legend = _combined_legend(ax, axp, loc="upper left") - texts = legend.get_texts() - return TemporalArtists( - line_retr=line_retr, - line_tl=line_tl, - line_phase=line_phase, - ax_phase=axp, - legend=legend, - legend_retr=texts[0], - legend_tl=texts[1], - line_truth=line_truth, - line_truth_phase=line_truth_phase, - ) - - -def plot_temporal( - ax, - pr: ProcessedResult, - *, - halfwidth=None, - power_scale=None, - truth: TruthPulse | None = None, -): - """Temporal intensity/power (left) and phase (right twin axis); phase masked. - - When ``pr.peak_power`` is set (a measured pulse energy was supplied to - :func:`~croak.processing.process_result`), the intensity is shown as absolute - instantaneous power on an SI-prefixed axis; otherwise it stays normalised - ("a.u."). ``power_scale`` is an optional ``(scale, unit)`` pair (see - :func:`si_power`) so several panels can share one prefix; when ``None`` it is - derived from the larger of the retrieved/TL peak powers. - - ``truth`` (a :class:`~croak.processing.TruthPulse`: a synthetic generating - field, or a simulated scan's reference intensity) is overlaid as a thin black - line under the retrieved/TL curves, labelled with its own FWHM, and — when it - carries a temporal phase — as a dotted phase in the same display gauge as the - retrieved one (see :func:`croak.processing.normalize_temporal_phase`). - - Returns the twin phase axes. :func:`draw_temporal` is the same panel for the - registry, returning every artist (:class:`TemporalArtists`). - """ - return _temporal_panel( - ax, pr, halfwidth=halfwidth, power_scale=power_scale, truth=truth - ).ax_phase - - -def _truth_spectral_phase( - wavelength: NDArray[np.float64], truth: TruthPulse | None -) -> NDArray[np.float64] | None: - """Return the truth's spectral phase interpolated onto ``wavelength``, or None. - - The truth is stored on the simulation's own frequency grid, which is neither - the same spacing nor the same extent as the retrieval's; NaN outside it. - """ - tphi_src = getattr(truth, "phi_w", None) if truth is not None else None - tlam_src = getattr(truth, "lam", None) if truth is not None else None - if tphi_src is None or tlam_src is None: - return None - return np.interp( - wavelength, - np.asarray(tlam_src, dtype=float), - np.asarray(tphi_src, dtype=float), - left=np.nan, - right=np.nan, - ) - - -def _dispersion_text(pr: ProcessedResult) -> str: - """Return the GDD/TOD annotation of the spectrum panel.""" - return f"GDD: {pr.gdd_fs2:.1f} fs²\nTOD: {pr.tod_fs3:.1f} fs³" - - -def _edge_text(pr: ProcessedResult) -> tuple[str, str]: - """Return the edge-energy annotation and its colour (red once it matters). - - Energy parked in the unmeasured grid-edge bins is an artefact indicator; the - 1 % threshold sits comfortably above what a sound retrieval produces. - """ - color = "C3" if pr.edge_energy > EDGE_ENERGY_WARN else "0.35" - return f"Edge: {100 * pr.edge_energy:.2f} %", color - - -def _unit_peak(values: NDArray[np.float64]) -> NDArray[np.float64]: - """Scale ``values`` to a peak of one (NaNs ignored; all-zero left alone).""" - peak = float(np.nanmax(values)) if values.size else 0.0 - return values / peak if peak > 0 else values - - -def _spectral_curves(pr: ProcessedResult, axis: str): - """Return ``(x, retrieved, measured, xlabel)`` of the spectrum panel on ``axis``. - - Both spectra are unit-peak densities *in the chosen variable*. On the - wavelength axis they are ``pr.Ilam`` and ``pr.Iw_meas`` — both wavelength - densities (the measured one carries the ω² Jacobian despite its name); on the - frequency axis the retrieved spectrum is ``|E(ω)|²`` itself and the measured - one has that Jacobian divided out again, so a spectrum symmetric in ω plots - symmetric. ``x`` is per grid index on both axes, so the phase curves index it - with ``pr.mask_w`` unchanged. - """ - if axis == "wavelength": - return pr.wavelength / 1e-9, pr.Ilam, pr.Iw_meas, "Wavelength (nm)" - if axis != "frequency": - raise ValueError(f"axis must be one of {SPECTRAL_AXES}, got {axis!r}") - omega_abs = pr.omega + pr.omega0 - measured = None - if pr.Iw_meas is not None: - # A centred grid can cross ω = 0; that sample is undefined as a density. - with np.errstate(divide="ignore", invalid="ignore"): - jacobian = np.where(omega_abs != 0, 1.0 / omega_abs**2, np.nan) - measured = _unit_peak(pr.Iw_meas * jacobian) - return omega_abs / _TWOPI_PHZ, _unit_peak(pr.Iw), measured, "Frequency (PHz)" - - -def _spectral_xlim(lam_min, lam_max, axis: str) -> tuple[float, float] | None: - """Return the spectrum panel's x-limits for a wavelength window on ``axis``.""" - if not (lam_min and lam_max): - return None - if axis == "wavelength": - return lam_min / 1e-9, lam_max / 1e-9 - return float(wlfreq(lam_max)) / _TWOPI_PHZ, float(wlfreq(lam_min)) / _TWOPI_PHZ - - -def _truth_spectrum(truth: TruthPulse, axis: str): - """Return the truth's spectrum ``(x, unit-peak density)`` on ``axis``, or None.""" - if truth.lam is None or truth.Iw is None: - return None - if axis == "wavelength": - return truth.lam / 1e-9, truth.Iw - omega = wlfreq(truth.lam) - return omega / _TWOPI_PHZ, _unit_peak(np.asarray(truth.Iw) / omega**2) - - -def _spectral_panel( - ax, - pr: ProcessedResult, - *, - lam_min=None, - lam_max=None, - truth: TruthPulse | None = None, - axis: str = "frequency", -) -> SpectralArtists: - """Draw the spectrum panel into ``ax``; return its artists (see plot_spectral).""" - x, retrieved, measured, xlabel = _spectral_curves(pr, axis) - (line_retr,) = ax.plot(x, retrieved, c="C0", label="R") - line_meas = None - if measured is not None: - (line_meas,) = ax.plot(x, measured, c="C1", label="M") - line_truth = None - truth_curve = None if truth is None else _truth_spectrum(truth, axis) - if truth_curve is not None: - (line_truth,) = ax.plot(*truth_curve, c="k", lw=0.8, zorder=1, label="truth") - ax.set_xlabel(xlabel) - ax.set_ylabel("Power (a.u.)") - ax.set_ylim(0, 1.05) - ax.set_title("Spectrum") - xlim = _spectral_xlim(lam_min, lam_max, axis) - if xlim is not None: - ax.set_xlim(*xlim) - axp = ax.twinx() - (line_phase,) = axp.plot(x[pr.mask_w], pr.phi_w[pr.mask_w], c="C2") - # dashed polynomial fit of the spectral phase (GDD/TOD visualisation) - (line_phase_fit,) = axp.plot( - x[pr.mask_w], pr.phi_w_fit[pr.mask_w], "--", c="C4", alpha=0.7 - ) - axp.set_ylabel("Phase (rad)") - tphi = _truth_spectral_phase(pr.wavelength, truth) - line_truth_phase = None - if tphi is not None: - (line_truth_phase,) = axp.plot( - x[pr.mask_w], - tphi[pr.mask_w], - ":", - c="0.35", - lw=1.2, - label="truth phase", - ) - limits = _phase_ylim(pr.phi_w[pr.mask_w], None if tphi is None else tphi[pr.mask_w]) - if limits is not None: - axp.set_ylim(*limits) - text_dispersion = ax.text( - 0.05, 0.78, _dispersion_text(pr), transform=ax.transAxes, fontsize="small" - ) - edge, edge_color = _edge_text(pr) - text_edge = ax.text( - 0.05, 0.70, edge, transform=ax.transAxes, fontsize="small", color=edge_color - ) - # Upper right: the GDD/TOD and edge annotations occupy the upper left. - legend = _combined_legend(ax, axp, loc="upper right") - return SpectralArtists( - line_retr=line_retr, - line_phase=line_phase, - line_phase_fit=line_phase_fit, - ax_phase=axp, - legend=legend, - text_dispersion=text_dispersion, - text_edge=text_edge, - line_meas=line_meas, - line_truth=line_truth, - line_truth_phase=line_truth_phase, - ) - - -def plot_spectral( - ax, - pr: ProcessedResult, - *, - lam_min=None, - lam_max=None, - truth: TruthPulse | None = None, - axis: SpectralAxis = "frequency", -): - """Spectral intensity (left) and phase (right twin axis) against ``axis``. - - ``axis`` is ``"frequency"`` (PHz, the default: the space the retrieval works in, - where ``|E(ω)|²`` of a symmetric pulse is symmetric) or ``"wavelength"`` (nm, the - spectrometer's axis, carrying the 1/λ² density Jacobian). Both spectra are drawn as - unit-peak densities in the chosen variable; ``lam_min``/``lam_max`` (m) bound the - axis on either. The phase curves are the same on both. - - ``truth``, when it carries a spectrum, is overlaid as a thin black line under - the retrieved curve; when it carries a known spectral phase, that phase is - overlaid on the phase axis. It is drawn in the SAME gauge as the retrieved - phase (unwrapped, de-tilted, zeroed at the spectral peak — see - :func:`croak.processing.normalize_spectral_phase`), because spectral phase - is defined only up to a constant and a linear term; a truth pinned any other - way would show a tilt or offset that is gauge, not error. - - Returns the twin phase axes. :func:`draw_spectral` is the same panel for the - registry, returning every artist (:class:`SpectralArtists`). - """ - return _spectral_panel( - ax, pr, lam_min=lam_min, lam_max=lam_max, truth=truth, axis=axis - ).ax_phase - - -def plot_convergence( - ax, errors: ArrayLike, *, boundaries: Sequence[int] = () -) -> ConvergenceArtists: - """Plot the FROG error vs iteration on a log y-axis. - - ``boundaries`` are indices into ``errors`` where a multi-stage solver handed - over to its next stage (:attr:`~croak.result.RetrievalResult.stage_boundaries`); - each is drawn as a dashed rule. Marking them matters because the stages need - not count the same unit of work — ``warm-lbfgs`` splices COPRA iterations onto - L-BFGS function evaluations — so an unmarked kink would read as convergence - behaviour rather than as the change of algorithm it is. - - The error curve is created even for an empty ``errors`` (a result restored - from file carries no history), so a live view can grow it in place. - """ - errors = np.asarray(errors, dtype=float) - (line,) = ax.semilogy(np.arange(1, errors.size + 1), errors, c="C0") - rules: list[Line2D] = [] - for i, edge in enumerate(boundaries): - if 0 < edge < errors.size: - rules.append( - ax.axvline( - edge + 0.5, - c="0.5", - ls="--", - lw=0.8, - label="stage handover" if i == 0 else None, - ) - ) - legend = None - if rules: # upper right: a decreasing curve leaves that corner empty - legend = ax.legend( - loc="upper right", handlelength=1.2, frameon=False, fontsize="small" - ) - ax.set_xlabel("Iteration") - ax.set_ylabel("Error") - ax.set_title("Convergence") - return ConvergenceArtists(line=line, boundaries=tuple(rules), legend=legend) - - -def plot_marginal( - ax, x, retrieved, measured=None, *, xlabel="", scale=1.0, halfwidth=None -) -> MarginalArtists: - """Plot a (retrieved vs measured) trace marginal.""" - (line_retr,) = ax.plot(np.asarray(x) / scale, retrieved, c="C0", label="R") - line_meas = None - if measured is not None: - (line_meas,) = ax.plot(np.asarray(x) / scale, measured, c="C1", label="M") - ax.set_xlabel(xlabel) - ax.set_ylabel("Marginal (a.u.)") - ax.set_ylim(0, 1.05) - if halfwidth is not None: - ax.set_xlim(-halfwidth / scale, halfwidth / scale) - legend = ax.legend( - loc="upper left", handlelength=1.0, frameon=False, fontsize="small" - ) - return MarginalArtists(line_retr=line_retr, legend=legend, line_meas=line_meas) - - -def plot_spectrogram( - ax, - t_centers, - wavelength, - S, - *, - lam_min=None, - lam_max=None, - halfwidth=None, - cmap=None, -): - """Plot a Gabor spectrogram (time fs × wavelength nm).""" - if cmap is None: - cmap = cmap_white("viridis") - m = ax.pcolormesh( - t_centers / 1e-15, - wavelength / 1e-9, - S, - cmap=cmap, - rasterized=True, - shading="auto", - ) - ax.set_xlabel("Time (fs)") - ax.set_ylabel("Wavelength (nm)") - ax.set_title("Spectrogram") - if lam_min and lam_max: - ax.set_ylim(lam_min / 1e-9, lam_max / 1e-9) - if halfwidth is not None: - ax.set_xlim(-halfwidth / 1e-15, halfwidth / 1e-15) - return m - - -# --------------------------------------------------------------------------- -# Retrieval panels: the data bundle, the twelve panels, pages and the overview -# --------------------------------------------------------------------------- -# The twelve-panel retrieval summary is built from independent panels so the GUI -# can arrange them as three 2×2 pages (Traces / Pulse / Diagnostics) while scripts -# and the CLI keep the 3×4 overview. Every panel is a pure function -# ``draw_(ax, data)`` over one immutable RetrievalPlotData, computed once per -# result. Colorbars that span two panels are a page-level concern (ColorbarSpec), -# drawn after all panels of a figure exist. - - -@dataclass(frozen=True, eq=False) -class RetrievalPlotData: - """Everything the retrieval panels share, computed once from one result. - - Build it with :func:`retrieval_plot_data`. The bundle is immutable and carries - no figure state, so one instance can feed several figures (the GUI's pages and - their pop-outs) and be redrawn at any size. - - Attributes - ---------- - result : RetrievalResult - The retrieval; ``result.trace`` is the retrieved trace. - processed : ProcessedResult - Display-ready quantities of ``result`` - (:func:`croak.processing.process_result`). - measured, retrieved : ndarray - Measured and retrieved traces, shape ``(Nω, Nτ)``. - omega0_trace : float - Centre angular frequency of the *signal* (twice ``omega0`` for SHG), so - the trace panels' frequency axis reads at the signal frequency. - halfwidth : float - Half the delay span (s); bounds the delay axes. - lam_min, lam_max : float - Wavelength bounds (m) of the spectrum and spectrogram panels. - flim : tuple of float or None - Optional ``(f_min, f_max)`` (Hz) bounds of the trace/residual frequency axes. - tracedb : float - Dynamic range (dB) of the logarithmic trace panels. - cmap_pos : Colormap - White-based positive colormap shared by the trace and spectrogram panels. - lin_vmax : float - Common scale of the linear trace pair, so their colorbar is meaningful. - truth : TruthPulse or None - Known pulse to overlay (synthetic and simulated data). - power_scale : tuple of (float, str) or None - SI ``(scale, unit)`` for absolute power when a pulse energy was given. - spectral_axis : str - ``"frequency"`` or ``"wavelength"``: the spectrum panel's abscissa. - """ - - result: RetrievalResult - processed: ProcessedResult - measured: NDArray[np.float64] - retrieved: NDArray[np.float64] - omega0_trace: float - halfwidth: float - lam_min: float - lam_max: float - flim: tuple[float, float] | None - tracedb: float - cmap_pos: Colormap - lin_vmax: float - truth: TruthPulse | None - power_scale: tuple[float, str] | None - spectral_axis: str - - @functools.cached_property - def spectrogram( - self, - ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: - """``(t_centres, wavelength, S)`` of :func:`croak.processing.spectrogram`. - - The Gabor transform is the costliest thing the panels compute (hundreds - of FFTs), so it runs only when the spectrogram panel is first drawn and is - then cached on the bundle for every later draw of it. - """ - return spectrogram(self.result, lam_min=self.lam_min, lam_max=self.lam_max) - - -def retrieval_plot_data( - result: RetrievalResult, - *, - measured: ArrayLike | None = None, - Iomega_meas: ArrayLike | None = None, - lam_min: float | None = None, - lam_max: float | None = None, - flim: tuple[float, float] | None = None, - tracedb: float = 30.0, - cmap: str = "viridis", - truth: TruthPulse | None = None, - energy: float | None = None, - processed: ProcessedResult | None = None, - spectral_axis: str = "frequency", -) -> RetrievalPlotData: - """Bundle a retrieval's plot inputs for the panels (see :func:`plot_retrieval`). - - The arguments are those of :func:`plot_retrieval`, which is this bundle drawn - as the overview; see there for their meaning. ``processed`` is an - already-computed :class:`~croak.processing.ProcessedResult` **for this same - result** (the GUI computes it once for its read-out); it must have been built - with the same ``measured``/``Iomega_meas``/``energy``. - - Raises - ------ - ValueError - If ``result`` carries no trace, ``processed`` belongs to another result, - or ``spectral_axis`` is not one of :data:`SPECTRAL_AXES`. - """ - omega = result.omega - omega0 = result.omega0 - if spectral_axis not in SPECTRAL_AXES: - raise ValueError( - f"spectral_axis must be one of {SPECTRAL_AXES}, got {spectral_axis!r}" - ) - if result.trace is None: - raise ValueError("plot_retrieval requires a result carrying a simulated trace") - retrieved = np.asarray(result.trace, dtype=float) - measured_arr = retrieved if measured is None else np.asarray(measured, dtype=float) - - if processed is None: - pr = process_result( - result, measured=measured_arr, Iomega_meas=Iomega_meas, energy=energy - ) - elif processed.result is not result: - # Cheap exact guard: mixing a ProcessedResult from another result would - # draw the trace panels from one retrieval and the pulse/spectrum panels - # from another, which is very hard to spot by eye. - raise ValueError("processed must be the ProcessedResult of this result") - else: - pr = processed - if lam_min is None or lam_max is None: - lams = wlfreq(omega + omega0) - lam_min, lam_max = float(lams.min()), float(lams.max()) - delays = result.delays - # When a pulse energy was supplied, derive one SI power prefix for the - # temporal panel (and the truth overlay) so both share a consistent axis. - power_scale = None - if pr.peak_power is not None: - power_scale = si_power(max(pr.peak_power, pr.peak_power_tl or 0.0)) - return RetrievalPlotData( - result=result, - processed=pr, - measured=measured_arr, - retrieved=retrieved, - omega0_trace=omega0 * _interaction_scale(result.interaction), - halfwidth=float((delays.max() - delays.min()) / 2), - lam_min=float(lam_min), - lam_max=float(lam_max), - flim=flim, - tracedb=tracedb, - # Positive values on the base map with white at the low end, so zero is - # white in *both* maps and meets the white→red negative map seamlessly. - cmap_pos=cmap_white(cmap), - # The linear pair shares one scale so its colorbar is meaningful. - lin_vmax=max( - float(np.nanmax(np.abs(measured_arr))), float(np.nanmax(retrieved)) - ), - truth=truth, - power_scale=power_scale, - spectral_axis=spectral_axis, - ) - - -def _trace_label(data: RetrievalPlotData) -> str: - """Return the retrieved-linear panel's annotation: FROG error and shape.""" - return ( - f"{data.result.error * 100:.2f}%\n" - f"{data.retrieved.shape[0]}×{data.retrieved.shape[1]}" - ) - - -def _draw_trace_image( - ax, data: RetrievalPlotData, C: NDArray[np.float64], *, db: bool, title: str -) -> tuple[QuadMesh, QuadMesh | None]: - """Draw one trace image (delay × signal frequency) and return its meshes.""" - mpos, mneg = signed_pcolormesh( - ax, - data.result.delays / 1e-15, - (data.result.omega + data.omega0_trace) / _TWOPI_PHZ, - C, - db=db, - tracedb=data.tracedb, - cmap_pos=data.cmap_pos, - vmax=None if db else data.lin_vmax, - ) - ax.set_xlabel("Delay (fs)") - ax.set_ylabel("Frequency (PHz)") - ax.set_title(title) - ax.set_xlim(-data.halfwidth / 1e-15, data.halfwidth / 1e-15) - if data.flim is not None: - ax.set_ylim(data.flim[0] / 1e15, data.flim[1] / 1e15) - return mpos, mneg - - -def draw_measured_log(ax, data: RetrievalPlotData) -> TraceArtists: - """Draw the measured trace, logarithmic (``tracedb`` dynamic range).""" - mpos, mneg = _draw_trace_image( - ax, data, data.measured, db=True, title="Measured (log)" - ) - return TraceArtists(mesh_pos=mpos, mesh_neg=mneg) - - -def draw_retrieved_log(ax, data: RetrievalPlotData) -> TraceArtists: - """Draw the retrieved trace, logarithmic.""" - mpos, mneg = _draw_trace_image( - ax, data, data.retrieved, db=True, title="Retrieved (log)" - ) - return TraceArtists(mesh_pos=mpos, mesh_neg=mneg) - - -def draw_measured_lin(ax, data: RetrievalPlotData) -> TraceArtists: - """Draw the measured trace, linear, on the scale shared with the retrieved.""" - mpos, mneg = _draw_trace_image( - ax, data, data.measured, db=False, title="Measured (lin)" - ) - return TraceArtists(mesh_pos=mpos, mesh_neg=mneg) - - -def draw_retrieved_lin(ax, data: RetrievalPlotData) -> TraceArtists: - """Draw the retrieved trace, linear, annotated with FROG error and shape.""" - mpos, mneg = _draw_trace_image( - ax, data, data.retrieved, db=False, title="Retrieved (lin)" - ) - label = ax.text( - 0.04, - 0.96, - _trace_label(data), - transform=ax.transAxes, - ha="left", - va="top", - color="black", - fontweight="bold", - fontsize="small", - ) - return TraceArtists(mesh_pos=mpos, mesh_neg=mneg, label=label) - - -def draw_temporal(ax, data: RetrievalPlotData) -> TemporalArtists: - """Draw the retrieved pulse: intensity/power, TL pulse, phase, truth overlay.""" - return _temporal_panel( - ax, - data.processed, - halfwidth=data.halfwidth, - power_scale=data.power_scale, - truth=data.truth, - ) - - -def draw_spectral(ax, data: RetrievalPlotData) -> SpectralArtists: - """Draw the spectrum: retrieved, measured, phase and fit, GDD/TOD, truth.""" - return _spectral_panel( - ax, - data.processed, - lam_min=data.lam_min, - lam_max=data.lam_max, - truth=data.truth, - axis=data.spectral_axis, - ) - - -def draw_convergence(ax, data: RetrievalPlotData) -> ConvergenceArtists: - """Draw the FROG error against iteration, with the solver's stage handovers.""" - return plot_convergence( - ax, data.processed.errors, boundaries=data.result.stage_boundaries - ) - - -def draw_residual(ax, data: RetrievalPlotData) -> ResidualArtists: - """Draw the measured − retrieved residual; leave it empty if there is none.""" - pr = data.processed - if pr.residual is None: - return ResidualArtists(mesh=None) - mesh = plot_residual( - ax, - data.result.omega, - data.result.delays, - pr.residual, - data.result.omega0, - halfwidth=data.halfwidth, - flim=data.flim, - ) - return ResidualArtists(mesh=mesh) - - -def _filter_factor(result: RetrievalResult) -> NDArray[np.float64]: - """Return the per-frequency Rω scale factor, peak-normalised, else flat.""" - mu = np.atleast_1d(np.asarray(result.mu, dtype=float)) - if mu.size == result.omega.size: - return mu / mu.max() - return np.ones_like(result.omega) - - -def draw_spectral_filter(ax, data: RetrievalPlotData) -> LineArtists: - """Draw the per-frequency scale factor (Rω filter) against wavelength.""" - # A centred retrieval grid can cross zero frequency, where the wavelength - # axis has a pole: harmless for the plot, so the divide warning is silenced. - with np.errstate(divide="ignore", invalid="ignore"): - lam_nm = wlfreq(data.result.omega + data.result.omega0) / 1e-9 - (line,) = ax.plot(lam_nm, _filter_factor(data.result), c="C2") - ax.set_xlabel("Wavelength (nm)") - ax.set_ylabel("Filter factor") - ax.set_title("Spectral filter") - ax.set_xlim(data.lam_min / 1e-9, data.lam_max / 1e-9) - return LineArtists(line=line) - - -def draw_freq_marginal(ax, data: RetrievalPlotData) -> MarginalArtists: - """Draw the frequency marginal of the retrieved and measured traces.""" - pr = data.processed - artists = plot_marginal( - ax, - data.result.omega + data.result.omega0, - pr.omega_marg_retr, - pr.omega_marg_meas, - xlabel="Frequency (PHz)", - scale=_TWOPI_PHZ, - ) - ax.set_title("Frequency marginal") - return artists - - -def draw_delay_marginal(ax, data: RetrievalPlotData) -> MarginalArtists: - """Draw the delay marginal of the retrieved and measured traces.""" - pr = data.processed - artists = plot_marginal( - ax, - data.result.delays, - pr.tau_marg_retr, - pr.tau_marg_meas, - xlabel="Delay (fs)", - scale=1e-15, - halfwidth=data.halfwidth, - ) - ax.set_title("Delay marginal") - return artists - - -def draw_spectrogram(ax, data: RetrievalPlotData) -> SpectrogramArtists: - """Draw the Gabor spectrogram of the retrieved pulse (computed once, cached).""" - tc, lam, S = data.spectrogram - mesh = plot_spectrogram( - ax, - tc, - lam, - S, - lam_min=data.lam_min, - lam_max=data.lam_max, - halfwidth=data.halfwidth, - # white at zero, like the trace panels and the dispersion stage's own - # spectrogram — this panel was the only one left on plain viridis. - cmap=data.cmap_pos, - ) - return SpectrogramArtists(mesh=mesh) - - -# --------------------------------------------------------------------------- -# In-place updates -# --------------------------------------------------------------------------- -# Each update_(artists, data) pushes a new result onto the artists its -# draw_ twin created: set_data / set_array / set_text and explicit limits, -# never new artists. The invariant is update(draw(d1), d2) ≡ draw(d2), which the -# tests check panel by panel. Whether an update is possible at all — same grid -# shapes, same optional artists — is decided by _update_signature. - - -def _axes_of(artist) -> Axes: - """Return the axes an artist is drawn on (an updated artist always has one).""" - ax = artist.axes - if ax is None: - raise RuntimeError("the artist is not on any axes") - return ax - - -def _update_trace(artists: TraceArtists, data: RetrievalPlotData, C, *, db: bool): - mag, neg, vmin, vhi = _signed_magnitude( - C, db=db, tracedb=data.tracedb, vmax=None if db else data.lin_vmax - ) - artists.mesh_pos.set_array(np.ma.masked_where(neg, mag)) - artists.mesh_pos.set_clim(vmin, vhi) # a shared colorbar follows the norm - if artists.mesh_neg is not None: - artists.mesh_neg.set_array(np.ma.masked_where(~neg, mag)) - artists.mesh_neg.set_clim(vmin, vhi) - - -def update_measured_log(artists: TraceArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_measured_log`'s artists in place.""" - _update_trace(artists, data, data.measured, db=True) - - -def update_retrieved_log(artists: TraceArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_retrieved_log`'s artists in place.""" - _update_trace(artists, data, data.retrieved, db=True) - - -def update_measured_lin(artists: TraceArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_measured_lin`'s artists in place.""" - _update_trace(artists, data, data.measured, db=False) - - -def update_retrieved_lin(artists: TraceArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_retrieved_lin`'s artists in place.""" - _update_trace(artists, data, data.retrieved, db=False) - if artists.label is not None: - artists.label.set_text(_trace_label(data)) - - -def update_temporal(artists: TemporalArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_temporal`'s artists in place.""" - pr = data.processed - t = pr.t_over / 1e-15 - retr, tl, r_label, tl_label, ylabel, power_scale = _temporal_curves( - pr, data.power_scale - ) - artists.line_retr.set_data(t, retr) - artists.line_tl.set_data(pr.t_tl / 1e-15, tl) - artists.legend_retr.set_text(r_label) - artists.legend_tl.set_text(tl_label) - ax = _axes_of(artists.line_retr) - ax.set_ylabel(ylabel) - truth_curve = None - if artists.line_truth is not None and data.truth is not None: - truth_curve = _truth_intensity(data.truth, pr.energy, power_scale) - artists.line_truth.set_ydata(truth_curve) - ax.set_ylim(*_intensity_ylim(retr, tl, truth_curve)) - artists.line_phase.set_data(t[pr.mask_t], pr.phi_t[pr.mask_t]) - truth_phase = None # static within a run: the truth does not change - if artists.line_truth_phase is not None: - truth_phase = np.asarray(artists.line_truth_phase.get_ydata(), dtype=float) - limits = _phase_ylim(pr.phi_t[pr.mask_t], truth_phase) - if limits is not None: - artists.ax_phase.set_ylim(*limits) - - -def update_spectral(artists: SpectralArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_spectral`'s artists in place.""" - pr = data.processed - x, retrieved, measured, _xlabel = _spectral_curves(pr, data.spectral_axis) - artists.line_retr.set_data(x, retrieved) - if artists.line_meas is not None and measured is not None: - artists.line_meas.set_data(x, measured) - artists.line_phase.set_data(x[pr.mask_w], pr.phi_w[pr.mask_w]) - artists.line_phase_fit.set_data(x[pr.mask_w], pr.phi_w_fit[pr.mask_w]) - # The truth phase is static but its mask (where the spectrum is bright) moves. - tphi = _truth_spectral_phase(pr.wavelength, data.truth) - if artists.line_truth_phase is not None and tphi is not None: - artists.line_truth_phase.set_data(x[pr.mask_w], tphi[pr.mask_w]) - limits = _phase_ylim(pr.phi_w[pr.mask_w], None if tphi is None else tphi[pr.mask_w]) - if limits is not None: - artists.ax_phase.set_ylim(*limits) - artists.text_dispersion.set_text(_dispersion_text(pr)) - edge, edge_color = _edge_text(pr) - artists.text_edge.set_text(edge) - artists.text_edge.set_color(edge_color) - - -def update_convergence_curve(artists: ConvergenceArtists, errors: ArrayLike) -> None: - """Grow the error curve of :func:`plot_convergence` in place and rescale.""" - errors = np.asarray(errors, dtype=float) - artists.line.set_data(np.arange(1, errors.size + 1), errors) - ax = _axes_of(artists.line) - ax.relim() # autoscaling is still on here: plot_convergence sets no limits - ax.autoscale_view() - - -def update_convergence(artists: ConvergenceArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_convergence`'s artists in place.""" - update_convergence_curve(artists, data.processed.errors) - - -def update_residual(artists: ResidualArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_residual`'s artists in place.""" - resid = data.processed.residual - if artists.mesh is None or resid is None: - return - mr = float(np.max(np.abs(resid))) or 1.0 - artists.mesh.set_array(resid) - artists.mesh.set_clim(-mr, mr) - - -def update_spectral_filter(artists: LineArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_spectral_filter`'s artists in place (x is the fixed grid).""" - artists.line.set_ydata(_filter_factor(data.result)) - - -def update_freq_marginal(artists: MarginalArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_freq_marginal`'s artists in place.""" - pr = data.processed - artists.line_retr.set_ydata(pr.omega_marg_retr) - if artists.line_meas is not None and pr.omega_marg_meas is not None: - artists.line_meas.set_ydata(pr.omega_marg_meas) - - -def update_delay_marginal(artists: MarginalArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_delay_marginal`'s artists in place.""" - pr = data.processed - artists.line_retr.set_ydata(pr.tau_marg_retr) - if artists.line_meas is not None and pr.tau_marg_meas is not None: - artists.line_meas.set_ydata(pr.tau_marg_meas) - - -def update_spectrogram(artists: SpectrogramArtists, data: RetrievalPlotData) -> None: - """Update :func:`draw_spectrogram`'s artists in place. - - Raises - ------ - ValueError - If the spectrogram's shape differs from the drawn one (a different grid): - the panel has to be redrawn, not updated. - """ - _tc, _lam, S = data.spectrogram - array = artists.mesh.get_array() - drawn = () if array is None else tuple(np.shape(array)) - if drawn != S.shape: - raise ValueError(f"spectrogram shape changed from {drawn} to {S.shape}") - artists.mesh.set_array(S) - artists.mesh.autoscale() # what the first draw did implicitly - - -def _update_signature(data: RetrievalPlotData) -> tuple: - """Everything that decides which artists exist and the mesh geometry. - - Two bundles with equal signatures differ only in values, so a panel drawn for - one can be updated in place for the other. Components: the trace, grid and - oversampled-axis shapes; whether the traces hold negatives (the second - colormap); which optional curves exist (residual, measured spectrum and - marginals, absolute power, the truth and its phases); how many stage-handover - rules are visible; and the fixed axis bounds. - """ - pr = data.processed - result = data.result - truth = data.truth - n_rules = sum(1 for edge in result.stage_boundaries if 0 < edge < len(pr.errors)) - return ( - data.retrieved.shape, - data.measured.shape, - result.omega.size, - result.delays.size, - pr.t_over.size, - pr.t_tl.size, - pr.wavelength.size, - bool((data.measured < 0).any()), - bool((data.retrieved < 0).any()), - pr.residual is not None, - pr.Iw_meas is not None, - pr.omega_marg_meas is not None, - pr.tau_marg_meas is not None, - pr.peak_power is not None, - truth is not None, - truth is not None and truth.phi_t is not None, - truth is not None and truth.lam is not None and truth.Iw is not None, - truth is not None and truth.lam is not None and truth.phi_w is not None, - n_rules, - data.lam_min, - data.lam_max, - data.flim, - data.halfwidth, - data.tracedb, - data.spectral_axis, - ) - - -type PanelDraw = Callable[[Axes, RetrievalPlotData], PanelArtists] -# Any: each update_ takes its own artists dataclass, and a registry entry -# typed with the union would reject them (parameters are contravariant). The draw -# side keeps the precise union because a return type is covariant. -type PanelUpdate = Callable[[Any, RetrievalPlotData], None] - - -@dataclass(frozen=True) -class PanelSpec: - """One retrieval panel: registry key, axes title, draw and in-place update.""" - - key: str - title: str - draw: PanelDraw - update: PanelUpdate - - -@dataclass(frozen=True) -class PageSpec: - """A figure's worth of panels laid out as a grid of registry keys. - - Attributes - ---------- - key, title : str - Identifier and human-readable name (the GUI's tab label). - mosaic : tuple of tuple of str - Rows of panel keys, as - :external+matplotlib:meth:`~matplotlib.figure.Figure.subplot_mosaic` - takes them. - """ - - key: str - title: str - mosaic: tuple[tuple[str, ...], ...] - - @property - def keys(self) -> tuple[str, ...]: - """The page's panel keys in reading order.""" - return tuple(key for row in self.mosaic for key in row) - - -@dataclass(frozen=True) -class ColorbarSpec: - """A colorbar shared by the listed panels (drawn only when all are present). - - A positive colorbar is always drawn; a second, ``neg_label``-ed one appears - when any of the panels holds negative samples (see :func:`signed_pcolormesh`). - """ - - panels: tuple[str, ...] - shrink: float = 0.6 - neg_label: str = "neg |·|" - - -#: The twelve retrieval panels, keyed, in the overview's reading order. -RETRIEVAL_PANELS: Mapping[str, PanelSpec] = { - spec.key: spec - for spec in ( - PanelSpec( - "measured_log", "Measured (log)", draw_measured_log, update_measured_log - ), - PanelSpec( - "retrieved_log", "Retrieved (log)", draw_retrieved_log, update_retrieved_log - ), - PanelSpec("temporal", "Retrieved pulse", draw_temporal, update_temporal), - PanelSpec( - "delay_marginal", - "Delay marginal", - draw_delay_marginal, - update_delay_marginal, - ), - PanelSpec( - "measured_lin", "Measured (lin)", draw_measured_lin, update_measured_lin - ), - PanelSpec( - "retrieved_lin", "Retrieved (lin)", draw_retrieved_lin, update_retrieved_lin - ), - PanelSpec("spectral", "Spectrum", draw_spectral, update_spectral), - PanelSpec( - "freq_marginal", - "Frequency marginal", - draw_freq_marginal, - update_freq_marginal, - ), - PanelSpec("convergence", "Convergence", draw_convergence, update_convergence), - PanelSpec("residual", "Residuals", draw_residual, update_residual), - PanelSpec( - "spectral_filter", - "Spectral filter", - draw_spectral_filter, - update_spectral_filter, - ), - PanelSpec("spectrogram", "Spectrogram", draw_spectrogram, update_spectrogram), - ) -} - -#: Colorbars: one per trace pair (they share a scale) and one for the residual. -RETRIEVAL_COLORBARS: tuple[ColorbarSpec, ...] = ( - ColorbarSpec(("measured_log", "retrieved_log")), - ColorbarSpec(("measured_lin", "retrieved_lin")), - ColorbarSpec(("residual",)), -) - -#: The three 2×2 pages the GUI shows as tabs. -RETRIEVAL_PAGES: Mapping[str, PageSpec] = { - page.key: page - for page in ( - PageSpec( - "traces", - "Traces", - (("measured_log", "retrieved_log"), ("measured_lin", "retrieved_lin")), - ), - # Each marginal sits under the domain it belongs to: delay under the - # time-domain pulse, frequency under the spectrum. - PageSpec( - "pulse", - "Pulse", - (("temporal", "spectral"), ("delay_marginal", "freq_marginal")), - ), - PageSpec( - "diagnostics", - "Diagnostics", - (("convergence", "residual"), ("spectral_filter", "spectrogram")), - ), - ) -} - -#: The 3×4 overview :func:`plot_retrieval` draws: traces left, pulse right (the -#: delay marginal beside the time-domain pulse, the frequency marginal beside the -#: spectrum), diagnostics along the bottom. -RETRIEVAL_OVERVIEW = PageSpec( - "overview", - "Retrieval overview", - ( - ("measured_log", "retrieved_log", "temporal", "delay_marginal"), - ("measured_lin", "retrieved_lin", "spectral", "freq_marginal"), - ("convergence", "residual", "spectral_filter", "spectrogram"), - ), -) - - -def _mappables(artists: PanelArtists) -> tuple[QuadMesh | None, QuadMesh | None]: - """Return the ``(positive, negative)`` images a colorbar can attach to.""" - if isinstance(artists, TraceArtists): - return artists.mesh_pos, artists.mesh_neg - if isinstance(artists, ResidualArtists): - return artists.mesh, None - return None, None - - -def _draw_colorbars( - fig, axd: Mapping[str, Axes], artists: Mapping[str, PanelArtists] -) -> None: - """Add every :data:`RETRIEVAL_COLORBARS` entry whose panels are all in ``axd``.""" - for spec in RETRIEVAL_COLORBARS: - if not all(key in axd for key in spec.panels): - continue - axes = [axd[key] for key in spec.panels] - pairs = [_mappables(artists[key]) for key in spec.panels] - pos = next((p for p, _ in pairs if p is not None), None) - if pos is None: - continue - # Both panels of a pair share vmin/vmax and cmap, so either can seed the bar. - fig.colorbar(pos, ax=axes, shrink=spec.shrink) - neg = next((n for _, n in pairs if n is not None), None) - if neg is not None: - fig.colorbar(neg, ax=axes, shrink=spec.shrink, label=spec.neg_label) - - -def _draw_panels( - fig, page: PageSpec, data: RetrievalPlotData -) -> tuple[dict[str, Axes], dict[str, PanelArtists]]: - """Lay ``page``'s mosaic out in ``fig``, draw every panel and its colourbars.""" - axd = fig.subplot_mosaic([list(row) for row in page.mosaic]) - artists = {key: RETRIEVAL_PANELS[key].draw(ax, data) for key, ax in axd.items()} - _draw_colorbars(fig, axd, artists) - return axd, artists - - -class RetrievalPageView: - """Maintain a page of retrieval plots for repeated GUI updates. - - The live preview hands the Retrieve stage a new result a few times a second. - Clearing a figure and rebuilding a page's axes, colourbars and legends for - each one costs a full constrained-layout solve and lets legends jump between - frames; this view draws the page once and then pushes later results onto the - existing artists when :meth:`can_update` accepts the new data, as - :class:`FrogFilterView` does for the preprocessing figure. It redraws when - the grid, optional curves or panel structure change. - - Parameters - ---------- - page : PageSpec - Panels and their layout. - - Examples - -------- - >>> view = RetrievalPageView(RETRIEVAL_PAGES["pulse"]) # doctest: +SKIP - >>> view.draw(fig, data1) # doctest: +SKIP - >>> if view.can_update(data2): # doctest: +SKIP - ... view.update(data2) - ... else: - ... view.draw(fig, data2) - """ - - def __init__(self, page: PageSpec): - self.page = page - self.fig: Figure | None = None - self.axd: dict[str, Axes] = {} - self.artists: dict[str, PanelArtists] = {} - self._signature: tuple | None = None - - @property - def attached(self) -> bool: - """Whether the view has drawn into a figure.""" - return self.fig is not None - - def draw(self, fig: Figure, data: RetrievalPlotData) -> None: - """Clear ``fig`` and draw the page afresh for ``data``. - - Constrained layout is solved on the first real draw and then frozen (the - engine is swapped for matplotlib's placeholder), so later in-place updates - neither re-solve it nor nudge the panels as tick labels change width. - """ - _prepare_fig(fig, None) - self.fig = fig - self.axd, self.artists = _draw_panels(fig, self.page, data) - self._signature = _update_signature(data) - canvas = fig.canvas - - def freeze(_event) -> None: - canvas.mpl_disconnect(cid) - if self.fig is fig: - fig.set_layout_engine("none") - - cid = canvas.mpl_connect("draw_event", freeze) - - def can_update(self, data: RetrievalPlotData) -> bool: - """Whether ``data`` differs from the drawn result in values only.""" - return self.attached and _update_signature(data) == self._signature - - def update(self, data: RetrievalPlotData) -> None: - """Push ``data`` onto the drawn artists. - - Raises - ------ - RuntimeError - If nothing has been drawn yet. - ValueError - If :meth:`can_update` is false (different grid or optional artists); - :meth:`draw` it instead. - """ - if not self.attached: - raise RuntimeError("draw(fig, data) before update(data)") - if not self.can_update(data): - raise ValueError("the result's shape or contents changed: draw() it") - for key, artists in self.artists.items(): - RETRIEVAL_PANELS[key].update(artists, data) - - def reset(self) -> None: - """Forget the figure (someone else cleared it); the next call must draw.""" - self.fig = None - self.axd = {} - self.artists = {} - self._signature = None - - -def plot_retrieval_page( - data: RetrievalPlotData, - page: PageSpec, - *, - figsize: tuple[float, float] = (8.5, 6.3), - fig: Figure | None = None, -) -> Figure: - """Draw one page of retrieval panels (a :data:`RETRIEVAL_PAGES` entry). - - The default ``figsize`` gives a 2×2 page the same per-panel size as the - overview's default 17.0×9.45 in. - - Parameters - ---------- - data : RetrievalPlotData - From :func:`retrieval_plot_data`. - page : PageSpec - Which panels, in which grid; :data:`RETRIEVAL_OVERVIEW` draws all twelve. - figsize : tuple of float - Size (in) of a new figure; ignored when ``fig`` is given. - fig : Figure, optional - Existing figure to clear and draw into (a GUI canvas). - - Examples - -------- - >>> data = retrieval_plot_data(result, measured=trace) # doctest: +SKIP - >>> fig = plot_retrieval_page(data, RETRIEVAL_PAGES["pulse"]) # doctest: +SKIP - """ - fig = _prepare_fig(fig, figsize) - _draw_panels(fig, page, data) - return fig - - -def plot_retrieval( - result: RetrievalResult, - *, - measured: ArrayLike | None = None, - Iomega_meas: ArrayLike | None = None, - lam_min: float | None = None, - lam_max: float | None = None, - flim: tuple[float, float] | None = None, - tracedb: float = 30.0, - cmap: str = "viridis", - truth: TruthPulse | None = None, - energy: float | None = None, - figsize=(17.0, 9.45), - fig: Figure | None = None, - processed: ProcessedResult | None = None, - spectral_axis: SpectralAxis = "frequency", -) -> Figure: - """12-panel retrieval summary: the standard at-a-glance quality check. - - The panels are :data:`RETRIEVAL_PANELS` in the :data:`RETRIEVAL_OVERVIEW` - layout — measured/retrieved traces (log and linear) on the left, the pulse, - spectrum and marginals on the right, convergence, residual, spectral filter - and spectrogram along the bottom. :func:`plot_retrieval_page` draws a subset. - - ``lam_min``/``lam_max`` (m) bound the **wavelength** axes — the spectrum and - spectrogram panels. ``flim`` bounds the **frequency** axis of the trace and - residual panels, as ``(f_min, f_max)`` in Hz; the default shows the whole - retrieval grid. The two are separate because the trace sits at the *signal* - frequency, which for SHG is twice the pulse's. Reach for ``flim`` when the - retrieval grid is much wider than the signal — typical when retrieving - directly on a synthesis grid rather than on one cropped by - :func:`~croak.preprocess.load_and_clean` — where the trace would otherwise be - a thin stripe in a mostly empty axis. - - ``energy`` is an optional measured pulse energy (J); when given, the temporal - panel is shown in absolute power (see :func:`~croak.processing.process_result`). - - ``processed`` is an optional, already-computed - :class:`~croak.processing.ProcessedResult` **for this same result**: pass it when - the caller needs the processed quantities too (the GUI shows them in a table - beside the figure) so they are not derived twice. It must have been built with - the same ``measured``/``Iomega_meas``/``energy``, which are then used only for - the trace panels; a mismatched one raises :class:`ValueError`. - - ``spectral_axis`` draws the spectrum panel against ``"frequency"`` (PHz, the - default) or ``"wavelength"`` (nm); see :func:`plot_spectral`. - - Raises - ------ - ValueError - If ``result`` carries no trace, or ``processed`` belongs to another result. - """ - data = retrieval_plot_data( - result, - measured=measured, - Iomega_meas=Iomega_meas, - lam_min=lam_min, - lam_max=lam_max, - flim=flim, - tracedb=tracedb, - cmap=cmap, - truth=truth, - energy=energy, - processed=processed, - spectral_axis=spectral_axis, - ) - return plot_retrieval_page(data, RETRIEVAL_OVERVIEW, figsize=figsize, fig=fig) - - -def filter_view_vmax(td) -> float: - """Return the shared positive colour scale for the six-panel filter view. - - Take the largest **positive** sample across the measured, filtered and - regridded traces, restricting the two wavelength-domain arrays to the - measurement window ``td.lamm_lims`` when set. - - Both restrictions matter on real data. An absolute-calibration curve - amplifies the background-subtracted noise far outside the signal band, so - scaling by ``max|·|`` lets a large *negative* excursion set the *positive* - scale: on one of our measured deep-UV traces, whose signal peaks at ``+1`` - but dips to ``-5.7`` at 1097 nm, every linear panel rendered its signal at - 18 % of the colour range. - - The three panels share this one number so the row's single colourbar means - something; each array is peak-normalised upstream, so they are comparable. - If no positive sample remains, fall back to the largest absolute value, - then 1.0, so the returned scale is always positive. - """ - - def _positive_max(arr, lam=None) -> float: - a = np.asarray(arr, dtype=float) - if lam is not None and td.lamm_lims is not None: - lo, hi = td.lamm_lims - band = (np.asarray(lam, dtype=float) >= lo) & ( - np.asarray(lam, dtype=float) <= hi - ) - if band.any(): - a = a[band] - hi = float(np.nanmax(a)) if a.size else 0.0 - return hi if hi > 0.0 else 0.0 - - vmax = max( - _positive_max(td.Ifrog_meas, td.lam_frog), - _positive_max(td.Ifrog_filt, td.lam_frog), - _positive_max(td.trace), - ) - if vmax > 0.0: - return vmax - # Degenerate (all-negative or all-zero) trace: keep a usable, positive scale. - fallback = max( - float(np.nanmax(np.abs(td.Ifrog_meas))), - float(np.nanmax(np.abs(td.Ifrog_filt))), - float(np.nanmax(np.abs(td.trace))), - ) - return fallback if fallback > 0.0 else 1.0 - - -def plot_frog_filter( - td, - *, - tracedb: float = 30.0, - cmap: str = "viridis", - figsize=(9.45, 4.72), - fig: Figure | None = None, -) -> Figure: - """6-panel measured/filtered (wavelength-domain) + regridded (frequency-domain). - - Positive values use the viridis (white-low) map; negative values — kept - through filtering rather than clamped — are drawn by magnitude on a - white→red map so they are visible and not mistaken for signal. Each row - (the dB row and the linear row) carries one positive + one negative - colorbar on the right-hand side. - """ - fig = _prepare_fig(fig, figsize) - axd = fig.subplot_mosaic("abe\ncdf") - cm = cmap_white(cmap) - # The linear row shares a single scale so its one colorbar is meaningful. - lin_vmax = filter_view_vmax(td) - - def _panel(ax, x, y, C, *, db, title, xlabel): - mpos, mneg = signed_pcolormesh( - ax, - x, - y, - C, - db=db, - tracedb=tracedb, - cmap_pos=cm, - vmax=None if db else lin_vmax, - ) - ax.set_xlabel(xlabel) - ax.set_ylabel("Delay (fs)") - ax.set_title(title) - return mpos, mneg - - def _row_cbars(axes, mpos, negs): - """One positive + (if any) one negative colorbar at the row's right.""" - fig.colorbar(mpos, ax=axes, shrink=0.5) - mneg = next((m for m in negs if m is not None), None) - if mneg is not None: - fig.colorbar(mneg, ax=axes, shrink=0.5, label="neg |·|") - - lam_nm = td.lam_frog / 1e-9 - freq = (td.omega + td.omega0_trace) / _TWOPI_PHZ - WL = "Wavelength (nm)" - HZ = "Frequency (PHz)" - - # top row: dB (log) — measured, filtered, regridded - pa, na = _panel( - axd["a"], - lam_nm, - td.tau_meas / 1e-15, - td.Ifrog_meas.T, - db=True, - title="Measured (dB)", - xlabel=WL, - ) - _pb, nb = _panel( - axd["b"], - lam_nm, - td.tau_filt / 1e-15, - td.Ifrog_filt.T, - db=True, - title="Filtered (dB)", - xlabel=WL, - ) - _pe, ne = _panel( - axd["e"], - freq, - td.delays / 1e-15, - td.trace.T, - db=True, - title="Regridded (dB)", - xlabel=HZ, - ) - _row_cbars([axd["a"], axd["b"], axd["e"]], pa, (nb, ne, na)) - - # bottom row: linear — measured, filtered, regridded - pc, nc = _panel( - axd["c"], - lam_nm, - td.tau_meas / 1e-15, - td.Ifrog_meas.T, - db=False, - title="Measured (lin)", - xlabel=WL, - ) - _pd, nd = _panel( - axd["d"], - lam_nm, - td.tau_filt / 1e-15, - td.Ifrog_filt.T, - db=False, - title="Filtered (lin)", - xlabel=WL, - ) - _pf, nf = _panel( - axd["f"], - freq, - td.delays / 1e-15, - td.trace.T, - db=False, - title="Regridded (lin)", - xlabel=HZ, - ) - _row_cbars([axd["c"], axd["d"], axd["f"]], pc, (nd, nf, nc)) - return fig - - -def _decimate_for_display(C, x, y, *, cap=1000): - """Stride a ``(ny, nx)`` image and its axes down to at most ``cap`` per side. - - The filter figure panels are only a few hundred screen pixels wide, so - rasterising a multi-thousand-sample trace is wasted work. Plain strided - subsampling is enough for a diagnostic preview (no anti-alias needed — the - physics-correct arrays are kept in ``td``; this only thins what is drawn). - """ - ny, nx = C.shape - sy = max(ny // cap, 1) - sx = max(nx // cap, 1) - if sy == 1 and sx == 1: - return C, x, y - return C[::sy, ::sx], x[::sx], y[::sy] - - -def _resample_uniform_x(x, C): - """Resample ``C`` (``(ny, nx)``) along axis 1 onto a uniform ``x`` grid. - - :external+matplotlib:class:`~matplotlib.image.AxesImage` (``imshow``) assumes evenly - spaced pixels and is positioned by a single linear ``extent``. A *wavelength* axis - derived from a uniform **frequency** grid (the simulated-trace case) is strongly - non-uniform, so a feature would otherwise be painted at the wrong wavelength (e.g. a - 260 nm signal drawn near 480 nm over a 150–800 nm span). Linearly interpolating each - delay row onto an evenly spaced ``x`` restores the correct positions; an - already-uniform axis (the frequency panels, or a uniform-λ spectrometer trace) is - returned unchanged. ``x`` must be ascending. - """ - x = np.asarray(x, dtype=float) - if x.size < 3: - return x, C - d = np.diff(x) - if np.allclose(d, d[0], rtol=1e-3, atol=0.0): - return x, C # already uniform — no interpolation needed - xu = np.linspace(x[0], x[-1], x.size) - Cu = np.empty_like(C) - for i in range(C.shape[0]): - Cu[i] = np.interp(xu, x, C[i]) - return xu, Cu - - -class FrogFilterView: - """Maintain a preprocessing figure for repeated GUI updates. - - The artist-reusing twin of :func:`plot_frog_filter`, which clears and - rebuilds the whole figure (6 panels × up to 2 ``pcolormesh`` + 4 - colourbars) on every call: ~150–400 ms, the dominant cost of the GUI - preprocessing redraw. Call :meth:`attach` once to create the panels, images - and colourbars, then :meth:`update` to push new data via - ``set_data``/``set_extent``/``set_clim`` onto the persistent images, which - benchmarks ~4× faster. Positive and negative samples use the same - signed-magnitude convention as :func:`signed_pcolormesh`. Non-uniform - wavelength arrays are interpolated onto a uniform display grid; the - underlying :class:`croak.preprocess.TraceData` arrays are unchanged. - """ - - _PANELS = ( - # key, title, xlabel-kind ("wl"/"hz"), db - ("a", "Measured (dB)", "wl", True), - ("b", "Filtered (dB)", "wl", True), - ("e", "Regridded (dB)", "hz", True), - ("c", "Measured (lin)", "wl", False), - ("d", "Filtered (lin)", "wl", False), - ("f", "Regridded (lin)", "hz", False), - ) - - def __init__( - self, *, tracedb: float = 30.0, cmap: str = "viridis", cap: int = 1000 - ): - self.tracedb = tracedb - self.cap = cap - self._cmap_pos = cmap_white(cmap) - self._cmap_pos.set_bad(alpha=0.0) # masked (negative) pixels stay clear - self._cmap_neg = _white_red() - self._cmap_neg.set_bad(alpha=0.0) - self.fig = None - self.axd = None - self.images: dict[str, tuple] = {} - - @property - def attached(self) -> bool: - """Whether a figure is currently attached to this view.""" - return self.fig is not None - - def attach(self, fig: Figure) -> None: - """Build the mosaic, images and colourbars once into ``fig``.""" - self.fig = fig - fig.clear() - with contextlib.suppress(Exception): - fig.set_layout_engine("constrained") - self.axd = fig.subplot_mosaic("abe\ncdf") - empty = np.zeros((1, 1)) - for key, title, kind, _db in self._PANELS: - ax = self.axd[key] - pos = ax.imshow( - empty, - origin="lower", - aspect="auto", - cmap=self._cmap_pos, - interpolation="nearest", - ) - neg = ax.imshow( - empty, - origin="lower", - aspect="auto", - cmap=self._cmap_neg, - interpolation="nearest", - ) - self.images[key] = (pos, neg) - ax.set_title(title) - ax.set_xlabel("Wavelength (nm)" if kind == "wl" else "Frequency (PHz)") - ax.set_ylabel("Delay (fs)") - # One positive + one negative colorbar per row (dB row a/b/e, lin row c/d/f). - self._cb_top = fig.colorbar( - self.images["a"][0], ax=[self.axd[k] for k in "abe"], shrink=0.5 - ) - self._cb_top_neg = fig.colorbar( - self.images["a"][1], - ax=[self.axd[k] for k in "abe"], - shrink=0.5, - label="neg |·|", - ) - self._cb_bot = fig.colorbar( - self.images["c"][0], ax=[self.axd[k] for k in "cdf"], shrink=0.5 - ) - self._cb_bot_neg = fig.colorbar( - self.images["c"][1], - ax=[self.axd[k] for k in "cdf"], - shrink=0.5, - label="neg |·|", - ) - - def _set_panel(self, key, x, y, C, *, db, vmax=None): - if self.axd is None: - raise RuntimeError("live plot is not attached to a figure") - pos_im, neg_im = self.images[key] - C = np.asarray(C, dtype=float) - C, x, y = _decimate_for_display(C, np.asarray(x), np.asarray(y), cap=self.cap) - # imshow paints evenly spaced pixels; a wavelength axis built from a - # uniform frequency grid (simulated traces) is non-uniform, so resample - # onto an even axis to place features at the right wavelength. - x, C = _resample_uniform_x(x, C) - neg = C < 0 - if db: - mag = np.clip( - 10 * np.log10(np.maximum(np.abs(C), 1e-30)), -self.tracedb, 0.0 - ) - vmin, vhi = -self.tracedb, 0.0 - else: - mag = np.abs(C) - vmin = 0.0 - vhi = float(np.nanmax(mag)) if vmax is None else vmax - extent = (float(x[0]), float(x[-1]), float(y[0]), float(y[-1])) - for im, masked in ( - (pos_im, np.ma.masked_where(neg, mag)), - (neg_im, np.ma.masked_where(~neg, mag)), - ): - im.set_data(masked) - im.set_clim(vmin, vhi) - im.set_extent(extent) - ax = self.axd[key] - ax.set_xlim(extent[0], extent[1]) - ax.set_ylim(extent[2], extent[3]) - - def update(self, td) -> None: - """Push a new :class:`~croak.preprocess.TraceData` onto the existing artists.""" - if not self.attached: - raise RuntimeError("attach(fig) before update(td)") - lam_nm = td.lam_frog / 1e-9 - freq = (td.omega + td.omega0_trace) / _TWOPI_PHZ - lin_vmax = filter_view_vmax(td) - self._set_panel("a", lam_nm, td.tau_meas / 1e-15, td.Ifrog_meas.T, db=True) - self._set_panel("b", lam_nm, td.tau_filt / 1e-15, td.Ifrog_filt.T, db=True) - self._set_panel("e", freq, td.delays / 1e-15, td.trace.T, db=True) - self._set_panel( - "c", lam_nm, td.tau_meas / 1e-15, td.Ifrog_meas.T, db=False, vmax=lin_vmax - ) - self._set_panel( - "d", lam_nm, td.tau_filt / 1e-15, td.Ifrog_filt.T, db=False, vmax=lin_vmax - ) - self._set_panel( - "f", freq, td.delays / 1e-15, td.trace.T, db=False, vmax=lin_vmax - ) - # The linear row's scale changes with the data; refresh its colorbars. - self._cb_bot.update_normal(self.images["c"][0]) - self._cb_bot_neg.update_normal(self.images["c"][1]) - - -def plot_simulated_trace( - omega, - delays, - trace, - omega0_trace, - *, - tracedb: float = 40.0, - cmap: str = "viridis", - figsize=(7.87, 5.51), - fig: Figure | None = None, -) -> Figure: - """4-panel view of a simulated trace: dB, linear, and the two marginals.""" - trace = np.asarray(trace, dtype=float) - fig = _prepare_fig(fig, figsize) - axd = fig.subplot_mosaic("ab\ncd") - cm = cmap_white(cmap) - freq = (omega + omega0_trace) / _TWOPI_PHZ - - def _panel(ax, *, db, title): - mpos, mneg = signed_pcolormesh( - ax, delays / 1e-15, freq, trace, db=db, tracedb=tracedb, cmap_pos=cm - ) - ax.set_xlabel("Delay (fs)") - ax.set_ylabel("Frequency (PHz)") - ax.set_title(title) - return mpos, mneg - - ma, na = _panel(axd["a"], db=True, title="FROG (dB)") - mb, nb = _panel(axd["b"], db=False, title="FROG (lin)") - fig.colorbar(ma, ax=[axd["a"]], shrink=0.6) - if na is not None: - fig.colorbar(na, ax=[axd["a"]], shrink=0.6, label="neg |·|") - fig.colorbar(mb, ax=[axd["b"]], shrink=0.6) - if nb is not None: - fig.colorbar(nb, ax=[axd["b"]], shrink=0.6, label="neg |·|") - wmarg, tmarg = marginals(trace) - axd["c"].plot(delays / 1e-15, tmarg, c="C0") - axd["c"].set_xlabel("Delay (fs)") - axd["c"].set_ylabel("Marginal") - axd["c"].set_ylim(0, 1.05) - axd["d"].plot((omega + omega0_trace) / _TWOPI_PHZ, wmarg, c="C0") - axd["d"].set_xlabel("Frequency (PHz)") - axd["d"].set_ylabel("Marginal") - axd["d"].set_ylim(0, 1.05) - return fig - - -def _interaction_scale(name: str) -> float: - from .interactions import get_interaction - - return get_interaction(name).omega0_scale - - -# --------------------------------------------------------------------------- -# Uncertainty -# --------------------------------------------------------------------------- -def plot_fwhm_distribution( - ax, uresult: UncertaintyResult, *, truth: float | None = None -) -> None: - """Histogram of bootstrap FWHM samples with the point estimate and intervals. - - Parameters - ---------- - ax : matplotlib.axes.Axes - Axis to draw into. - uresult : UncertaintyResult - The bootstrap result (its ``samples`` are histogrammed). - truth : float, optional - A known true FWHM (s) to mark with a dashed line (synthetic tests). - """ - samples = np.asarray(uresult.samples, dtype=float) / 1e-15 - nbins = int(np.clip(samples.size // 5, 12, 40)) - ax.hist(samples, bins=nbins, color="C0", alpha=0.7, edgecolor="white", lw=0.4) - lo95, hi95 = uresult.interval_95 - lo68, hi68 = uresult.interval_68 - ax.axvspan(lo95 / 1e-15, hi95 / 1e-15, color="C0", alpha=0.10, label="95 %") - ax.axvspan(lo68 / 1e-15, hi68 / 1e-15, color="C0", alpha=0.20, label="68 %") - ax.axvline(uresult.point_estimate / 1e-15, c="C3", lw=1.5, label="estimate") - if truth is not None: - ax.axvline(truth / 1e-15, c="0.35", ls="--", lw=1.2, label="truth") - ax.set_xlabel("FWHM (fs)") - ax.set_ylabel("Replicates") - ax.set_title( - f"{uresult.point_estimate / 1e-15:.2f} ± {uresult.plus_minus / 1e-15:.2f} fs" - f" (B={uresult.n_converged})" - ) - ax.legend(loc="best", handlelength=1.0, frameon=False, fontsize="small") - - -def plot_intensity_band(ax, uresult: UncertaintyResult) -> None: - """Plot the median temporal intensity and pointwise 68 %/95 % resampling bands. - - Requires the bootstrap to have been run with ``collect_profiles=True``. The profiles - are peak-aligned (translation gauge removed by ``shiftnorm``), so the band is the - spread of the aligned intensity envelope across replicates. The bands are - pointwise, not simultaneous bands for the whole curve. - """ - if uresult.profiles is None or uresult.t_profile is None: - raise ValueError( - "uresult has no profiles; run the bootstrap with collect_profiles=True" - ) - t = np.asarray(uresult.t_profile, dtype=float) / 1e-15 - profiles = np.asarray(uresult.profiles, dtype=float) - med = np.median(profiles, axis=0) - lo68, hi68 = np.percentile(profiles, [16.0, 84.0], axis=0) - lo95, hi95 = np.percentile(profiles, [2.5, 97.5], axis=0) - ax.fill_between(t, lo95, hi95, color="C0", alpha=0.15, label="95 %") - ax.fill_between(t, lo68, hi68, color="C0", alpha=0.30, label="68 %") - ax.plot(t, med, c="C0", lw=1.5, label="median") - ax.set_xlabel("Time (fs)") - ax.set_ylabel("Intensity (a.u.)") - ax.set_ylim(bottom=0.0) - ax.set_title("Temporal intensity") - # zoom to where the envelope carries signal - support = t[med > 0.01 * med.max()] - if support.size: - pad = 0.2 * (support.max() - support.min() + 1.0) - ax.set_xlim(support.min() - pad, support.max() + pad) - ax.legend(loc="best", handlelength=1.0, frameon=False, fontsize="small") - - -def plot_uncertainty( - uresult: UncertaintyResult, - *, - truth: float | None = None, - figsize=(11.0, 4.2), - fig: Figure | None = None, -) -> Figure: - """Composite uncertainty figure: FWHM histogram + a confidence band if present. - - Parameters - ---------- - uresult : UncertaintyResult - The bootstrap result. - truth : float, optional - Known true FWHM (s) for the histogram (synthetic tests). - figsize : tuple, optional - Figure size. - fig : matplotlib.figure.Figure, optional - Existing figure to draw into (cleared first), e.g. a Qt canvas figure. - - Returns - ------- - matplotlib.figure.Figure - """ - # A thickness-systematic result carries per-draw thicknesses; show the dedicated - # sensitivity figure so the GUI canvas path needs no special-casing. - if getattr(uresult, "thicknesses", None) is not None: - return plot_thickness_sensitivity(uresult, truth=truth, fig=fig) - fig = _prepare_fig(fig, figsize) - if uresult.profiles is not None and uresult.t_profile is not None: - ax_h, ax_b = fig.subplots(1, 2) - plot_fwhm_distribution(ax_h, uresult, truth=truth) - plot_intensity_band(ax_b, uresult) - else: - ax_h = fig.subplots(1, 1) - plot_fwhm_distribution(ax_h, uresult, truth=truth) - fig.suptitle(uresult.summary(), fontsize="small") - return fig - - -# --------------------------------------------------------------------------- -# Substrate-thickness systematic -# --------------------------------------------------------------------------- -def _mark_thickness_prior(ax, uresult: UncertaintyResult) -> None: - """Shade the measured thickness prior ``L₀ ± σ_L`` (µm) on a thickness axis.""" - if uresult.thickness_central is None or uresult.thickness_sigma is None: - return - central_um = uresult.thickness_central / 1e-6 - sigma_um = uresult.thickness_sigma / 1e-6 - ax.axvspan( - central_um - sigma_um, - central_um + sigma_um, - color="0.8", - alpha=0.5, - label="L₀ ± σ_L", - ) - ax.axvline(central_um, color="0.4", ls=":", lw=1.0) - - -def plot_fwhm_vs_thickness( - ax, uresult: UncertaintyResult, *, truth: float | None = None -) -> None: - """Scatter the retrieved FWHM against the drawn substrate thickness. - - The horizontal bands give the point estimate and its 68 % interval; the vertical - band is the measured thickness prior. The slope of the cloud is the - sensitivity ``dFWHM/dL`` the analysis propagates; solver variation adds to - the spread. - - Parameters - ---------- - ax : matplotlib.axes.Axes - Axis to draw into. - uresult : UncertaintyResult - A ``method="thickness"`` result (its ``thicknesses`` are the x-values). - truth : float, optional - A known true FWHM (s) to mark with a dashed line (synthetic tests). - """ - if uresult.thicknesses is None: - raise ValueError("uresult has no thicknesses; run method='thickness'") - thick_um = np.asarray(uresult.thicknesses, dtype=float) / 1e-6 - fwhm_fs = np.asarray(uresult.samples, dtype=float) / 1e-15 - _mark_thickness_prior(ax, uresult) - ax.scatter(thick_um, fwhm_fs, s=14, color="C0", alpha=0.6, edgecolors="none") - ax.axhline(uresult.point_estimate / 1e-15, color="C3", lw=1.5, label="estimate") - lo68, hi68 = uresult.interval_68 - ax.axhspan(lo68 / 1e-15, hi68 / 1e-15, color="C3", alpha=0.15, label="68 %") - if truth is not None: - ax.axhline(truth / 1e-15, color="0.35", ls="--", lw=1.2, label="truth") - ax.set_xlabel("Substrate thickness (µm)") - ax.set_ylabel("FWHM (fs)") - ax.set_title("FWHM vs thickness") - ax.legend(loc="best", handlelength=1.0, frameon=False, fontsize="small") - - -def plot_error_vs_thickness(ax, uresult: UncertaintyResult) -> None: - """Scatter the FROG error against the drawn thickness (a consistency check). - - A flat cloud means the data do not constrain the thickness (the full prior - propagates); a clear minimum near ``L₀`` means the trace itself prefers a - thickness under the chosen model. The plot does not reweight the prior. - """ - if uresult.thicknesses is None: - raise ValueError("uresult has no thicknesses; run method='thickness'") - thick_um = np.asarray(uresult.thicknesses, dtype=float) / 1e-6 - errors = np.asarray(uresult.frog_errors, dtype=float) - _mark_thickness_prior(ax, uresult) - ax.scatter(thick_um, errors, s=14, color="C1", alpha=0.6, edgecolors="none") - ax.set_xlabel("Substrate thickness (µm)") - ax.set_ylabel("FROG error R") - ax.set_title("Trace misfit vs thickness") - ax.legend(loc="best", handlelength=1.0, frameon=False, fontsize="small") - - -def plot_thickness_sensitivity( - uresult: UncertaintyResult, - *, - truth: float | None = None, - figsize=(13.0, 4.2), - fig: Figure | None = None, -) -> Figure: - """Composite figure for the substrate-thickness systematic. - - Panels: FWHM vs thickness, FROG error vs thickness, and — when profiles were - collected — the temporal intensity confidence band. - - Parameters - ---------- - uresult : UncertaintyResult - A ``method="thickness"`` result. - truth : float, optional - Known true FWHM (s) for the FWHM panel (synthetic tests). - figsize : tuple, optional - Figure size. - fig : matplotlib.figure.Figure, optional - Existing figure to draw into (cleared first), e.g. a Qt canvas figure. - - Returns - ------- - matplotlib.figure.Figure - """ - fig = _prepare_fig(fig, figsize) - has_band = uresult.profiles is not None and uresult.t_profile is not None - axes = fig.subplots(1, 3 if has_band else 2) - plot_fwhm_vs_thickness(axes[0], uresult, truth=truth) - plot_error_vs_thickness(axes[1], uresult) - if has_band: - plot_intensity_band(axes[2], uresult) - fig.suptitle(uresult.summary(), fontsize="small") - return fig diff --git a/croak/plotting/__init__.py b/croak/plotting/__init__.py new file mode 100644 index 0000000..9f1e2c3 --- /dev/null +++ b/croak/plotting/__init__.py @@ -0,0 +1,175 @@ +"""Matplotlib plotting for retrieval results. + +Provides custom colormaps (white at the low end; a red band for negatives), +reusable **single-axis** plotters (so the GUI embeds exactly the same code), and +**composite** figures: the 12-panel :func:`plot_retrieval`, the 6-panel +:func:`plot_frog_filter`, and :func:`plot_simulated_trace`. + +The retrieval summary is assembled from twelve independent **panels** +(``draw_(ax, data)`` over one :class:`RetrievalPlotData` bundle, registered in +:data:`RETRIEVAL_PANELS`). :func:`plot_retrieval` lays them out as the 3×4 +overview; :func:`plot_retrieval_page` draws one of the three 2×2 pages +(:data:`RETRIEVAL_PAGES`: Traces, Pulse, Diagnostics), as the GUI's Analyse +pages show them. + +Figures are built with :external+matplotlib:class:`matplotlib.figure.Figure` directly +(no ``pyplot`` global state), so they embed cleanly in a Qt canvas and also save +standalone via ``fig.savefig(...)``. + +Every public name is importable from ``croak.plotting`` itself; the submodules +group them by domain: + +``units`` + Display units and read-out formatting (:func:`sig3`, :func:`si_power`). +``traces`` + FROG-trace images: colormaps, :func:`signed_pcolormesh`, the residual and + marginal plotters, :func:`plot_frog_filter` and :func:`plot_simulated_trace`. +``pulse`` + The retrieved pulse: :func:`plot_temporal`, :func:`plot_spectral` and + :func:`plot_spectrogram`. +``artists`` + The artist bundles each panel returns (:class:`TraceArtists`, …). +``panels`` + :class:`RetrievalPlotData` and the twelve ``draw_`` panels. +``retrieval`` + The panel registry, pages and overview, :func:`plot_retrieval_page` and + :func:`plot_retrieval`. +``uncertainty`` + Bootstrap and substrate-thickness uncertainty figures. +``figure`` + The figure set-up the composites share. +``screen`` + What the GUI's interactive views show, as plain data + (:mod:`croak.plotting.screen`). +``export`` + Those views drawn as publication figures, for the GUI's "Export … as a + figure" (:mod:`croak.plotting.export`). +``display`` + Those views on the spectral axis (wavelength or frequency, with the density + Jacobian) and logarithmic range the viewer chose + (:mod:`croak.plotting.display`). +""" + +from .artists import ( + ConvergenceArtists, + LineArtists, + MarginalArtists, + PanelArtists, + ResidualArtists, + SpectralArtists, + SpectrogramArtists, + TemporalArtists, + TraceArtists, +) +from .panels import ( + RetrievalPlotData, + draw_convergence, + draw_delay_marginal, + draw_freq_marginal, + draw_measured_lin, + draw_measured_log, + draw_residual, + draw_retrieved_lin, + draw_retrieved_log, + draw_spectral, + draw_spectral_filter, + draw_spectrogram, + draw_temporal, + plot_convergence, + retrieval_plot_data, +) +from .pulse import ( + EDGE_ENERGY_WARN, + SPECTRAL_AXES, + SpectralAxis, + plot_spectral, + plot_spectrogram, + plot_temporal, +) +from .retrieval import ( + RETRIEVAL_COLORBARS, + RETRIEVAL_OVERVIEW, + RETRIEVAL_PAGES, + RETRIEVAL_PANELS, + ColorbarSpec, + PageSpec, + PanelDraw, + PanelSpec, + plot_retrieval, + plot_retrieval_page, +) +from .traces import ( + cmap_negwhite, + cmap_white, + filter_view_vmax, + plot_frog_filter, + plot_marginal, + plot_residual, + plot_simulated_trace, + signed_pcolormesh, +) +from .uncertainty import ( + plot_error_vs_thickness, + plot_fwhm_distribution, + plot_fwhm_vs_thickness, + plot_intensity_band, + plot_thickness_sensitivity, + plot_uncertainty, +) +from .units import ( + si_power, + sig3, +) + +__all__ = [ + "cmap_white", + "cmap_negwhite", + "signed_pcolormesh", + "si_power", + "sig3", + "plot_residual", + "plot_temporal", + "plot_spectral", + "SPECTRAL_AXES", + "EDGE_ENERGY_WARN", + "plot_convergence", + "plot_marginal", + "plot_spectrogram", + "plot_retrieval", + "RetrievalPlotData", + "retrieval_plot_data", + "TraceArtists", + "ResidualArtists", + "TemporalArtists", + "SpectralArtists", + "ConvergenceArtists", + "LineArtists", + "MarginalArtists", + "SpectrogramArtists", + "PanelSpec", + "PageSpec", + "ColorbarSpec", + "RETRIEVAL_PANELS", + "RETRIEVAL_COLORBARS", + "RETRIEVAL_PAGES", + "RETRIEVAL_OVERVIEW", + "plot_retrieval_page", + "draw_measured_log", + "draw_retrieved_log", + "draw_measured_lin", + "draw_retrieved_lin", + "draw_temporal", + "draw_spectral", + "draw_convergence", + "draw_residual", + "draw_spectral_filter", + "draw_freq_marginal", + "draw_delay_marginal", + "draw_spectrogram", + "plot_frog_filter", + "filter_view_vmax", + "plot_simulated_trace", + "plot_fwhm_distribution", + "plot_intensity_band", + "plot_uncertainty", +] diff --git a/croak/plotting/artists.py b/croak/plotting/artists.py new file mode 100644 index 0000000..50fca25 --- /dev/null +++ b/croak/plotting/artists.py @@ -0,0 +1,156 @@ +"""The matplotlib artists each retrieval panel returns, for in-place updates.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from matplotlib.axes import Axes + from matplotlib.collections import QuadMesh + from matplotlib.legend import Legend + from matplotlib.lines import Line2D + from matplotlib.text import Text + + +# --------------------------------------------------------------------------- +# Panel artists +# --------------------------------------------------------------------------- +# Each retrieval panel returns the matplotlib artists it created, in a small frozen +# dataclass, so that a live view can later update them in place (set_data, +# set_array, set_text) instead of clearing and rebuilding the figure, and so that +# page-level decorations (the colorbars shared by a pair of trace panels) can find +# their mappables. eq=False: comparing artists by field value is never meaningful. + + +@dataclass(frozen=True, eq=False) +class TraceArtists: + """Artists of a trace-image panel. + + Attributes + ---------- + mesh_pos, mesh_neg : QuadMesh + The positive-sample image and, when the data hold negatives, the + white→red magnitude image of the negative samples (else ``None``). + label : Text or None + The bold error/shape annotation of the retrieved-linear panel. + """ + + mesh_pos: QuadMesh + mesh_neg: QuadMesh | None + label: Text | None = None + + +@dataclass(frozen=True, eq=False) +class ResidualArtists: + """Artists of the residual panel: its signed image (``None`` if no residual).""" + + mesh: QuadMesh | None + + +@dataclass(frozen=True, eq=False) +class TemporalArtists: + """Artists of the temporal panel. + + Attributes + ---------- + line_retr, line_tl : Line2D + Retrieved and transform-limited intensity (or power) curves. + line_phase : Line2D + Retrieved temporal phase on the twin axis ``ax_phase``. + ax_phase : Axes + The right-hand phase axis. + legend : Legend + The combined intensity + phase legend. + legend_retr, legend_tl : Text + The legend entries carrying the R and TL durations (they change with the + result, so a live update rewrites them in place). + line_truth, line_truth_phase : Line2D or None + Ground-truth intensity and phase overlays, when a truth was given. + """ + + line_retr: Line2D + line_tl: Line2D + line_phase: Line2D + ax_phase: Axes + legend: Legend + legend_retr: Text + legend_tl: Text + line_truth: Line2D | None = None + line_truth_phase: Line2D | None = None + + +@dataclass(frozen=True, eq=False) +class SpectralArtists: + """Artists of the spectrum panel. + + Attributes + ---------- + line_retr : Line2D + Retrieved spectral intensity. + line_phase, line_phase_fit : Line2D + Retrieved spectral phase and its dashed polynomial fit, on ``ax_phase``. + ax_phase : Axes + The right-hand phase axis. + legend : Legend + The combined intensity + phase legend. + text_dispersion, text_edge : Text + The GDD/TOD block and the edge-energy warning. + line_meas, line_truth, line_truth_phase : Line2D or None + Measured spectrum, truth spectrum and truth phase, when available. + """ + + line_retr: Line2D + line_phase: Line2D + line_phase_fit: Line2D + ax_phase: Axes + legend: Legend + text_dispersion: Text + text_edge: Text + line_meas: Line2D | None = None + line_truth: Line2D | None = None + line_truth_phase: Line2D | None = None + + +@dataclass(frozen=True, eq=False) +class ConvergenceArtists: + """Artists of the convergence panel: the error curve, stage rules, legend.""" + + line: Line2D + boundaries: tuple[Line2D, ...] = () + legend: Legend | None = None + + +@dataclass(frozen=True, eq=False) +class LineArtists: + """A single-curve panel (the spectral filter).""" + + line: Line2D + + +@dataclass(frozen=True, eq=False) +class MarginalArtists: + """Artists of a marginal panel: retrieved and (optional) measured curves.""" + + line_retr: Line2D + legend: Legend + line_meas: Line2D | None = None + + +@dataclass(frozen=True, eq=False) +class SpectrogramArtists: + """Artists of the spectrogram panel: its image.""" + + mesh: QuadMesh + + +type PanelArtists = ( + TraceArtists + | ResidualArtists + | TemporalArtists + | SpectralArtists + | ConvergenceArtists + | LineArtists + | MarginalArtists + | SpectrogramArtists +) diff --git a/croak/plotting/display.py b/croak/plotting/display.py new file mode 100644 index 0000000..68c64d7 --- /dev/null +++ b/croak/plotting/display.py @@ -0,0 +1,244 @@ +r"""Show a screen view on the spectral axis and logarithmic range the viewer chose. + +The views of :mod:`croak.plotting.screen` are built on the axis their data were +computed on: a loaded trace against wavelength, a retrieval against frequency. +:func:`displayed` moves any of them to the axis a :class:`Display` asks for and +sets every logarithmic map's range, so that one setting holds for every plot. + +Moving between wavelength λ (nm) and frequency ν (PHz) uses ν = c/λ, with +c = 299.792458 nm·PHz, on positions, ranges, shaded intervals and rules. An +intensity is a density per unit of its axis, so it also takes the Jacobian: +from :math:`I_\lambda\,d\lambda = I_\nu\,d\nu`, + +.. math:: I_\nu = I_\lambda \left|\frac{d\lambda}{d\nu}\right| + = I_\lambda \frac{\lambda^2}{c}, + \qquad I_\lambda = I_\nu \frac{\nu^2}{c}. + +Either way the factor is the square of the *old* coordinate, up to the constant, +so a broad spectrum changes shape, not only position. Every map, and every curve +marked as a density (:attr:`~croak.plotting.screen.CurveView.density`), is +converted so; a phase or a filter factor only moves. The views are normalised +displays, so after the Jacobian each map and each density curve is rescaled to +its old peak (in dB: shifted to its old peak), and the colour scale still +reads the same. A map is resampled onto an even grid on the new axis +(:func:`~croak.plotting.screen.uniform_image`); that is for drawing only. + +Examples +-------- +>>> import numpy as np +>>> from croak.plotting.screen import WAVELENGTH_LABEL, CurveView, LineView +>>> flat = CurveView(np.array([500.0, 1000.0]), np.array([1.0, 1.0]), "measured") +>>> view = LineView( +... curves=(flat,), +... title="Spectrum", +... xlabel=WAVELENGTH_LABEL, +... ylabel="Intensity", +... ) +>>> shown = displayed(view, Display(axis="frequency")) +>>> shown.xlabel, shown.curves[0].x.round(4).tolist(), shown.curves[0].y.tolist() +('Frequency (PHz)', [0.2998, 0.5996], [1.0, 0.25]) +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import overload + +import numpy as np +from numpy.typing import NDArray + +from .screen import ( + DB_DEPTH, + DB_UNIT, + FREQUENCY_LABEL, + WAVELENGTH_LABEL, + CurveView, + LineView, + MapView, + SpanView, + uniform_image, +) + +__all__ = [ + "DB_RANGE", + "DB_RANGE_LIMITS", + "DISPLAY_AXES", + "SPEED_OF_LIGHT_NM_PHZ", + "Display", + "displayed", +] + +SPEED_OF_LIGHT_NM_PHZ = 299.792458 +"""The speed of light in nm·PHz, so that ν (PHz) = c / λ (nm).""" + +DISPLAY_AXES: tuple[str, ...] = ("native", "wavelength", "frequency") +"""The spectral axes a display can ask for; ``"native"`` keeps each view's own.""" + +DB_RANGE = 30.0 +"""The default range of a logarithmic map below its peak, dB.""" + +DB_RANGE_LIMITS = (5.0, DB_DEPTH) +"""The narrowest and widest logarithmic range, dB (the views keep values down to +:data:`~croak.plotting.screen.DB_DEPTH` below the peak).""" + +_LABELS = {"wavelength": WAVELENGTH_LABEL, "frequency": FREQUENCY_LABEL} +_SPECTRAL = frozenset(_LABELS.values()) + + +@dataclass(frozen=True) +class Display: + """How the views are shown: their spectral axis and logarithmic range. + + Attributes + ---------- + axis : {"native", "wavelength", "frequency"} + The spectral axis of every view; ``"native"`` keeps each on its own. + db_range : float + Every logarithmic map spans this many dB below its peak, within + :data:`DB_RANGE_LIMITS`. + + Raises + ------ + ValueError + For an unknown axis or a range outside the limits. + """ + + axis: str = "native" + db_range: float = DB_RANGE + + def __post_init__(self) -> None: + """Check the axis and the range.""" + if self.axis not in DISPLAY_AXES: + raise ValueError(f"axis must be one of {DISPLAY_AXES}, got {self.axis!r}") + low, high = DB_RANGE_LIMITS + if not low <= self.db_range <= high: + raise ValueError( + f"db_range must be within {low:g}–{high:g} dB, got {self.db_range!r}" + ) + + +@overload +def displayed(view: MapView, display: Display) -> MapView: ... +@overload +def displayed(view: LineView, display: Display) -> LineView: ... +def displayed(view: MapView | LineView, display: Display) -> MapView | LineView: + """Return ``view`` as ``display`` shows it (the view itself if unchanged). + + Parameters + ---------- + view : MapView or LineView + A screen view; its spectral axis is the one labelled + :data:`~croak.plotting.screen.WAVELENGTH_LABEL` or + :data:`~croak.plotting.screen.FREQUENCY_LABEL`. + display : Display + The axis and the logarithmic range. + + Returns + ------- + MapView or LineView + The view on the display's axis, with its logarithmic range. + """ + if isinstance(view, MapView): + return _displayed_map(view, display) + target = _LABELS.get(display.axis) + if target is None or view.xlabel not in _SPECTRAL or view.xlabel == target: + return view + return _moved_line(view, target) + + +def _displayed_map(view: MapView, display: Display) -> MapView: + target = _LABELS.get(display.axis) + if target is not None and view.ylabel in _SPECTRAL and view.ylabel != target: + view = _moved_map(view, target, along_y=True) + elif target is not None and view.xlabel in _SPECTRAL and view.xlabel != target: + view = _moved_map(view, target, along_y=False) + if view.unit == DB_UNIT: + view = replace(view, levels=(-display.db_range, 0.0)) + return view + + +def _moved(axis: NDArray[np.float64]) -> tuple[NDArray[np.intp], NDArray[np.float64]]: + """Pick the samples of ``axis`` that can move (positive), and move them. + + Returns their indices, in the new axis's (increasing) order, and their new + positions. + """ + keep = np.flatnonzero(np.isfinite(axis) & (axis > 0)) + # ν = c/λ reverses the order: reading the old samples backwards keeps the new + # positions increasing, as the views need. + keep = keep[np.argsort(-axis[keep], kind="stable")] + return keep, SPEED_OF_LIGHT_NM_PHZ / axis[keep] + + +def _range(window: tuple[float, float] | None) -> tuple[float, float] | None: + """Move a window to the new axis (``None`` if it reaches zero or below).""" + if window is None or min(window) <= 0: + return None + low, high = sorted(SPEED_OF_LIGHT_NM_PHZ / np.asarray(window, dtype=float)) + return float(low), float(high) + + +def _moved_map(view: MapView, target: str, *, along_y: bool) -> MapView: + values = view.values if along_y else view.values.T + old = view.y if along_y else view.x + keep, new = _moved(old) + values = values[keep] + jacobian = old[keep] ** 2 + if view.unit == DB_UNIT: + peak = float(np.nanmax(values)) + values = values + 10 * np.log10(jacobian / jacobian.max())[:, None] + values = np.clip(values - (float(np.nanmax(values)) - peak), -DB_DEPTH, 0.0) + else: + peak = float(np.nanmax(np.abs(values))) + values = values * jacobian[:, None] + scale = float(np.nanmax(np.abs(values))) + values = values * (peak / scale if scale > 0 else 1.0) + if along_y: + x, y, image = uniform_image(view.x, new, values) + return replace( + view, values=image, x=x, y=y, ylabel=target, yrange=_range(view.yrange) + ) + y, x, image = uniform_image(view.y, new, values) + return replace( + view, values=image.T, x=x, y=y, xlabel=target, xrange=_range(view.xrange) + ) + + +def _moved_curve(curve: CurveView) -> CurveView: + keep, x = _moved(curve.x) + y = curve.y[keep] + if curve.density: + finite = np.isfinite(curve.y) + peak = float(np.max(np.abs(curve.y[finite]))) if finite.any() else 0.0 + y = y * curve.x[keep] ** 2 + new_finite = np.isfinite(y) + scale = float(np.max(np.abs(y[new_finite]))) if new_finite.any() else 0.0 + y = y * (peak / scale if scale > 0 else 1.0) + return replace(curve, x=x, y=y) + + +def _moved_span(span: SpanView) -> SpanView: + if not span.vertical: + return span + window = _range((span.low, span.high)) + if window is None: + return span + return replace(span, low=window[0], high=window[1]) + + +def _moved_line(view: LineView, target: str) -> LineView: + bands = [] + for band in view.bands: + keep, x = _moved(band.x) + bands.append(replace(band, x=x, low=band.low[keep], high=band.high[keep])) + return replace( + view, + curves=tuple(_moved_curve(curve) for curve in view.curves), + xlabel=target, + xrange=_range(view.xrange), + marks=tuple( + (SPEED_OF_LIGHT_NM_PHZ / x, role) for x, role in view.marks if x > 0 + ), + bands=tuple(bands), + spans=tuple(_moved_span(span) for span in view.spans), + ) diff --git a/croak/plotting/export.py b/croak/plotting/export.py new file mode 100644 index 0000000..44c9783 --- /dev/null +++ b/croak/plotting/export.py @@ -0,0 +1,269 @@ +"""Publication figures of what the GUI shows: screen views drawn by matplotlib. + +The window's plots are pyqtgraph, built from the Qt-free views of +:mod:`croak.plotting.screen`. :func:`draw_view` draws one of those views into a +matplotlib axes with the same data, colours and line styles, and +:func:`views_figure` lays a page's views out as one figure, so any plot or page +can be saved as a vector PDF or SVG (or a PNG). Images are rasterized inside a +vector file (``rasterized=True``): a trace map stays a few hundred kilobytes and +opens quickly, while its axes, labels and curves stay sharp. + +Examples +-------- +>>> import numpy as np +>>> from croak.plotting.screen import CurveView, LineView +>>> view = LineView( +... curves=(CurveView(np.arange(3.0), np.ones(3), "retrieved", "pulse"),), +... title="Temporal", +... xlabel="Time (fs)", +... ylabel="Intensity", +... ) +>>> fig = views_figure([[view]]) +>>> fig.axes[0].get_xlabel() +'Time (fs)' +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import matplotlib as mpl +from matplotlib.axes import Axes +from matplotlib.figure import Figure + +from .screen import ROLE_STYLE, LineView, MapView +from .traces import cmap_negwhite + +__all__ = [ + "EXPORT_RC", + "PANEL_SIZE", + "draw_line_view", + "draw_map_view", + "draw_view", + "save_figure", + "views_figure", +] + +PANEL_SIZE = (3.4, 2.6) +"""One panel's size in inches: a journal's single column is about 3.4 in wide.""" + +EXPORT_RC = { + "font.size": 8, + "axes.titlesize": 8, + "axes.labelsize": 8, + "xtick.labelsize": 7, + "ytick.labelsize": 7, + "legend.fontsize": 7, + "legend.frameon": False, + "xtick.direction": "in", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "lines.linewidth": 1.2, + "axes.linewidth": 0.6, + # Keep text as text in a vector file, so a journal's tools can edit it. + "pdf.fonttype": 42, + "ps.fonttype": 42, + "svg.fonttype": "none", +} +"""Matplotlib settings for a figure at print size: small type, inward ticks.""" + +EXPORT_DPI = 300 +"""Resolution of a PNG, and of the rasterized images inside a PDF or SVG.""" + +REFERENCE_WIDTH = 0.7 +"""The line width of a reference curve (the known pulse), points.""" + + +def draw_map_view( + ax: Axes, view: MapView, *, colours: str | None = None, white: bool = True +) -> None: + """Draw a map ``view`` into ``ax`` with a colour bar, as the screen shows it. + + Parameters + ---------- + ax : matplotlib.axes.Axes + Where to draw. + view : MapView + The image, its levels and labels. + colours : str, optional + The colour map (default: the view's own). + white : bool + Start the colour map at white, so an empty background reads as paper. + A signed view's negative values fade from red to white at zero. + """ + low, high = view.levels + negative = -low / (high - low) if view.signed and low < 0 < high else 0.0 + cmap = cmap_negwhite(colours or view.colours, white=white, negfrac=negative) + mesh = ax.pcolormesh( + view.x, + view.y, + view.values, + cmap=cmap, + vmin=low, + vmax=high, + shading="nearest", + rasterized=True, + ) + bar = ax.figure.colorbar(mesh, ax=ax, pad=0.02) + if view.unit: + bar.set_label(view.unit) + _label(ax, view.title, view.xlabel, view.ylabel) + if view.xrange is not None: + ax.set_xlim(*view.xrange) + if view.yrange is not None: + ax.set_ylim(*view.yrange) + + +def draw_line_view(ax: Axes, view: LineView) -> None: + """Draw a line ``view`` into ``ax``: curves, a right axis, bands and rules. + + Parameters + ---------- + ax : matplotlib.axes.Axes + Where to draw. + view : LineView + The curves and their decorations. + """ + right = None + if view.right_label is not None: + right = ax.twinx() + ax.tick_params(right=False) # the right axis has ticks of its own + if view.bars is not None: + colour = ROLE_STYLE[view.bars.role][0] + ax.stairs(view.bars.counts, view.bars.edges, fill=True, color=colour, alpha=0.5) + for span in view.spans: + colour = ROLE_STYLE[span.role][0] + shade = ax.axvspan if span.vertical else ax.axhspan + shade(span.low, span.high, color=colour, alpha=span.opacity, lw=0) + for band in view.bands: + colour = ROLE_STYLE[band.role][0] + ax.fill_between( + band.x, band.low, band.high, color=colour, alpha=band.opacity, lw=0 + ) + for curve in view.curves: + target = right if curve.right and right is not None else ax + colour, dashed, reference = ROLE_STYLE[curve.role] + if curve.points: + target.plot(curve.x, curve.y, ".", color=colour, ms=3, label=curve.name) + continue + target.plot( + curve.x, + curve.y, + color=colour, + ls="--" if dashed else "-", + lw=REFERENCE_WIDTH if reference else None, + zorder=1 if reference else 2, + label=curve.name, + ) + for y, role in view.levels: + ax.axhline(y, color=ROLE_STYLE[role][0], lw=0.8) + for x, role in view.marks: + ax.axvline(x, color=ROLE_STYLE[role][0], lw=0.8, ls="--") + if view.log_y: + ax.set_yscale("log") + _label(ax, view.title, view.xlabel, view.ylabel) + if view.notes: + ax.set_title(" ".join(text for text, _ in view.notes), loc="right") + if view.xrange is not None: + ax.set_xlim(*view.xrange) + if view.yrange is not None: + ax.set_ylim(*view.yrange) + if right is not None: + right.set_ylabel(view.right_label or "") + if view.right_range is not None: + right.set_ylim(*view.right_range) + _legend(ax, right) + + +def draw_view( + ax: Axes, + view: MapView | LineView, + *, + colours: str | None = None, + white: bool = True, +) -> None: + """Draw ``view`` into ``ax`` (a map or a line view; see the two drawers).""" + if isinstance(view, MapView): + draw_map_view(ax, view, colours=colours, white=white) + else: + draw_line_view(ax, view) + + +def views_figure( + views: Sequence[Sequence[MapView | LineView | None]], + *, + colours: Sequence[Sequence[str | None]] | None = None, + white: Sequence[Sequence[bool]] | None = None, +) -> Figure: + """Lay views out as a figure: rows of panels, as they sit on the page. + + Parameters + ---------- + views : sequence of sequence of view or None + The rows; ``None`` leaves a cell empty. + colours, white : sequence of sequence, optional + Per cell, a map's colour map and white-based scale (see + :func:`draw_map_view`); default: the view's own map, white-based. + + Returns + ------- + matplotlib.figure.Figure + At :data:`PANEL_SIZE` per panel, drawn with :data:`EXPORT_RC`. + + Raises + ------ + ValueError + If there is nothing to draw. + """ + rows = len(views) + columns = max((len(row) for row in views), default=0) + if not any(view is not None for row in views for view in row): + raise ValueError("a figure needs at least one view") + with mpl.rc_context(EXPORT_RC): + fig = Figure( + figsize=(PANEL_SIZE[0] * columns, PANEL_SIZE[1] * rows), + layout="constrained", + ) + axes = fig.subplots(rows, columns, squeeze=False) + for r, row in enumerate(views): + for c in range(columns): + view = row[c] if c < len(row) else None + ax = axes[r][c] + if view is None: + ax.set_axis_off() + continue + draw_view( + ax, + view, + colours=colours[r][c] if colours is not None else None, + white=white[r][c] if white is not None else True, + ) + return fig + + +def save_figure(fig: Figure, path: str) -> None: + """Save ``fig`` to ``path``; the extension picks PDF, SVG or PNG. + + Saved under :data:`EXPORT_RC`, which keeps text as editable text in a + vector file, at :data:`EXPORT_DPI`. + """ + with mpl.rc_context(EXPORT_RC): + fig.savefig(path, dpi=EXPORT_DPI) + + +def _label(ax: Axes, title: str, xlabel: str, ylabel: str) -> None: + ax.set_title(title, loc="left") + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + + +def _legend(ax: Axes, right: Axes | None) -> None: + """One legend for both axes, when more than one curve has a name.""" + handles, names = ax.get_legend_handles_labels() + if right is not None: + more = right.get_legend_handles_labels() + handles, names = handles + more[0], names + more[1] + named = [(h, n) for h, n in zip(handles, names, strict=True) if n] + if len(named) > 1: + ax.legend(*zip(*named, strict=True)) diff --git a/croak/plotting/figure.py b/croak/plotting/figure.py new file mode 100644 index 0000000..f5da62f --- /dev/null +++ b/croak/plotting/figure.py @@ -0,0 +1,17 @@ +"""Figure lifecycle shared by the composite plotters.""" + +from __future__ import annotations + +import contextlib + +from matplotlib.figure import Figure + + +def _prepare_fig(fig, figsize): + """Return a cleared figure to draw a composite into (new if None).""" + if fig is None: + return Figure(figsize=figsize, layout="constrained") + fig.clear() + with contextlib.suppress(Exception): + fig.set_layout_engine("constrained") + return fig diff --git a/croak/plotting/panels.py b/croak/plotting/panels.py new file mode 100644 index 0000000..4c990b1 --- /dev/null +++ b/croak/plotting/panels.py @@ -0,0 +1,436 @@ +"""The twelve retrieval panels over one shared :class:`RetrievalPlotData` bundle.""" + +from __future__ import annotations + +import functools +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np +from numpy.typing import ArrayLike + +from ..maths import wlfreq +from ..processing import ProcessedResult, TruthPulse, process_result, spectrogram +from ..result import RetrievalResult +from .artists import ( + ConvergenceArtists, + LineArtists, + MarginalArtists, + ResidualArtists, + SpectralArtists, + SpectrogramArtists, + TemporalArtists, + TraceArtists, +) +from .pulse import SPECTRAL_AXES, _spectral_panel, _temporal_panel, plot_spectrogram +from .traces import cmap_white, plot_marginal, plot_residual, signed_pcolormesh +from .units import _TWOPI_PHZ, si_power + +if TYPE_CHECKING: + from matplotlib.collections import QuadMesh + from matplotlib.colors import Colormap + from matplotlib.lines import Line2D + from numpy.typing import NDArray + + +# --------------------------------------------------------------------------- +# Retrieval panels: the data bundle and the twelve panels +# --------------------------------------------------------------------------- +# The twelve-panel retrieval summary is built from independent panels so the GUI +# can arrange them as three 2×2 pages (Traces / Pulse / Diagnostics) while scripts +# and the CLI keep the 3×4 overview. Every panel is a pure function +# ``draw_(ax, data)`` over one immutable RetrievalPlotData, computed once per +# result. Colorbars that span two panels are a page-level concern (ColorbarSpec, +# in croak.plotting.retrieval), drawn after all panels of a figure exist. + + +@dataclass(frozen=True, eq=False) +class RetrievalPlotData: + """Everything the retrieval panels share, computed once from one result. + + Build it with :func:`retrieval_plot_data`. The bundle is immutable and carries + no figure state, so one instance can feed several figures (the GUI's pages and + their pop-outs) and be redrawn at any size. + + Attributes + ---------- + result : RetrievalResult + The retrieval; ``result.trace`` is the retrieved trace. + processed : ProcessedResult + Display-ready quantities of ``result`` + (:func:`croak.processing.process_result`). + measured, retrieved : ndarray + Measured and retrieved traces, shape ``(Nω, Nτ)``. + omega0_trace : float + Centre angular frequency of the *signal* (twice ``omega0`` for SHG), so + the trace panels' frequency axis reads at the signal frequency. + halfwidth : float + Half the delay span (s); bounds the delay axes. + lam_min, lam_max : float + Wavelength bounds (m) of the spectrum and spectrogram panels. + flim : tuple of float or None + Optional ``(f_min, f_max)`` (Hz) bounds of the trace/residual frequency axes. + tracedb : float + Dynamic range (dB) of the logarithmic trace panels. + cmap_pos : Colormap + White-based positive colormap shared by the trace and spectrogram panels. + lin_vmax : float + Common scale of the linear trace pair, so their colorbar is meaningful. + truth : TruthPulse or None + Known pulse to overlay (synthetic and simulated data). + power_scale : tuple of (float, str) or None + SI ``(scale, unit)`` for absolute power when a pulse energy was given. + spectral_axis : str + ``"frequency"`` or ``"wavelength"``: the spectrum panel's abscissa. + """ + + result: RetrievalResult + processed: ProcessedResult + measured: NDArray[np.float64] + retrieved: NDArray[np.float64] + omega0_trace: float + halfwidth: float + lam_min: float + lam_max: float + flim: tuple[float, float] | None + tracedb: float + cmap_pos: Colormap + lin_vmax: float + truth: TruthPulse | None + power_scale: tuple[float, str] | None + spectral_axis: str + + @functools.cached_property + def spectrogram( + self, + ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: + """``(t_centres, wavelength, S)`` of :func:`croak.processing.spectrogram`. + + The Gabor transform is the costliest thing the panels compute (hundreds + of FFTs), so it runs only when the spectrogram panel is first drawn and is + then cached on the bundle for every later draw of it. + """ + return spectrogram(self.result, lam_min=self.lam_min, lam_max=self.lam_max) + + +def retrieval_plot_data( + result: RetrievalResult, + *, + measured: ArrayLike | None = None, + Iomega_meas: ArrayLike | None = None, + lam_min: float | None = None, + lam_max: float | None = None, + flim: tuple[float, float] | None = None, + tracedb: float = 30.0, + cmap: str = "viridis", + truth: TruthPulse | None = None, + energy: float | None = None, + processed: ProcessedResult | None = None, + spectral_axis: str = "frequency", +) -> RetrievalPlotData: + """Bundle a retrieval's plot inputs for the panels (see :func:`plot_retrieval`). + + The arguments are those of :func:`plot_retrieval`, which is this bundle drawn + as the overview; see there for their meaning. ``processed`` is an + already-computed :class:`~croak.processing.ProcessedResult` **for this same + result** (the GUI computes it once for its read-out); it must have been built + with the same ``measured``/``Iomega_meas``/``energy``. + + Raises + ------ + ValueError + If ``result`` carries no trace, ``processed`` belongs to another result, + or ``spectral_axis`` is not one of :data:`SPECTRAL_AXES`. + """ + omega = result.omega + omega0 = result.omega0 + if spectral_axis not in SPECTRAL_AXES: + raise ValueError( + f"spectral_axis must be one of {SPECTRAL_AXES}, got {spectral_axis!r}" + ) + if result.trace is None: + raise ValueError("plot_retrieval requires a result carrying a simulated trace") + retrieved = np.asarray(result.trace, dtype=float) + measured_arr = retrieved if measured is None else np.asarray(measured, dtype=float) + + if processed is None: + pr = process_result( + result, measured=measured_arr, Iomega_meas=Iomega_meas, energy=energy + ) + elif processed.result is not result: + # Cheap exact guard: mixing a ProcessedResult from another result would + # draw the trace panels from one retrieval and the pulse/spectrum panels + # from another, which is very hard to spot by eye. + raise ValueError("processed must be the ProcessedResult of this result") + else: + pr = processed + if lam_min is None or lam_max is None: + lams = wlfreq(omega + omega0) + lam_min, lam_max = float(lams.min()), float(lams.max()) + delays = result.delays + # When a pulse energy was supplied, derive one SI power prefix for the + # temporal panel (and the truth overlay) so both share a consistent axis. + power_scale = None + if pr.peak_power is not None: + power_scale = si_power(max(pr.peak_power, pr.peak_power_tl or 0.0)) + return RetrievalPlotData( + result=result, + processed=pr, + measured=measured_arr, + retrieved=retrieved, + omega0_trace=omega0 * _interaction_scale(result.interaction), + halfwidth=float((delays.max() - delays.min()) / 2), + lam_min=float(lam_min), + lam_max=float(lam_max), + flim=flim, + tracedb=tracedb, + # Positive values on the base map with white at the low end, so zero is + # white in *both* maps and meets the white→red negative map seamlessly. + cmap_pos=cmap_white(cmap), + # The linear pair shares one scale so its colorbar is meaningful. + lin_vmax=max( + float(np.nanmax(np.abs(measured_arr))), float(np.nanmax(retrieved)) + ), + truth=truth, + power_scale=power_scale, + spectral_axis=spectral_axis, + ) + + +def plot_convergence( + ax, errors: ArrayLike, *, boundaries: Sequence[int] = () +) -> ConvergenceArtists: + """Plot the FROG error vs iteration on a log y-axis. + + ``boundaries`` are indices into ``errors`` where a multi-stage solver handed + over to its next stage (:attr:`~croak.result.RetrievalResult.stage_boundaries`); + each is drawn as a dashed rule. Marking them matters because the stages need + not count the same unit of work — ``warm-lbfgs`` splices COPRA iterations onto + L-BFGS function evaluations — so an unmarked kink would read as convergence + behaviour rather than as the change of algorithm it is. + + The error curve is created even for an empty ``errors`` (a result restored + from file carries no history), so a live view can grow it in place. + """ + errors = np.asarray(errors, dtype=float) + (line,) = ax.semilogy(np.arange(1, errors.size + 1), errors, c="C0") + rules: list[Line2D] = [] + for i, edge in enumerate(boundaries): + if 0 < edge < errors.size: + rules.append( + ax.axvline( + edge + 0.5, + c="0.5", + ls="--", + lw=0.8, + label="stage handover" if i == 0 else None, + ) + ) + legend = None + if rules: # upper right: a decreasing curve leaves that corner empty + legend = ax.legend( + loc="upper right", handlelength=1.2, frameon=False, fontsize="small" + ) + ax.set_xlabel("Iteration") + ax.set_ylabel("Error") + ax.set_title("Convergence") + return ConvergenceArtists(line=line, boundaries=tuple(rules), legend=legend) + + +def _trace_label(data: RetrievalPlotData) -> str: + """Return the retrieved-linear panel's annotation: FROG error and shape.""" + return ( + f"{data.result.error * 100:.2f}%\n" + f"{data.retrieved.shape[0]}×{data.retrieved.shape[1]}" + ) + + +def _draw_trace_image( + ax, data: RetrievalPlotData, C: NDArray[np.float64], *, db: bool, title: str +) -> tuple[QuadMesh, QuadMesh | None]: + """Draw one trace image (delay × signal frequency) and return its meshes.""" + mpos, mneg = signed_pcolormesh( + ax, + data.result.delays / 1e-15, + (data.result.omega + data.omega0_trace) / _TWOPI_PHZ, + C, + db=db, + tracedb=data.tracedb, + cmap_pos=data.cmap_pos, + vmax=None if db else data.lin_vmax, + ) + ax.set_xlabel("Delay (fs)") + ax.set_ylabel("Frequency (PHz)") + ax.set_title(title) + ax.set_xlim(-data.halfwidth / 1e-15, data.halfwidth / 1e-15) + if data.flim is not None: + ax.set_ylim(data.flim[0] / 1e15, data.flim[1] / 1e15) + return mpos, mneg + + +def draw_measured_log(ax, data: RetrievalPlotData) -> TraceArtists: + """Draw the measured trace, logarithmic (``tracedb`` dynamic range).""" + mpos, mneg = _draw_trace_image( + ax, data, data.measured, db=True, title="Measured (log)" + ) + return TraceArtists(mesh_pos=mpos, mesh_neg=mneg) + + +def draw_retrieved_log(ax, data: RetrievalPlotData) -> TraceArtists: + """Draw the retrieved trace, logarithmic.""" + mpos, mneg = _draw_trace_image( + ax, data, data.retrieved, db=True, title="Retrieved (log)" + ) + return TraceArtists(mesh_pos=mpos, mesh_neg=mneg) + + +def draw_measured_lin(ax, data: RetrievalPlotData) -> TraceArtists: + """Draw the measured trace, linear, on the scale shared with the retrieved.""" + mpos, mneg = _draw_trace_image( + ax, data, data.measured, db=False, title="Measured (lin)" + ) + return TraceArtists(mesh_pos=mpos, mesh_neg=mneg) + + +def draw_retrieved_lin(ax, data: RetrievalPlotData) -> TraceArtists: + """Draw the retrieved trace, linear, annotated with FROG error and shape.""" + mpos, mneg = _draw_trace_image( + ax, data, data.retrieved, db=False, title="Retrieved (lin)" + ) + label = ax.text( + 0.04, + 0.96, + _trace_label(data), + transform=ax.transAxes, + ha="left", + va="top", + color="black", + fontweight="bold", + fontsize="small", + ) + return TraceArtists(mesh_pos=mpos, mesh_neg=mneg, label=label) + + +def draw_temporal(ax, data: RetrievalPlotData) -> TemporalArtists: + """Draw the retrieved pulse: intensity/power, TL pulse, phase, truth overlay.""" + return _temporal_panel( + ax, + data.processed, + halfwidth=data.halfwidth, + power_scale=data.power_scale, + truth=data.truth, + ) + + +def draw_spectral(ax, data: RetrievalPlotData) -> SpectralArtists: + """Draw the spectrum: retrieved, measured, phase and fit, GDD/TOD, truth.""" + return _spectral_panel( + ax, + data.processed, + lam_min=data.lam_min, + lam_max=data.lam_max, + truth=data.truth, + axis=data.spectral_axis, + ) + + +def draw_convergence(ax, data: RetrievalPlotData) -> ConvergenceArtists: + """Draw the FROG error against iteration, with the solver's stage handovers.""" + return plot_convergence( + ax, data.processed.errors, boundaries=data.result.stage_boundaries + ) + + +def draw_residual(ax, data: RetrievalPlotData) -> ResidualArtists: + """Draw the measured − retrieved residual; leave it empty if there is none.""" + pr = data.processed + if pr.residual is None: + return ResidualArtists(mesh=None) + mesh = plot_residual( + ax, + data.result.omega, + data.result.delays, + pr.residual, + data.result.omega0, + halfwidth=data.halfwidth, + flim=data.flim, + ) + return ResidualArtists(mesh=mesh) + + +def _filter_factor(result: RetrievalResult) -> NDArray[np.float64]: + """Return the per-frequency Rω scale factor, peak-normalised, else flat.""" + mu = np.atleast_1d(np.asarray(result.mu, dtype=float)) + if mu.size == result.omega.size: + return mu / mu.max() + return np.ones_like(result.omega) + + +def draw_spectral_filter(ax, data: RetrievalPlotData) -> LineArtists: + """Draw the per-frequency scale factor (Rω filter) against wavelength.""" + # A centred retrieval grid can cross zero frequency, where the wavelength + # axis has a pole: harmless for the plot, so the divide warning is silenced. + with np.errstate(divide="ignore", invalid="ignore"): + lam_nm = wlfreq(data.result.omega + data.result.omega0) / 1e-9 + (line,) = ax.plot(lam_nm, _filter_factor(data.result), c="C2") + ax.set_xlabel("Wavelength (nm)") + ax.set_ylabel("Filter factor") + ax.set_title("Spectral filter") + ax.set_xlim(data.lam_min / 1e-9, data.lam_max / 1e-9) + return LineArtists(line=line) + + +def draw_freq_marginal(ax, data: RetrievalPlotData) -> MarginalArtists: + """Draw the frequency marginal of the retrieved and measured traces.""" + pr = data.processed + artists = plot_marginal( + ax, + data.result.omega + data.result.omega0, + pr.omega_marg_retr, + pr.omega_marg_meas, + xlabel="Frequency (PHz)", + scale=_TWOPI_PHZ, + ) + ax.set_title("Frequency marginal") + return artists + + +def draw_delay_marginal(ax, data: RetrievalPlotData) -> MarginalArtists: + """Draw the delay marginal of the retrieved and measured traces.""" + pr = data.processed + artists = plot_marginal( + ax, + data.result.delays, + pr.tau_marg_retr, + pr.tau_marg_meas, + xlabel="Delay (fs)", + scale=1e-15, + halfwidth=data.halfwidth, + ) + ax.set_title("Delay marginal") + return artists + + +def draw_spectrogram(ax, data: RetrievalPlotData) -> SpectrogramArtists: + """Draw the Gabor spectrogram of the retrieved pulse (computed once, cached).""" + tc, lam, S = data.spectrogram + mesh = plot_spectrogram( + ax, + tc, + lam, + S, + lam_min=data.lam_min, + lam_max=data.lam_max, + halfwidth=data.halfwidth, + # white at zero, like the trace panels and the dispersion stage's own + # spectrogram — this panel was the only one left on plain viridis. + cmap=data.cmap_pos, + ) + return SpectrogramArtists(mesh=mesh) + + +def _interaction_scale(name: str) -> float: + from ..interactions import get_interaction + + return get_interaction(name).omega0_scale diff --git a/croak/plotting/pulse.py b/croak/plotting/pulse.py new file mode 100644 index 0000000..80252d9 --- /dev/null +++ b/croak/plotting/pulse.py @@ -0,0 +1,446 @@ +"""The retrieved pulse: temporal, spectral and spectrogram panels.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +import numpy as np + +from ..maths import wlfreq +from ..processing import ProcessedResult, TruthPulse +from .artists import SpectralArtists, TemporalArtists +from .traces import cmap_white +from .units import _TWOPI_PHZ, _fs, si_power + +if TYPE_CHECKING: + from matplotlib.legend import Legend + from numpy.typing import NDArray + + +#: Edge-energy fraction (see :func:`croak.processing.edge_energy_fraction`) above +#: which the spectrum panel flags the retrieval in red. A sound retrieval on a +#: generous band sits two orders of magnitude below this; above it, re-run on a +#: wider grid. +EDGE_ENERGY_WARN = 0.01 + + +#: Abscissae the spectrum panel can be drawn against (see :func:`plot_spectral`). +SPECTRAL_AXES: tuple[str, ...] = ("frequency", "wavelength") +type SpectralAxis = Literal["frequency", "wavelength"] + + +def _combined_legend(ax, axp, *, loc: str) -> Legend: + """Draw one legend containing intensity and twin-axis phase curves. + + ``loc`` is a fixed corner: matplotlib's ``"best"`` re-evaluates on every draw + and made the legend jump between frames of the live preview. + """ + handles, labels = ax.get_legend_handles_labels() + phase_handles, phase_labels = axp.get_legend_handles_labels() + return ax.legend( + handles + phase_handles, + labels + phase_labels, + loc=loc, + handlelength=1.0, + frameon=False, + fontsize="small", + ) + + +def _temporal_curves(pr: ProcessedResult, power_scale): + """Return ``(retr, tl, r_label, tl_label, ylabel, power_scale)`` for the pulse. + + In absolute power when ``pr.peak_power`` is known (deriving the SI prefix if + ``power_scale`` is None), else normalised. Shared by draw and update. + """ + if pr.peak_power is not None: + if power_scale is None: + power_scale = si_power(max(pr.peak_power, pr.peak_power_tl or 0.0)) + scale, unit = power_scale + retr = pr.It_retr * pr.peak_power / scale + tl = pr.It_tl * (pr.peak_power_tl or 0.0) / scale + r_label = f"R ({_fs(pr.fwhm_retr)} fs, {pr.peak_power / scale:.1f} {unit})" + tl_label = ( + f"TL ({_fs(pr.fwhm_tl)} fs, {(pr.peak_power_tl or 0.0) / scale:.1f} {unit})" + ) + return retr, tl, r_label, tl_label, f"Power ({unit})", power_scale + r_label = f"R ({_fs(pr.fwhm_retr)} fs)" + tl_label = f"TL ({_fs(pr.fwhm_tl)} fs)" + return pr.It_retr, pr.It_tl, r_label, tl_label, "Power (a.u.)", None + + +def _truth_intensity(truth: TruthPulse, energy, power_scale) -> NDArray[np.float64]: + """Return the truth's intensity on the temporal panel's scale. + + With a pulse energy the truth is shown in absolute power on the shared SI + scale: it carries the same energy, so its peak power is energy/∫(I/I_peak)dt + over its *own* duration (:meth:`~croak.processing.TruthPulse.peak_power`). + """ + peak_power = truth.peak_power(energy) + if peak_power is None: + return np.asarray(truth.It, dtype=float) + scale = power_scale[0] if power_scale is not None else 1.0 + return np.asarray(truth.It, dtype=float) * peak_power / scale + + +def _intensity_ylim(*curves) -> tuple[float, float]: + """``(0, 1.05 × max)`` over the given curves (``None`` entries ignored). + + Set explicitly because ``Axes.set_ylim`` switches autoscaling off, so an + in-place update could not rely on it; the 5 % headroom is matplotlib's + default margin. + """ + tops = [float(np.nanmax(c)) for c in curves if c is not None and np.size(c)] + top = max(tops, default=1.0) + return 0.0, 1.05 * top if top > 0 else 1.0 + + +PHASE_MIN_HALF_SPAN = 1.0 +"""The least half-range of a phase axis (rad). + +A nearly flat phase would otherwise fill the axis with its rounding noise +(a transform-limited pulse's phase varies by ~1e-15 rad) and look structured.""" + + +def _phase_ylim(*curves) -> tuple[float, float] | None: + """Phase-axis limits centred on the shown phases with their span as margin. + + Finite samples of every non-empty curve count; ``None`` when nothing is shown. + The half-range is at least :data:`PHASE_MIN_HALF_SPAN`. + """ + arrays = [np.asarray(c, dtype=float) for c in curves if c is not None] + arrays = [a[np.isfinite(a)] for a in arrays] + arrays = [a for a in arrays if a.size] + if not arrays: + return None + pm = np.concatenate(arrays) + d = max(float(pm.max() - pm.min()), PHASE_MIN_HALF_SPAN) + mid = (pm.max() + pm.min()) / 2 + return mid - d, mid + d + + +def _temporal_panel( + ax, + pr: ProcessedResult, + *, + halfwidth=None, + power_scale=None, + truth: TruthPulse | None = None, +) -> TemporalArtists: + """Draw the temporal panel into ``ax``; return its artists (see plot_temporal).""" + t = pr.t_over / 1e-15 + retr, tl, r_label, tl_label, ylabel, power_scale = _temporal_curves(pr, power_scale) + (line_retr,) = ax.plot(t, retr, c="C0", label=r_label) + # The TL pulse is peak-centred on its own oversampled axis (its peak index + # need not coincide with the retrieved pulse's), so plot it against t_tl — + # using t_over would shift its peak off t=0 by a sample or two. + (line_tl,) = ax.plot(pr.t_tl / 1e-15, tl, c="C1", label=tl_label) + line_truth = None + truth_curve = None + if truth is not None: + # The known pulse as a thin black line *under* the retrieved/TL curves, in + # physical units so it is independent of the retrieval grid. A retrieval + # is defined only up to the time-direction ambiguity, so the truth may + # appear shifted or flipped. Its legend entry carries its own FWHM so the + # three durations read side by side. + truth_curve = _truth_intensity(truth, pr.energy, power_scale) + (line_truth,) = ax.plot( + truth.t / 1e-15, + truth_curve, + c="k", + lw=0.8, + zorder=1, + label=f"truth ({_fs(truth.fwhm)} fs)", + ) + ax.set_xlabel("Time (fs)") + ax.set_ylabel(ylabel) + ax.set_ylim(*_intensity_ylim(retr, tl, truth_curve)) + ax.set_title("Retrieved pulse") + if halfwidth is not None: + ax.set_xlim(-halfwidth / 1e-15, halfwidth / 1e-15) + axp = ax.twinx() + (line_phase,) = axp.plot(t[pr.mask_t], pr.phi_t[pr.mask_t], c="C2") + axp.set_ylabel("Phase (rad)") + line_truth_phase = None + truth_phase = None + if truth is not None and truth.phi_t is not None: + truth_mask = np.asarray(truth.It) > 0.01 + truth_phase = np.asarray(truth.phi_t)[truth_mask] + (line_truth_phase,) = axp.plot( + np.asarray(truth.t)[truth_mask] / 1e-15, + truth_phase, + ":", + c="0.35", + lw=1.2, + label="truth phase", + ) + limits = _phase_ylim(pr.phi_t[pr.mask_t], truth_phase) + if limits is not None: + axp.set_ylim(*limits) + # Upper left: the pulse and its masked phase sit at the centre, the wings are + # empty, and a fixed corner cannot jump between live frames. + legend = _combined_legend(ax, axp, loc="upper left") + texts = legend.get_texts() + return TemporalArtists( + line_retr=line_retr, + line_tl=line_tl, + line_phase=line_phase, + ax_phase=axp, + legend=legend, + legend_retr=texts[0], + legend_tl=texts[1], + line_truth=line_truth, + line_truth_phase=line_truth_phase, + ) + + +def plot_temporal( + ax, + pr: ProcessedResult, + *, + halfwidth=None, + power_scale=None, + truth: TruthPulse | None = None, +): + """Temporal intensity/power (left) and phase (right twin axis); phase masked. + + When ``pr.peak_power`` is set (a measured pulse energy was supplied to + :func:`~croak.processing.process_result`), the intensity is shown as absolute + instantaneous power on an SI-prefixed axis; otherwise it stays normalised + ("a.u."). ``power_scale`` is an optional ``(scale, unit)`` pair (see + :func:`si_power`) so several panels can share one prefix; when ``None`` it is + derived from the larger of the retrieved/TL peak powers. + + ``truth`` (a :class:`~croak.processing.TruthPulse`: a synthetic generating + field, or a simulated scan's reference intensity) is overlaid as a thin black + line under the retrieved/TL curves, labelled with its own FWHM, and — when it + carries a temporal phase — as a dotted phase in the same display gauge as the + retrieved one (see :func:`croak.processing.normalize_temporal_phase`). + + Returns the twin phase axes. :func:`draw_temporal` is the same panel for the + registry, returning every artist (:class:`TemporalArtists`). + """ + return _temporal_panel( + ax, pr, halfwidth=halfwidth, power_scale=power_scale, truth=truth + ).ax_phase + + +def _truth_spectral_phase( + wavelength: NDArray[np.float64], truth: TruthPulse | None +) -> NDArray[np.float64] | None: + """Return the truth's spectral phase interpolated onto ``wavelength``, or None. + + The truth is stored on the simulation's own frequency grid, which is neither + the same spacing nor the same extent as the retrieval's; NaN outside it. + """ + tphi_src = getattr(truth, "phi_w", None) if truth is not None else None + tlam_src = getattr(truth, "lam", None) if truth is not None else None + if tphi_src is None or tlam_src is None: + return None + return np.interp( + wavelength, + np.asarray(tlam_src, dtype=float), + np.asarray(tphi_src, dtype=float), + left=np.nan, + right=np.nan, + ) + + +def _dispersion_text(pr: ProcessedResult) -> str: + """Return the GDD/TOD annotation of the spectrum panel.""" + return f"GDD: {pr.gdd_fs2:.1f} fs²\nTOD: {pr.tod_fs3:.1f} fs³" + + +def _edge_text(pr: ProcessedResult) -> tuple[str, str]: + """Return the edge-energy annotation and its colour (red once it matters). + + Energy parked in the unmeasured grid-edge bins is an artefact indicator; the + 1 % threshold sits comfortably above what a sound retrieval produces. + """ + color = "C3" if pr.edge_energy > EDGE_ENERGY_WARN else "0.35" + return f"Edge: {100 * pr.edge_energy:.2f} %", color + + +def _unit_peak(values: NDArray[np.float64]) -> NDArray[np.float64]: + """Scale ``values`` to a peak of one (NaNs ignored; all-zero left alone).""" + peak = float(np.nanmax(values)) if values.size else 0.0 + return values / peak if peak > 0 else values + + +def _spectral_curves(pr: ProcessedResult, axis: str): + """Return ``(x, retrieved, measured, xlabel)`` of the spectrum panel on ``axis``. + + Both spectra are unit-peak densities *in the chosen variable*. On the + wavelength axis they are ``pr.Ilam`` and ``pr.Iw_meas`` — both wavelength + densities (the measured one carries the ω² Jacobian despite its name); on the + frequency axis the retrieved spectrum is ``|E(ω)|²`` itself and the measured + one has that Jacobian divided out again, so a spectrum symmetric in ω plots + symmetric. ``x`` is per grid index on both axes, so the phase curves index it + with ``pr.mask_w`` unchanged. + """ + if axis == "wavelength": + return pr.wavelength / 1e-9, pr.Ilam, pr.Iw_meas, "Wavelength (nm)" + if axis != "frequency": + raise ValueError(f"axis must be one of {SPECTRAL_AXES}, got {axis!r}") + omega_abs = pr.omega + pr.omega0 + measured = None + if pr.Iw_meas is not None: + # A centred grid can cross ω = 0; that sample is undefined as a density. + with np.errstate(divide="ignore", invalid="ignore"): + jacobian = np.where(omega_abs != 0, 1.0 / omega_abs**2, np.nan) + measured = _unit_peak(pr.Iw_meas * jacobian) + return omega_abs / _TWOPI_PHZ, _unit_peak(pr.Iw), measured, "Frequency (PHz)" + + +def _spectral_xlim(lam_min, lam_max, axis: str) -> tuple[float, float] | None: + """Return the spectrum panel's x-limits for a wavelength window on ``axis``.""" + if not (lam_min and lam_max): + return None + if axis == "wavelength": + return lam_min / 1e-9, lam_max / 1e-9 + return float(wlfreq(lam_max)) / _TWOPI_PHZ, float(wlfreq(lam_min)) / _TWOPI_PHZ + + +def _truth_spectrum(truth: TruthPulse, axis: str): + """Return the truth's spectrum ``(x, unit-peak density)`` on ``axis``, or None.""" + if truth.lam is None or truth.Iw is None: + return None + if axis == "wavelength": + return truth.lam / 1e-9, truth.Iw + omega = wlfreq(truth.lam) + return omega / _TWOPI_PHZ, _unit_peak(np.asarray(truth.Iw) / omega**2) + + +def _spectral_panel( + ax, + pr: ProcessedResult, + *, + lam_min=None, + lam_max=None, + truth: TruthPulse | None = None, + axis: str = "frequency", +) -> SpectralArtists: + """Draw the spectrum panel into ``ax``; return its artists (see plot_spectral).""" + x, retrieved, measured, xlabel = _spectral_curves(pr, axis) + (line_retr,) = ax.plot(x, retrieved, c="C0", label="R") + line_meas = None + if measured is not None: + (line_meas,) = ax.plot(x, measured, c="C1", label="M") + line_truth = None + truth_curve = None if truth is None else _truth_spectrum(truth, axis) + if truth_curve is not None: + (line_truth,) = ax.plot(*truth_curve, c="k", lw=0.8, zorder=1, label="truth") + ax.set_xlabel(xlabel) + ax.set_ylabel("Power (a.u.)") + ax.set_ylim(0, 1.05) + ax.set_title("Spectrum") + xlim = _spectral_xlim(lam_min, lam_max, axis) + if xlim is not None: + ax.set_xlim(*xlim) + axp = ax.twinx() + (line_phase,) = axp.plot(x[pr.mask_w], pr.phi_w[pr.mask_w], c="C2") + # dashed polynomial fit of the spectral phase (GDD/TOD visualisation) + (line_phase_fit,) = axp.plot( + x[pr.mask_w], pr.phi_w_fit[pr.mask_w], "--", c="C4", alpha=0.7 + ) + axp.set_ylabel("Phase (rad)") + tphi = _truth_spectral_phase(pr.wavelength, truth) + line_truth_phase = None + if tphi is not None: + (line_truth_phase,) = axp.plot( + x[pr.mask_w], + tphi[pr.mask_w], + ":", + c="0.35", + lw=1.2, + label="truth phase", + ) + limits = _phase_ylim(pr.phi_w[pr.mask_w], None if tphi is None else tphi[pr.mask_w]) + if limits is not None: + axp.set_ylim(*limits) + text_dispersion = ax.text( + 0.05, 0.78, _dispersion_text(pr), transform=ax.transAxes, fontsize="small" + ) + edge, edge_color = _edge_text(pr) + text_edge = ax.text( + 0.05, 0.70, edge, transform=ax.transAxes, fontsize="small", color=edge_color + ) + # Upper right: the GDD/TOD and edge annotations occupy the upper left. + legend = _combined_legend(ax, axp, loc="upper right") + return SpectralArtists( + line_retr=line_retr, + line_phase=line_phase, + line_phase_fit=line_phase_fit, + ax_phase=axp, + legend=legend, + text_dispersion=text_dispersion, + text_edge=text_edge, + line_meas=line_meas, + line_truth=line_truth, + line_truth_phase=line_truth_phase, + ) + + +def plot_spectral( + ax, + pr: ProcessedResult, + *, + lam_min=None, + lam_max=None, + truth: TruthPulse | None = None, + axis: SpectralAxis = "frequency", +): + """Spectral intensity (left) and phase (right twin axis) against ``axis``. + + ``axis`` is ``"frequency"`` (PHz, the default: the space the retrieval works in, + where ``|E(ω)|²`` of a symmetric pulse is symmetric) or ``"wavelength"`` (nm, the + spectrometer's axis, carrying the 1/λ² density Jacobian). Both spectra are drawn as + unit-peak densities in the chosen variable; ``lam_min``/``lam_max`` (m) bound the + axis on either. The phase curves are the same on both. + + ``truth``, when it carries a spectrum, is overlaid as a thin black line under + the retrieved curve; when it carries a known spectral phase, that phase is + overlaid on the phase axis. It is drawn in the SAME gauge as the retrieved + phase (unwrapped, de-tilted, zeroed at the spectral peak — see + :func:`croak.processing.normalize_spectral_phase`), because spectral phase + is defined only up to a constant and a linear term; a truth pinned any other + way would show a tilt or offset that is gauge, not error. + + Returns the twin phase axes. :func:`draw_spectral` is the same panel for the + registry, returning every artist (:class:`SpectralArtists`). + """ + return _spectral_panel( + ax, pr, lam_min=lam_min, lam_max=lam_max, truth=truth, axis=axis + ).ax_phase + + +def plot_spectrogram( + ax, + t_centers, + wavelength, + S, + *, + lam_min=None, + lam_max=None, + halfwidth=None, + cmap=None, +): + """Plot a Gabor spectrogram (time fs × wavelength nm).""" + if cmap is None: + cmap = cmap_white("viridis") + m = ax.pcolormesh( + t_centers / 1e-15, + wavelength / 1e-9, + S, + cmap=cmap, + rasterized=True, + shading="auto", + ) + ax.set_xlabel("Time (fs)") + ax.set_ylabel("Wavelength (nm)") + ax.set_title("Spectrogram") + if lam_min and lam_max: + ax.set_ylim(lam_min / 1e-9, lam_max / 1e-9) + if halfwidth is not None: + ax.set_xlim(-halfwidth / 1e-15, halfwidth / 1e-15) + return m diff --git a/croak/plotting/retrieval.py b/croak/plotting/retrieval.py new file mode 100644 index 0000000..5b177c5 --- /dev/null +++ b/croak/plotting/retrieval.py @@ -0,0 +1,295 @@ +"""The retrieval summary: panel registry, pages, the overview and the page view. + +The twelve panels of :mod:`croak.plotting.panels` are registered in +:data:`RETRIEVAL_PANELS`; :func:`plot_retrieval` lays them out as the 3×4 +overview and :func:`plot_retrieval_page` draws one of the three 2×2 pages +(:data:`RETRIEVAL_PAGES`) the GUI shows as tabs. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from matplotlib.figure import Figure +from numpy.typing import ArrayLike + +from ..processing import ProcessedResult, TruthPulse +from ..result import RetrievalResult +from .artists import PanelArtists, ResidualArtists, TraceArtists +from .figure import _prepare_fig +from .panels import ( + RetrievalPlotData, + draw_convergence, + draw_delay_marginal, + draw_freq_marginal, + draw_measured_lin, + draw_measured_log, + draw_residual, + draw_retrieved_lin, + draw_retrieved_log, + draw_spectral, + draw_spectral_filter, + draw_spectrogram, + draw_temporal, + retrieval_plot_data, +) +from .pulse import SpectralAxis + +if TYPE_CHECKING: + from matplotlib.axes import Axes + from matplotlib.collections import QuadMesh + + +type PanelDraw = Callable[[Axes, RetrievalPlotData], PanelArtists] + + +@dataclass(frozen=True) +class PanelSpec: + """One retrieval panel: registry key, axes title and how to draw it.""" + + key: str + title: str + draw: PanelDraw + + +@dataclass(frozen=True) +class PageSpec: + """A figure's worth of panels laid out as a grid of registry keys. + + Attributes + ---------- + key, title : str + Identifier and human-readable name (the GUI's tab label). + mosaic : tuple of tuple of str + Rows of panel keys, as + :external+matplotlib:meth:`~matplotlib.figure.Figure.subplot_mosaic` + takes them. + """ + + key: str + title: str + mosaic: tuple[tuple[str, ...], ...] + + @property + def keys(self) -> tuple[str, ...]: + """The page's panel keys in reading order.""" + return tuple(key for row in self.mosaic for key in row) + + +@dataclass(frozen=True) +class ColorbarSpec: + """A colorbar shared by the listed panels (drawn only when all are present). + + A positive colorbar is always drawn; a second, ``neg_label``-ed one appears + when any of the panels holds negative samples (see :func:`signed_pcolormesh`). + """ + + panels: tuple[str, ...] + shrink: float = 0.6 + neg_label: str = "neg |·|" + + +#: The twelve retrieval panels, keyed, in the overview's reading order. +RETRIEVAL_PANELS: Mapping[str, PanelSpec] = { + spec.key: spec + for spec in ( + PanelSpec("measured_log", "Measured (log)", draw_measured_log), + PanelSpec("retrieved_log", "Retrieved (log)", draw_retrieved_log), + PanelSpec("temporal", "Retrieved pulse", draw_temporal), + PanelSpec("delay_marginal", "Delay marginal", draw_delay_marginal), + PanelSpec("measured_lin", "Measured (lin)", draw_measured_lin), + PanelSpec("retrieved_lin", "Retrieved (lin)", draw_retrieved_lin), + PanelSpec("spectral", "Spectrum", draw_spectral), + PanelSpec("freq_marginal", "Frequency marginal", draw_freq_marginal), + PanelSpec("convergence", "Convergence", draw_convergence), + PanelSpec("residual", "Residuals", draw_residual), + PanelSpec("spectral_filter", "Spectral filter", draw_spectral_filter), + PanelSpec("spectrogram", "Spectrogram", draw_spectrogram), + ) +} + +#: Colorbars: one per trace pair (they share a scale) and one for the residual. +RETRIEVAL_COLORBARS: tuple[ColorbarSpec, ...] = ( + ColorbarSpec(("measured_log", "retrieved_log")), + ColorbarSpec(("measured_lin", "retrieved_lin")), + ColorbarSpec(("residual",)), +) + +#: The three 2×2 pages the GUI shows as tabs. +RETRIEVAL_PAGES: Mapping[str, PageSpec] = { + page.key: page + for page in ( + PageSpec( + "traces", + "Traces", + (("measured_log", "retrieved_log"), ("measured_lin", "retrieved_lin")), + ), + # Each marginal sits under the domain it belongs to: delay under the + # time-domain pulse, frequency under the spectrum. + PageSpec( + "pulse", + "Pulse", + (("temporal", "spectral"), ("delay_marginal", "freq_marginal")), + ), + PageSpec( + "diagnostics", + "Diagnostics", + (("convergence", "residual"), ("spectral_filter", "spectrogram")), + ), + ) +} + +#: The 3×4 overview :func:`plot_retrieval` draws: traces left, pulse right (the +#: delay marginal beside the time-domain pulse, the frequency marginal beside the +#: spectrum), diagnostics along the bottom. +RETRIEVAL_OVERVIEW = PageSpec( + "overview", + "Retrieval overview", + ( + ("measured_log", "retrieved_log", "temporal", "delay_marginal"), + ("measured_lin", "retrieved_lin", "spectral", "freq_marginal"), + ("convergence", "residual", "spectral_filter", "spectrogram"), + ), +) + + +def _mappables(artists: PanelArtists) -> tuple[QuadMesh | None, QuadMesh | None]: + """Return the ``(positive, negative)`` images a colorbar can attach to.""" + if isinstance(artists, TraceArtists): + return artists.mesh_pos, artists.mesh_neg + if isinstance(artists, ResidualArtists): + return artists.mesh, None + return None, None + + +def _draw_colorbars( + fig, axd: Mapping[str, Axes], artists: Mapping[str, PanelArtists] +) -> None: + """Add every :data:`RETRIEVAL_COLORBARS` entry whose panels are all in ``axd``.""" + for spec in RETRIEVAL_COLORBARS: + if not all(key in axd for key in spec.panels): + continue + axes = [axd[key] for key in spec.panels] + pairs = [_mappables(artists[key]) for key in spec.panels] + pos = next((p for p, _ in pairs if p is not None), None) + if pos is None: + continue + # Both panels of a pair share vmin/vmax and cmap, so either can seed the bar. + fig.colorbar(pos, ax=axes, shrink=spec.shrink) + neg = next((n for _, n in pairs if n is not None), None) + if neg is not None: + fig.colorbar(neg, ax=axes, shrink=spec.shrink, label=spec.neg_label) + + +def _draw_panels( + fig, page: PageSpec, data: RetrievalPlotData +) -> tuple[dict[str, Axes], dict[str, PanelArtists]]: + """Lay ``page``'s mosaic out in ``fig``, draw every panel and its colourbars.""" + axd = fig.subplot_mosaic([list(row) for row in page.mosaic]) + artists = {key: RETRIEVAL_PANELS[key].draw(ax, data) for key, ax in axd.items()} + _draw_colorbars(fig, axd, artists) + return axd, artists + + +def plot_retrieval_page( + data: RetrievalPlotData, + page: PageSpec, + *, + figsize: tuple[float, float] = (8.5, 6.3), + fig: Figure | None = None, +) -> Figure: + """Draw one page of retrieval panels (a :data:`RETRIEVAL_PAGES` entry). + + The default ``figsize`` gives a 2×2 page the same per-panel size as the + overview's default 17.0×9.45 in. + + Parameters + ---------- + data : RetrievalPlotData + From :func:`retrieval_plot_data`. + page : PageSpec + Which panels, in which grid; :data:`RETRIEVAL_OVERVIEW` draws all twelve. + figsize : tuple of float + Size (in) of a new figure; ignored when ``fig`` is given. + fig : Figure, optional + Existing figure to clear and draw into (a GUI canvas). + + Examples + -------- + >>> data = retrieval_plot_data(result, measured=trace) # doctest: +SKIP + >>> fig = plot_retrieval_page(data, RETRIEVAL_PAGES["pulse"]) # doctest: +SKIP + """ + fig = _prepare_fig(fig, figsize) + _draw_panels(fig, page, data) + return fig + + +def plot_retrieval( + result: RetrievalResult, + *, + measured: ArrayLike | None = None, + Iomega_meas: ArrayLike | None = None, + lam_min: float | None = None, + lam_max: float | None = None, + flim: tuple[float, float] | None = None, + tracedb: float = 30.0, + cmap: str = "viridis", + truth: TruthPulse | None = None, + energy: float | None = None, + figsize=(17.0, 9.45), + fig: Figure | None = None, + processed: ProcessedResult | None = None, + spectral_axis: SpectralAxis = "frequency", +) -> Figure: + """12-panel retrieval summary: the standard at-a-glance quality check. + + The panels are :data:`RETRIEVAL_PANELS` in the :data:`RETRIEVAL_OVERVIEW` + layout — measured/retrieved traces (log and linear) on the left, the pulse, + spectrum and marginals on the right, convergence, residual, spectral filter + and spectrogram along the bottom. :func:`plot_retrieval_page` draws a subset. + + ``lam_min``/``lam_max`` (m) bound the **wavelength** axes — the spectrum and + spectrogram panels. ``flim`` bounds the **frequency** axis of the trace and + residual panels, as ``(f_min, f_max)`` in Hz; the default shows the whole + retrieval grid. The two are separate because the trace sits at the *signal* + frequency, which for SHG is twice the pulse's. Reach for ``flim`` when the + retrieval grid is much wider than the signal — typical when retrieving + directly on a synthesis grid rather than on one cropped by + :func:`~croak.preprocess.load_and_clean` — where the trace would otherwise be + a thin stripe in a mostly empty axis. + + ``energy`` is an optional measured pulse energy (J); when given, the temporal + panel is shown in absolute power (see :func:`~croak.processing.process_result`). + + ``processed`` is an optional, already-computed + :class:`~croak.processing.ProcessedResult` **for this same result**: pass it when + the caller needs the processed quantities too (the GUI shows them in a table + beside the figure) so they are not derived twice. It must have been built with + the same ``measured``/``Iomega_meas``/``energy``, which are then used only for + the trace panels; a mismatched one raises :class:`ValueError`. + + ``spectral_axis`` draws the spectrum panel against ``"frequency"`` (PHz, the + default) or ``"wavelength"`` (nm); see :func:`plot_spectral`. + + Raises + ------ + ValueError + If ``result`` carries no trace, or ``processed`` belongs to another result. + """ + data = retrieval_plot_data( + result, + measured=measured, + Iomega_meas=Iomega_meas, + lam_min=lam_min, + lam_max=lam_max, + flim=flim, + tracedb=tracedb, + cmap=cmap, + truth=truth, + energy=energy, + processed=processed, + spectral_axis=spectral_axis, + ) + return plot_retrieval_page(data, RETRIEVAL_OVERVIEW, figsize=figsize, fig=fig) diff --git a/croak/plotting/screen.py b/croak/plotting/screen.py new file mode 100644 index 0000000..53270a2 --- /dev/null +++ b/croak/plotting/screen.py @@ -0,0 +1,1178 @@ +"""What the GUI's interactive retrieval views show, as plain data. + +The matplotlib panels (:mod:`croak.plotting.panels`) draw a retrieval for a +figure; this module turns the same :class:`RetrievalPlotData` into the arrays an +interactive view draws: :class:`MapView` for an image and :class:`LineView` for +curves. Both use the panels' own helpers, so a curve on screen is the curve in +an exported figure. Nothing here imports Qt. + +Two display choices differ from the figures. An image is resampled onto evenly +spaced axes (an interactive image is placed by its corners, a ``pcolormesh`` by +every edge), and a logarithmic trace shows the positive part only: negative +samples, from background subtraction, are visible on the linear maps, where the +signed colour map shows them in red. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ..maths import wlfreq +from .panels import RetrievalPlotData, _filter_factor +from .pulse import ( + EDGE_ENERGY_WARN, + _dispersion_text, + _intensity_ylim, + _phase_ylim, + _spectral_curves, + _spectral_xlim, + _temporal_curves, + _truth_intensity, + _truth_spectral_phase, + _truth_spectrum, +) +from .traces import _decimate_for_display, filter_view_vmax +from .units import _TWOPI_PHZ + +__all__ = [ + "CURVE_COLOURS", + "DB_DEPTH", + "DB_UNIT", + "FREQUENCY_LABEL", + "WAVELENGTH_LABEL", + "ROLE_STYLE", + "CurveView", + "LineView", + "MapView", + "convergence_view", + "delay_marginal_view", + "filter_view", + "filter_views", + "frequency_marginal_view", + "pulse_field_views", + "raw_trace_map", + "residual_map", + "simulated_trace_views", + "spectral_view", + "spectral_view_of", + "spectrogram_map", + "spectrogram_map_of", + "temporal_view", + "temporal_view_of", + "trace_map", + "uncertainty_views", + "uniform_image", +] + +CURVE_COLOURS = ( + "#1f6fb4", # blue + "#d2670b", # orange + "#2a9d6a", # green + "#8c5bb5", # purple + "#c2445c", # red + "#5a6675", # grey + "#b08a16", # ochre + "#2b8fb3", # cyan +) +"""The curves' colours, in order of use, on screen and in exported figures.""" + +_INK = "#1b2430" +_MUTED = "#5a6675" + +ROLE_STYLE: dict[str, tuple[str, bool, bool]] = { + "retrieved": (CURVE_COLOURS[0], False, False), + "transform-limited": (CURVE_COLOURS[1], False, False), + "measured": (CURVE_COLOURS[1], False, False), + "truth": (_INK, False, True), + "phase": (CURVE_COLOURS[2], False, False), + "fit": (CURVE_COLOURS[3], True, False), + "truth-phase": (_MUTED, True, True), + "error": (CURVE_COLOURS[0], False, False), + "estimate": (CURVE_COLOURS[4], False, False), + "prior": ("#9aa3ad", False, False), + "predicted": (CURVE_COLOURS[4], True, False), + "corrected": (CURVE_COLOURS[0], True, False), + "faint": ("#9cbfdd", False, False), + "boundary": (_MUTED, True, False), +} +"""Each role a view gives its curves: colour, dashed, and whether it is a thin +reference drawn beneath the rest (the known pulse). The screen and the exported +figures both draw from it.""" + +ROLES = tuple(ROLE_STYLE) +"""The roles a view may give its curves, bands, spans and rules.""" + +WAVELENGTH_LABEL = "Wavelength (nm)" +"""The label of a wavelength axis, in nm; :mod:`croak.plotting.display` knows it.""" +FREQUENCY_LABEL = "Frequency (PHz)" +"""The label of a frequency axis, in PHz; :mod:`croak.plotting.display` knows it.""" +DB_UNIT = "dB" +"""The colour-bar unit of a logarithmic map, whose peak is 0 dB.""" +DB_DEPTH = 120.0 +"""How far below the peak a logarithmic map keeps its values, dB: a display +range up to this much (:class:`croak.plotting.display.Display`) shows detail.""" + + +@dataclass(frozen=True) +class MapView: + """An image on evenly spaced axes. + + Attributes + ---------- + values : ndarray, shape (len(y), len(x)) + What is drawn, in the units of ``levels``. + x, y : ndarray + Increasing, evenly spaced sample positions. + levels : tuple of float + The colour scale's span. + title, xlabel, ylabel, unit : str + The view's title, axis labels and colour-bar label. + colours : str + The colour map's name. + signed : bool + Values below zero are meaningful (draw them in red, fading to white at 0). + xrange, yrange : tuple of float or None + The view's initial window (``None``: the whole image). + """ + + values: NDArray[np.float64] + x: NDArray[np.float64] + y: NDArray[np.float64] + levels: tuple[float, float] + title: str + xlabel: str + ylabel: str + unit: str = "" + colours: str = "viridis" + signed: bool = False + xrange: tuple[float, float] | None = None + yrange: tuple[float, float] | None = None + + +@dataclass(frozen=True) +class CurveView: + """One curve of a :class:`LineView`; ``role`` decides its colour and style. + + ``points`` draws the samples as dots (a scatter) instead of a line. + ``density`` says ``y`` is an intensity per unit of ``x`` (a spectrum, a + marginal), so moving it to another spectral axis multiplies it by the + Jacobian; a phase or a filter factor is not one. + """ + + x: NDArray[np.float64] + y: NDArray[np.float64] + role: str + name: str = "" + right: bool = False + points: bool = False + density: bool = True + + +@dataclass(frozen=True) +class BandView: + """A shaded band between two curves over ``x`` (e.g. a confidence band).""" + + x: NDArray[np.float64] + low: NDArray[np.float64] + high: NDArray[np.float64] + role: str + name: str = "" + opacity: float = 0.25 + + +@dataclass(frozen=True) +class SpanView: + """A shaded interval across the plot: of x (``vertical``) or of y.""" + + low: float + high: float + role: str + name: str = "" + vertical: bool = True + opacity: float = 0.15 + + +@dataclass(frozen=True) +class BarsView: + """A histogram: bin ``edges`` and the ``counts`` in each bin.""" + + edges: NDArray[np.float64] + counts: NDArray[np.float64] + role: str + + +@dataclass(frozen=True) +class LineView: + """Curves against one x axis, with an optional right-hand axis. + + Attributes + ---------- + curves : tuple of CurveView + Left-axis curves first, then any on the right axis. + title, xlabel, ylabel : str + Labels. + right_label : str or None + The right-hand axis's label; ``None`` for none. + xrange, yrange, right_range : tuple of float or None + The initial window of each axis. + log_y : bool + A logarithmic left axis. + notes : tuple of (str, bool) + Short annotations, each with whether it is a warning. + marks : tuple of (float, str) + Vertical rules: x position and role (the role sets colour and style). + levels : tuple of (float, str) + Horizontal rules: y position and role. + bands, spans : tuple + Shaded bands between curves and shaded intervals across the plot. + bars : BarsView or None + A histogram drawn beneath the curves. + """ + + curves: tuple[CurveView, ...] + title: str + xlabel: str + ylabel: str + right_label: str | None = None + xrange: tuple[float, float] | None = None + yrange: tuple[float, float] | None = None + right_range: tuple[float, float] | None = None + log_y: bool = False + notes: tuple[tuple[str, bool], ...] = field(default_factory=tuple) + marks: tuple[tuple[float, str], ...] = field(default_factory=tuple) + levels: tuple[tuple[float, str], ...] = field(default_factory=tuple) + bands: tuple[BandView, ...] = field(default_factory=tuple) + spans: tuple[SpanView, ...] = field(default_factory=tuple) + bars: BarsView | None = None + + +MAX_SAMPLES = 2048 +"""The most samples an evenly resampled image axis gets.""" + + +def _evenly(axis: NDArray[np.float64]) -> NDArray[np.float64]: + """``axis`` if its steps are equal (to 0.1 %), else an evenly spaced copy. + + The copy keeps the finest spacing of ``axis`` (up to :data:`MAX_SAMPLES` + points), so resampling loses no detail where the samples are densest — a + wavelength axis from an even frequency grid is far denser at short + wavelengths than at long ones. + """ + steps = np.diff(axis) + if axis.size < 3 or np.allclose(steps, steps[0], rtol=1e-3, atol=0.0): + return axis + count = int(np.ceil((axis[-1] - axis[0]) / steps.min())) + 1 + return np.linspace(axis[0], axis[-1], int(np.clip(count, axis.size, MAX_SAMPLES))) + + +def uniform_image( + x: ArrayLike, y: ArrayLike, values: ArrayLike +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: + """Put an image on increasing, evenly spaced axes for display. + + A decreasing axis is reversed with the image; an uneven one (a wavelength + axis from an even frequency grid, an uneven delay scan) is resampled by + linear interpolation, which moves no feature: each sample stays at its own + position. The physics arrays are untouched; this is for drawing only. + + Parameters + ---------- + x, y : array_like + The sample positions, monotonic. + values : array_like, shape (len(y), len(x)) + The image. + + Returns + ------- + tuple of ndarray + ``(x, y, values)`` on even, increasing axes. + + Raises + ------ + ValueError + If the shapes disagree or an axis has fewer than two samples. + + Examples + -------- + >>> x, y, v = uniform_image([2.0, 1.0, 0.0], [0.0, 1.0], [[3, 2, 1], [6, 5, 4]]) + >>> x.tolist(), v[0].tolist() + ([0.0, 1.0, 2.0], [1.0, 2.0, 3.0]) + """ + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + values = np.asarray(values, dtype=float) + if values.shape != (y.size, x.size): + raise ValueError(f"values {values.shape} do not match y×x {(y.size, x.size)}") + if x.size < 2 or y.size < 2: + raise ValueError("an image needs at least two samples along each axis") + if x[0] > x[-1]: + x, values = x[::-1], values[:, ::-1] + if y[0] > y[-1]: + y, values = y[::-1], values[::-1] + xe, ye = _evenly(x), _evenly(y) + if xe is not x: + values = np.array([np.interp(xe, x, row) for row in values]) + if ye is not y: + values = np.array([np.interp(ye, y, col) for col in values.T]).T + return xe, ye, values + + +def _delay_window(data: RetrievalPlotData) -> tuple[float, float]: + half = data.halfwidth / 1e-15 + return -half, half + + +def _frequency_window(data: RetrievalPlotData) -> tuple[float, float] | None: + if data.flim is None: + return None + return data.flim[0] / 1e15, data.flim[1] / 1e15 + + +def trace_map(data: RetrievalPlotData, which: str, *, db: bool) -> MapView: + """Return the measured or retrieved trace against delay and signal frequency. + + Parameters + ---------- + data : RetrievalPlotData + The retrieval. + which : {"measured", "retrieved"} + Which trace. + db : bool + Logarithmic (``data.tracedb`` dynamic range below the peak), or linear + on the scale the two linear traces share. + + Raises + ------ + ValueError + For any other ``which``. + """ + if which not in ("measured", "retrieved"): + raise ValueError(f"which must be 'measured' or 'retrieved', not {which!r}") + trace = data.measured if which == "measured" else data.retrieved + if db: + peak = float(np.nanmax(trace)) or 1.0 + with np.errstate(divide="ignore"): + shown = 10 * np.log10(np.maximum(trace, 0.0) / peak) + shown = np.clip(shown, -DB_DEPTH, 0.0) + levels, unit = (-data.tracedb, 0.0), DB_UNIT + else: + shown, levels, unit = trace, (0.0, data.lin_vmax), "a.u." + x, y, values = uniform_image( + data.result.delays / 1e-15, + (data.result.omega + data.omega0_trace) / _TWOPI_PHZ, + shown, + ) + low = float(np.nanmin(values)) + if not db and low < 0: + levels = (low, data.lin_vmax) + scale = "log" if db else "lin" + return MapView( + values=values, + x=x, + y=y, + levels=levels, + title=f"{which.capitalize()} ({scale})", + xlabel="Delay (fs)", + ylabel=FREQUENCY_LABEL, + unit=unit, + signed=not db, + xrange=_delay_window(data), + yrange=_frequency_window(data), + ) + + +def residual_map(data: RetrievalPlotData) -> MapView | None: + """Return measured minus retrieved on a symmetric scale (None without one).""" + residual = data.processed.residual + if residual is None: + return None + reach = float(np.max(np.abs(residual))) or 1.0 + x, y, values = uniform_image( + data.result.delays / 1e-15, + (data.result.omega + data.result.omega0) / _TWOPI_PHZ, + residual, + ) + return MapView( + values=values, + x=x, + y=y, + levels=(-reach, reach), + title="Residuals", + xlabel="Delay (fs)", + ylabel=FREQUENCY_LABEL, + unit="a.u.", + colours="bwr", + xrange=_delay_window(data), + yrange=_frequency_window(data), + ) + + +def spectrogram_map(data: RetrievalPlotData) -> MapView: + """Return the retrieved pulse's Gabor spectrogram, time against wavelength.""" + t_centres, wavelength, s = data.spectrogram + return spectrogram_map_of( + t_centres, + wavelength, + s, + window=_delay_window(data), + band=(data.lam_min, data.lam_max), + ) + + +def spectrogram_map_of( + t_centres: ArrayLike, + wavelength: ArrayLike, + s: ArrayLike, + *, + window: tuple[float, float] | None = None, + band: tuple[float, float] | None = None, +) -> MapView: + """Return a spectrogram ``(t, λ, S)`` (s, m) as a map, time against wavelength. + + ``window`` is the time window shown (fs) and ``band`` the wavelength band (m). + """ + x, y, values = uniform_image( + np.asarray(t_centres) / 1e-15, np.asarray(wavelength) / 1e-9, s + ) + return MapView( + values=values, + x=x, + y=y, + levels=(0.0, float(np.nanmax(values)) or 1.0), + title="Spectrogram", + xlabel="Time (fs)", + ylabel=WAVELENGTH_LABEL, + unit="a.u.", + xrange=window, + yrange=None if band is None else (band[0] / 1e-9, band[1] / 1e-9), + ) + + +def temporal_view(data: RetrievalPlotData) -> LineView: + """Return the retrieved pulse: intensity or power, transform limit, phase.""" + return temporal_view_of( + data.processed, + power_scale=data.power_scale, + truth=data.truth, + window=_delay_window(data), + ) + + +def temporal_view_of( + pr, + *, + power_scale: tuple[float, str] | None = None, + truth=None, + window: tuple[float, float] | None = None, +) -> LineView: + """Return a processed pulse in time: intensity or power, transform limit, phase. + + Parameters + ---------- + pr : ProcessedResult + The pulse (:func:`croak.processing.process_result`). + power_scale : tuple of (float, str), optional + The SI ``(scale, unit)`` of an absolute power axis (derived when the + pulse energy is known and this is None). + truth : TruthPulse, optional + A known pulse to draw beneath it. + window : tuple of float, optional + The time window shown (fs). + """ + retr, tl, r_label, tl_label, ylabel, power_scale = _temporal_curves(pr, power_scale) + curves = [ + CurveView(pr.t_over / 1e-15, retr, "retrieved", r_label), + CurveView(pr.t_tl / 1e-15, tl, "transform-limited", tl_label), + ] + truth_curve = None + if truth is not None: + truth_curve = _truth_intensity(truth, pr.energy, power_scale) + curves.append( + CurveView( + truth.t / 1e-15, + truth_curve, + "truth", + f"truth ({truth.fwhm / 1e-15:.1f} fs)", + ) + ) + t = pr.t_over / 1e-15 + curves.append( + CurveView( + t[pr.mask_t], pr.phi_t[pr.mask_t], "phase", "phase", True, density=False + ) + ) + truth_phase = None + if truth is not None and truth.phi_t is not None: + keep = np.asarray(truth.It) > 0.01 + truth_phase = np.asarray(truth.phi_t)[keep] + curves.append( + CurveView( + np.asarray(truth.t)[keep] / 1e-15, + truth_phase, + "truth-phase", + "truth phase", + True, + ) + ) + return LineView( + curves=tuple(curves), + title="Retrieved pulse", + xlabel="Time (fs)", + ylabel=ylabel, + right_label="Phase (rad)", + xrange=window, + yrange=_intensity_ylim(retr, tl, truth_curve), + right_range=_phase_ylim(pr.phi_t[pr.mask_t], truth_phase), + ) + + +def spectral_view(data: RetrievalPlotData) -> LineView: + """Return the spectrum: retrieved, measured, phase and its fit, the truth. + + Its notes are the GDD/TOD of the phase fit and the energy at the grid's + edge, a warning above :data:`~croak.plotting.EDGE_ENERGY_WARN`. + """ + return spectral_view_of( + data.processed, + axis=data.spectral_axis, + band=(data.lam_min, data.lam_max), + truth=data.truth, + ) + + +def spectral_view_of( + pr, + *, + axis: str = "frequency", + band: tuple[float, float] | None = None, + truth=None, +) -> LineView: + """Return a processed pulse's spectrum on ``axis``, with its phase and fit. + + Parameters + ---------- + pr : ProcessedResult + The pulse. + axis : {"frequency", "wavelength"} + The abscissa. + band : tuple of float, optional + The wavelength band shown (m), on either axis. + truth : TruthPulse, optional + A known pulse to draw beneath it. + """ + x, retrieved, measured, xlabel = _spectral_curves(pr, axis) + curves = [CurveView(x, retrieved, "retrieved", "R")] + if measured is not None: + curves.append(CurveView(x, measured, "measured", "M")) + known = None if truth is None else _truth_spectrum(truth, axis) + if known is not None: + curves.append(CurveView(known[0], known[1], "truth", "truth")) + mask = pr.mask_w + curves.append( + CurveView(x[mask], pr.phi_w[mask], "phase", "phase", True, density=False) + ) + curves.append( + CurveView(x[mask], pr.phi_w_fit[mask], "fit", "phase fit", True, density=False) + ) + truth_phase = _truth_spectral_phase(pr.wavelength, truth) + if truth_phase is not None: + curves.append( + CurveView( + x[mask], + truth_phase[mask], + "truth-phase", + "truth phase", + True, + density=False, + ) + ) + edge = pr.edge_energy + lam_min, lam_max = band if band is not None else (None, None) + return LineView( + curves=tuple(curves), + title="Spectrum", + xlabel=xlabel, + ylabel="Power (a.u.)", + right_label="Phase (rad)", + xrange=_spectral_xlim(lam_min, lam_max, axis), + yrange=(0.0, 1.05), + right_range=_phase_ylim( + pr.phi_w[mask], None if truth_phase is None else truth_phase[mask] + ), + notes=( + (_dispersion_text(pr).replace("\n", " "), False), + (f"Edge: {100 * edge:.2f} %", edge > EDGE_ENERGY_WARN), + ), + ) + + +def convergence_view(errors: ArrayLike, boundaries: Sequence[int] = ()) -> LineView: + """Return the FROG error against iteration, with the stage handovers.""" + errors = np.asarray(errors, dtype=float) + iterations = np.arange(1, errors.size + 1, dtype=float) + return LineView( + curves=(CurveView(iterations, errors, "error", "error"),), + title="Convergence", + xlabel="Iteration", + ylabel="Error", + log_y=True, + marks=tuple( + (float(edge) + 0.5, "boundary") + for edge in boundaries + if 0 < edge < errors.size + ), + ) + + +def _marginal_view( + x: NDArray[np.float64], + retrieved: NDArray[np.float64], + measured: NDArray[np.float64] | None, + *, + title: str, + xlabel: str, + xrange: tuple[float, float] | None, +) -> LineView: + curves = [CurveView(x, retrieved, "retrieved", "R")] + if measured is not None: + curves.append(CurveView(x, measured, "measured", "M")) + return LineView( + curves=tuple(curves), + title=title, + xlabel=xlabel, + ylabel="Marginal (a.u.)", + xrange=xrange, + yrange=(0.0, 1.05), + ) + + +def frequency_marginal_view(data: RetrievalPlotData) -> LineView: + """Return the frequency marginals of the retrieved and measured traces.""" + pr = data.processed + x = (data.result.omega + data.result.omega0) / _TWOPI_PHZ + return _marginal_view( + x, + pr.omega_marg_retr, + pr.omega_marg_meas, + title="Frequency marginal", + xlabel=FREQUENCY_LABEL, + xrange=None, + ) + + +def delay_marginal_view(data: RetrievalPlotData) -> LineView: + """Return the delay marginals of the retrieved and measured traces.""" + pr = data.processed + return _marginal_view( + data.result.delays / 1e-15, + pr.tau_marg_retr, + pr.tau_marg_meas, + title="Delay marginal", + xlabel="Delay (fs)", + xrange=_delay_window(data), + ) + + +def filter_view(data: RetrievalPlotData) -> LineView: + """Return the per-frequency scale factor (Rω filter) against wavelength.""" + result = data.result + # A centred grid can cross zero frequency, a pole of the wavelength axis. + with np.errstate(divide="ignore", invalid="ignore"): + lam_nm = wlfreq(result.omega + result.omega0) / 1e-9 + keep = np.isfinite(lam_nm) + order = np.argsort(lam_nm[keep]) + return LineView( + curves=( + CurveView( + lam_nm[keep][order], + _filter_factor(result)[keep][order], + "retrieved", + density=False, + ), + ), + title="Spectral filter", + xlabel=WAVELENGTH_LABEL, + ylabel="Filter factor", + xrange=(data.lam_min / 1e-9, data.lam_max / 1e-9), + ) + + +SIGNAL_FRACTION = 1e-2 +"""A wavelength row holds signal if its peak exceeds this fraction of the scale.""" + + +def raw_trace_map( + delay_fs: ArrayLike, + wavelength_nm: ArrayLike, + trace: ArrayLike, + trust_nm: tuple[float, float], + *, + title: str = "Measured trace", +) -> MapView: + """Return a loaded trace (wavelength × delay) as a signed linear map. + + The colour scale is set by the signal inside ``trust_nm`` alone, so noise far + outside it (strongly amplified by a ``(λ/µm)^exp`` correction) cannot wash + the image out, and the view is cropped to the trusted rows that hold signal. + + Parameters + ---------- + delay_fs, wavelength_nm : array_like + The trace's axes (any order, any spacing). + trace : array_like, shape (len(wavelength_nm), len(delay_fs)) + The trace; negative after background subtraction is allowed. + trust_nm : tuple of float + The band whose signal sets the scale. + title : str + The view's title. + """ + lam = np.asarray(wavelength_nm, dtype=float) + trace = np.asarray(trace, dtype=float) + in_band = (lam >= trust_nm[0]) & (lam <= trust_nm[1]) + band = trace[in_band] if np.any(in_band) else trace + vmax = float(np.nanmax(np.abs(band))) or 1.0 + rows = lam[in_band & (trace.max(axis=1) > SIGNAL_FRACTION * vmax)] + x, y, values = uniform_image(delay_fs, lam, trace) + low = min(float(np.nanmin(values)), 0.0) + return MapView( + values=values, + x=x, + y=y, + levels=(low, vmax), + title=title, + xlabel="Delay (fs)", + ylabel=WAVELENGTH_LABEL, + unit="a.u.", + signed=True, + yrange=(float(rows.min()), float(rows.max())) if rows.size else None, + ) + + +# A field sample is part of the pulse above this fraction of the peak intensity; +# the views zoom to those samples and blank the phase elsewhere, where it is noise. +PULSE_FRACTION = 1e-3 +# Each side of the pulse gets this fraction of its extent as margin. +PULSE_MARGIN = 0.2 +SPECTROGRAM_COLUMNS = 160 +"""Gate positions of the generator's spectrogram.""" + + +def _masked_phase(intensity: NDArray[np.float64], field: NDArray[np.complex128]): + """Unwrapped phase, zero at the peak and NaN where the pulse is absent.""" + phase = np.unwrap(np.angle(field)) + phase = phase - phase[int(np.argmax(intensity))] + phase[intensity < intensity.max() * PULSE_FRACTION] = np.nan + return phase + + +def _unit(values: NDArray[np.float64]) -> NDArray[np.float64]: + peak = float(values.max()) if values.size else 0.0 + return values / peak if peak > 0 else values + + +def _zoom(x: NDArray[np.float64], keep: NDArray[np.bool_]): + """``x``'s extent where ``keep``, widened by :data:`PULSE_MARGIN` (or None).""" + sig = x[keep] + if not sig.size: + return None + pad = PULSE_MARGIN * (sig.max() - sig.min() + 1e-9) + return float(sig.min() - pad), float(sig.max() + pad) + + +def pulse_field_views( + grid, ew: ArrayLike, omega0: float +) -> tuple[LineView, LineView, MapView]: + """Return a known pulse as the generator shows it: time, spectrum, spectrogram. + + The time and spectral views carry the phase (zero at the peak, blank where + the intensity is below :data:`PULSE_FRACTION` of it) on a right axis, and + zoom to the pulse. The spectrogram gates the field with a Gaussian a + thirtieth of the pulse's span wide (at least four samples), at + :data:`SPECTROGRAM_COLUMNS` positions over the pulse and a 30 % margin, and + is a wavelength density, peak-normalised. + + Parameters + ---------- + grid : Grid + The field's grid. + ew : array_like + The spectral field on ``grid``. + omega0 : float + The carrier angular frequency (rad/s). + """ + ew = np.asarray(ew, dtype=complex) + et = grid.ifft(ew) + it = _unit(np.abs(et) ** 2) + t = grid.t / 1e-15 + phase_t = _masked_phase(it, et) + temporal = LineView( + curves=( + CurveView(t, it, "retrieved", "intensity"), + CurveView(t, phase_t, "phase", "phase", True, density=False), + ), + title="Temporal", + xlabel="Time (fs)", + ylabel="Intensity", + right_label="Phase (rad)", + xrange=_zoom(t, it > PULSE_FRACTION), + yrange=(0.0, 1.05), + right_range=_phase_ylim(phase_t), + ) + iw = _unit(np.abs(ew) ** 2) + omega_abs = grid.omega + omega0 + pos = omega_abs > 0 + lam = wlfreq(omega_abs[pos]) / 1e-9 + order = np.argsort(lam) + # |E(ω)|² dω = |E(λ)|² dλ: drawn against wavelength, the spectrum is a + # wavelength density, with the ω² Jacobian, as the spectrogram beside it. + lam, ip = lam[order], _unit(iw[pos] * omega_abs[pos] ** 2)[order] + phase_w = _masked_phase(iw, ew)[pos][order] + spectral = LineView( + curves=( + CurveView(lam, ip, "retrieved", "intensity"), + CurveView(lam, phase_w, "phase", "phase", True, density=False), + ), + title="Spectral", + xlabel=WAVELENGTH_LABEL, + ylabel="Intensity", + right_label="Phase (rad)", + xrange=_zoom(lam, ip > PULSE_FRACTION), + yrange=(0.0, 1.05), + right_range=_phase_ylim(phase_w), + ) + return temporal, spectral, _gabor(grid, et, omega0) + + +def _gabor(grid, et: NDArray[np.complex128], omega0: float) -> MapView: + """Return the generator's spectrogram of the temporal field ``et``.""" + t = grid.t + inten = np.abs(et) ** 2 + sig = t[inten > inten.max() * PULSE_FRACTION] if inten.max() > 0 else t + span = sig.max() - sig.min() + 1e-15 + centres = np.linspace( + sig.min() - 0.3 * span, sig.max() + 0.3 * span, SPECTROGRAM_COLUMNS + ) + width = max((centres[-1] - centres[0]) / 30.0, grid.dt * 4) + s = np.empty((grid.n, centres.size)) + for i, centre in enumerate(centres): + gate = np.exp(-0.5 * ((t - centre) / width) ** 2) + s[:, i] = np.abs(np.fft.fftshift(np.fft.fft(np.fft.ifftshift(et * gate)))) ** 2 + omega_abs = grid.omega + omega0 + pos = omega_abs > 0 + # |E(ω)|² dω = |E(λ)|² dλ: a wavelength density carries the ω² Jacobian. + s = s[pos] * (omega_abs[pos] ** 2)[:, None] + lam = wlfreq(omega_abs[pos]) / 1e-9 + s = _unit(s) + has_signal = s.max(axis=1) > SIGNAL_FRACTION + rows = lam[has_signal] + if rows.size: + # Draw only the band that holds the pulse (and a margin): positive + # frequencies reach far into the infrared, where nothing is. + pad = PULSE_MARGIN * (rows.max() - rows.min() + 1e-9) + keep = (lam >= rows.min() - pad) & (lam <= rows.max() + pad) + lam, s = lam[keep], s[keep] + x, y, values = uniform_image(centres / 1e-15, lam, s) + return MapView( + values=values, + x=x, + y=y, + levels=(0.0, 1.0), + title="Spectrogram", + xlabel="Time (fs)", + ylabel=WAVELENGTH_LABEL, + unit="a.u.", + yrange=(float(rows.min()), float(rows.max())) if rows.size else None, + ) + + +SIMULATED_DB = 40.0 +"""Dynamic range of a simulated trace's logarithmic view, dB (no detector noise).""" + + +def simulated_trace_views( + data: dict, +) -> tuple[MapView, MapView, LineView, LineView]: + """Return a loaded simulated scan as its loader previews it. + + The simulation stores an ω-density, which the loader divides by λ² to ride + the wavelength pipeline; the preview undoes that (×λ², ν = ω/2π) so it shows + the file's own density against frequency, as the marginal check and the + retrieval do. It is then put on a unit peak (×λ² leaves a tiny absolute + level, which the logarithmic view would clip), shown linear and over + :data:`SIMULATED_DB`, with its delay and spectral marginals (the latter with + the known input spectrum, when the file has one). The view is cropped to the + rows that hold signal. + + Parameters + ---------- + data : dict + The loader's ``load_data``: ``lam`` (m, ascending), ``scanaxis`` (s), + ``trace`` (λ-density) and optionally ``lam_spec``/``Ilam_spec``. + + Returns + ------- + tuple + The linear map, the logarithmic map, the delay marginal and the + spectral marginal. + """ + lam = np.asarray(data["lam"], dtype=float) + delays = np.asarray(data["scanaxis"], dtype=float) / 1e-15 + freq = wlfreq(lam) / _TWOPI_PHZ + order = np.argsort(freq) + freq = freq[order] + trace = (np.asarray(data["trace"], dtype=float) * lam[:, None] ** 2)[order] + peak = float(np.nanmax(np.abs(trace))) + if peak > 0: + trace = trace / peak + rows = freq[trace.max(axis=1) > SIGNAL_FRACTION] if peak > 0 else freq + window = (float(rows.min()), float(rows.max())) + x, y, lin = uniform_image(delays, freq, trace) + with np.errstate(divide="ignore"): + db = np.clip(10 * np.log10(np.maximum(lin, 0.0)), -DB_DEPTH, 0.0) + common = {"x": x, "y": y, "xlabel": "Delay (fs)", "ylabel": FREQUENCY_LABEL} + linear = MapView( + values=lin, + levels=(min(float(lin.min()), 0.0), 1.0), + title="Simulated FROG trace (lin)", + unit="a.u.", + signed=True, + yrange=window, + **common, + ) + logarithmic = MapView( + values=db, + levels=(-SIMULATED_DB, 0.0), + title="Simulated FROG trace (dB)", + unit=DB_UNIT, + yrange=window, + **common, + ) + delay_marginal = LineView( + curves=(CurveView(delays, _unit(trace.sum(axis=0)), "retrieved", "delay"),), + title="Delay marginal", + xlabel="Delay (fs)", + ylabel="Marginal (a.u.)", + yrange=(0.0, 1.05), + ) + curves = [CurveView(freq, _unit(trace.sum(axis=1)), "retrieved", "trace marginal")] + if data.get("lam_spec") is not None and data.get("Ilam_spec") is not None: + lam_s = np.asarray(data["lam_spec"], dtype=float) + f_s = wlfreq(lam_s) / _TWOPI_PHZ + order_s = np.argsort(f_s) + spectrum = (np.asarray(data["Ilam_spec"], dtype=float) * lam_s**2)[order_s] + curves.append(CurveView(f_s[order_s], _unit(spectrum), "measured", "input")) + spectral_marginal = LineView( + curves=tuple(curves), + title="Spectral marginal (ω-density)", + xlabel=FREQUENCY_LABEL, + ylabel="Marginal (a.u.)", + xrange=window, + yrange=(0.0, 1.05), + ) + return linear, logarithmic, delay_marginal, spectral_marginal + + +FILTER_PANELS = ( + # key, title, which trace, domain, logarithmic + ("measured_db", "Measured (dB)", "measured", "wavelength", True), + ("filtered_db", "Filtered (dB)", "filtered", "wavelength", True), + ("regridded_db", "Regridded (dB)", "regridded", "frequency", True), + ("measured_lin", "Measured (lin)", "measured", "wavelength", False), + ("filtered_lin", "Filtered (lin)", "filtered", "wavelength", False), + ("regridded_lin", "Regridded (lin)", "regridded", "frequency", False), +) +"""The preprocessing view's six maps, in reading order (two rows of three).""" + +DISPLAY_CAP = 1000 +"""The most samples a preprocessing map keeps per side (screens are narrower).""" + + +def filter_views(td, *, tracedb: float = 30.0) -> dict[str, MapView]: + """Return the preprocessing view: measured, filtered and regridded traces. + + Each trace is shown in dB (``tracedb`` below its peak, positive part) and + linear (signed), against delay: the measured and filtered traces against + wavelength, the regridded one against signal frequency. The linear maps + share :func:`~croak.plotting.filter_view_vmax`, so their colours compare. + Large traces are thinned to :data:`DISPLAY_CAP` samples per side for + drawing only. + + Parameters + ---------- + td : TraceData + The preprocessed trace (:func:`croak.preprocess.load_and_clean`). + tracedb : float + The logarithmic maps' dynamic range, dB. + + Returns + ------- + dict of str to MapView + Keyed as :data:`FILTER_PANELS`. + """ + lam_nm = np.asarray(td.lam_frog, dtype=float) / 1e-9 + freq = (np.asarray(td.omega) + td.omega0_trace) / _TWOPI_PHZ + sources = { + "measured": (lam_nm, td.tau_meas / 1e-15, td.Ifrog_meas), + "filtered": (lam_nm, td.tau_filt / 1e-15, td.Ifrog_filt), + "regridded": (freq, td.delays / 1e-15, td.trace), + } + vmax = filter_view_vmax(td) + views = {} + for key, title, which, domain, db in FILTER_PANELS: + x, delays, trace = sources[which] + image, x, delays = _decimate_for_display( + np.asarray(trace, dtype=float).T, + np.asarray(x, dtype=float), + np.asarray(delays, dtype=float), + cap=DISPLAY_CAP, + ) + xs, ys, values = uniform_image(x, delays, image) + if db: + peak = float(np.nanmax(values)) or 1.0 + with np.errstate(divide="ignore", invalid="ignore"): + values = np.clip( + 10 * np.log10(np.maximum(values, 0.0) / peak), -DB_DEPTH, 0.0 + ) + levels, unit = (-tracedb, 0.0), DB_UNIT + else: + levels, unit = (min(float(np.nanmin(values)), 0.0), vmax), "a.u." + views[key] = MapView( + values=values, + x=xs, + y=ys, + levels=levels, + title=title, + xlabel=FREQUENCY_LABEL if domain == "frequency" else WAVELENGTH_LABEL, + ylabel="Delay (fs)", + unit=unit, + signed=not db, + ) + return views + + +def _histogram_bins(count: int) -> int: + """Return the bin count: a fifth of the samples, from 12 to 40.""" + return int(np.clip(count // 5, 12, 40)) + + +def _fwhm_distribution_view(uresult, truth: float | None) -> LineView: + samples = np.asarray(uresult.samples, dtype=float) / 1e-15 + counts, edges = np.histogram(samples, bins=_histogram_bins(samples.size)) + lo95, hi95 = (v / 1e-15 for v in uresult.interval_95) + lo68, hi68 = (v / 1e-15 for v in uresult.interval_68) + marks = [(uresult.point_estimate / 1e-15, "estimate")] + if truth is not None: + marks.append((truth / 1e-15, "truth-phase")) + return LineView( + curves=(), + title=( + f"{uresult.point_estimate / 1e-15:.2f} ± " + f"{uresult.plus_minus / 1e-15:.2f} fs (B={uresult.n_converged})" + ), + xlabel="FWHM (fs)", + ylabel="Replicates", + spans=( + SpanView(lo95, hi95, "retrieved", "95 %", opacity=0.10), + SpanView(lo68, hi68, "retrieved", "68 %", opacity=0.20), + ), + bars=BarsView(edges, counts.astype(float), "retrieved"), + marks=tuple(marks), + yrange=(0.0, 1.05 * float(counts.max())), + ) + + +def _intensity_band_view(uresult) -> LineView: + t = np.asarray(uresult.t_profile, dtype=float) / 1e-15 + profiles = np.asarray(uresult.profiles, dtype=float) + median = np.median(profiles, axis=0) + lo68, hi68 = np.percentile(profiles, [16.0, 84.0], axis=0) + lo95, hi95 = np.percentile(profiles, [2.5, 97.5], axis=0) + support = t[median > 0.01 * median.max()] + window = None + if support.size: + pad = PULSE_MARGIN * (support.max() - support.min() + 1.0) + window = (float(support.min() - pad), float(support.max() + pad)) + return LineView( + curves=(CurveView(t, median, "retrieved", "median"),), + title="Temporal intensity", + xlabel="Time (fs)", + ylabel="Intensity (a.u.)", + bands=( + BandView(t, lo95, hi95, "retrieved", "95 %", opacity=0.15), + BandView(t, lo68, hi68, "retrieved", "68 %", opacity=0.30), + ), + xrange=window, + yrange=(0.0, 1.05 * float(np.max(hi95))), + ) + + +def _thickness_prior(uresult) -> tuple[SpanView, ...]: + if uresult.thickness_central is None or uresult.thickness_sigma is None: + return () + central = uresult.thickness_central / 1e-6 + sigma = uresult.thickness_sigma / 1e-6 + return (SpanView(central - sigma, central + sigma, "prior", "L₀ ± σ_L"),) + + +def _fwhm_vs_thickness_view(uresult, truth: float | None) -> LineView: + thick = np.asarray(uresult.thicknesses, dtype=float) / 1e-6 + fwhm = np.asarray(uresult.samples, dtype=float) / 1e-15 + lo68, hi68 = (v / 1e-15 for v in uresult.interval_68) + levels = [(uresult.point_estimate / 1e-15, "estimate")] + if truth is not None: + levels.append((truth / 1e-15, "truth-phase")) + return LineView( + curves=(CurveView(thick, fwhm, "retrieved", "draws", points=True),), + title="FWHM vs thickness", + xlabel="Substrate thickness (µm)", + ylabel="FWHM (fs)", + spans=( + *_thickness_prior(uresult), + SpanView(lo68, hi68, "estimate", "68 %", vertical=False), + ), + levels=tuple(levels), + ) + + +def _error_vs_thickness_view(uresult) -> LineView: + thick = np.asarray(uresult.thicknesses, dtype=float) / 1e-6 + errors = np.asarray(uresult.frog_errors, dtype=float) + return LineView( + curves=(CurveView(thick, errors, "measured", "draws", points=True),), + title="Trace misfit vs thickness", + xlabel="Substrate thickness (µm)", + ylabel="FROG error R", + spans=_thickness_prior(uresult), + ) + + +def uncertainty_views(uresult, *, truth: float | None = None) -> list[LineView]: + """Return an uncertainty estimate's views, as the composite figure lays them out. + + A resampling estimate gives the FWHM histogram with its 68 % and 95 % + intervals; a substrate-thickness estimate gives FWHM and FROG error against + the drawn thickness, with the thickness prior. Either is followed by the + temporal intensity's pointwise confidence band when profiles were + collected (see :func:`croak.plotting.plot_uncertainty`). + + Parameters + ---------- + uresult : UncertaintyResult + The estimate. + truth : float, optional + A known true FWHM (s) to mark. + """ + if getattr(uresult, "thicknesses", None) is not None: + views = [ + _fwhm_vs_thickness_view(uresult, truth), + _error_vs_thickness_view(uresult), + ] + else: + views = [_fwhm_distribution_view(uresult, truth)] + if uresult.profiles is not None and uresult.t_profile is not None: + views.append(_intensity_band_view(uresult)) + return views diff --git a/croak/plotting/traces.py b/croak/plotting/traces.py new file mode 100644 index 0000000..809d452 --- /dev/null +++ b/croak/plotting/traces.py @@ -0,0 +1,397 @@ +"""FROG-trace images: colormaps, signed images, residual, marginals and composites. + +Custom colormaps (white at the low end; a red band for negatives), the +sign-splitting :func:`signed_pcolormesh`, the residual and marginal panels, and +the trace composites :func:`plot_frog_filter` (with its artist-reusing twin +:class:`FrogFilterView`) and :func:`plot_simulated_trace`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from matplotlib.colors import ListedColormap +from matplotlib.figure import Figure + +from ..processing import marginals +from .artists import MarginalArtists +from .figure import _prepare_fig +from .units import _TWOPI_PHZ + +if TYPE_CHECKING: + from numpy.typing import NDArray + + +# --------------------------------------------------------------------------- +# Colormaps +# --------------------------------------------------------------------------- +def cmap_white(base: str = "viridis", *, n: int = 8, white: bool = True, N: int = 512): + """Build a base colormap with (optionally) a white point at the low end.""" + import matplotlib as mpl + + cm = mpl.colormaps[base] + vals = np.linspace(0, 1, n) + cols = cm(vals) + if white: + cols[0] = [1, 1, 1, 1] + xi = np.linspace(0, 1, N) + out = np.empty((N, 4)) + for k in range(4): + out[:, k] = np.interp(xi, vals, cols[:, k]) + return ListedColormap(out) + + +def cmap_negwhite( + base: str = "viridis", + *, + n: int = 8, + white: bool = True, + negfrac: float = 0.0, + N: int = 512, +): + """Like :func:`cmap_white` but with a red gradient for negative values.""" + if negfrac <= 0: + return cmap_white(base, n=n, white=white, N=N) + n_pos = max(round(N * (1 - negfrac)), 2) + n_neg = N - n_pos + pos = cmap_white(base, n=n, white=white, N=n_pos)(np.linspace(0, 1, n_pos)) + neg = np.empty((n_neg, 4)) + for i in range(n_neg): + t = i / max(n_neg - 1, 1) + neg[i] = [1.0, t, t, 1.0] + return ListedColormap(np.vstack([neg, pos])) + + +def _white_red(): + """White→red colormap used to render negative trace values by magnitude.""" + from matplotlib.colors import LinearSegmentedColormap + + return LinearSegmentedColormap.from_list("white_red", ["white", "red"]) + + +def _signed_magnitude( + C, *, db: bool, tracedb: float, vmax: float | None +) -> tuple[NDArray[np.float64], NDArray[np.bool_], float, float]: + """Split ``C`` into magnitude, negative mask and the shared ``(vmin, vmax)``. + + One code path for drawing and for updating in place. + """ + C = np.asarray(C, dtype=float) + neg = C < 0 + if db: + mag = np.clip(10 * np.log10(np.maximum(np.abs(C), 1e-30)), -tracedb, 0.0) + return mag, neg, -tracedb, 0.0 + mag = np.abs(C) + return mag, neg, 0.0, float(np.nanmax(mag)) if vmax is None else vmax + + +def signed_pcolormesh(ax, x, y, C, *, db, tracedb=30.0, cmap_pos, vmax=None): + """Pcolormesh that splits an image's sign onto two colormaps. + + Positive samples are drawn with ``cmap_pos`` (e.g. viridis); negative + samples are drawn by *magnitude* with a white→red map. Both maps share the + **same** scale (``[0, vmax]`` linear, ``[-tracedb, 0]`` dB), so a negative is + only as saturated as a positive of equal magnitude — small negatives stay + faint instead of dominating — and zero is white in both (pair ``cmap_pos`` + with :func:`cmap_white`). ``C`` follows the usual + :external+matplotlib:meth:`~matplotlib.axes.Axes.pcolormesh` convention — + shape ``(len(y), len(x))`` (a FROG ``(Nλ, Nτ)`` array already has this shape + for x = delay and y = wavelength). + + Returns ``(mappable_pos, mappable_neg)`` (the second is ``None`` when there + are no negative samples). + """ + mag, neg, vmin, vhi = _signed_magnitude(C, db=db, tracedb=tracedb, vmax=vmax) + mpos = ax.pcolormesh( + x, + y, + np.ma.masked_where(neg, mag), + vmin=vmin, + vmax=vhi, + cmap=cmap_pos, + rasterized=True, + shading="auto", + ) + mneg = None + if neg.any(): + mneg = ax.pcolormesh( + x, + y, + np.ma.masked_where(~neg, mag), + vmin=vmin, + vmax=vhi, + cmap=_white_red(), + rasterized=True, + shading="auto", + ) + return mpos, mneg + + +def plot_residual(ax, omega, delays, resid, omega0, *, halfwidth=None, flim=None): + """Plot the measured−retrieved residual with a symmetric blue-white-red map. + + ``flim`` optionally bounds the frequency axis, as ``(f_min, f_max)`` in Hz. + """ + mr = float(np.max(np.abs(resid))) or 1.0 + freq = (np.asarray(omega) + omega0) / _TWOPI_PHZ + m = ax.pcolormesh( + np.asarray(delays) / 1e-15, + freq, + resid, + vmin=-mr, + vmax=mr, + cmap=cmap_white("bwr", n=32, white=False), + rasterized=True, + shading="auto", + ) + ax.set_xlabel("Delay (fs)") + ax.set_ylabel("Frequency (PHz)") + ax.set_title("Residuals") + if halfwidth is not None: + ax.set_xlim(-halfwidth / 1e-15, halfwidth / 1e-15) + if flim is not None: + ax.set_ylim(flim[0] / 1e15, flim[1] / 1e15) + return m + + +def plot_marginal( + ax, x, retrieved, measured=None, *, xlabel="", scale=1.0, halfwidth=None +) -> MarginalArtists: + """Plot a (retrieved vs measured) trace marginal.""" + (line_retr,) = ax.plot(np.asarray(x) / scale, retrieved, c="C0", label="R") + line_meas = None + if measured is not None: + (line_meas,) = ax.plot(np.asarray(x) / scale, measured, c="C1", label="M") + ax.set_xlabel(xlabel) + ax.set_ylabel("Marginal (a.u.)") + ax.set_ylim(0, 1.05) + if halfwidth is not None: + ax.set_xlim(-halfwidth / scale, halfwidth / scale) + legend = ax.legend( + loc="upper left", handlelength=1.0, frameon=False, fontsize="small" + ) + return MarginalArtists(line_retr=line_retr, legend=legend, line_meas=line_meas) + + +def filter_view_vmax(td) -> float: + """Return the shared positive colour scale for the six-panel filter view. + + Take the largest **positive** sample across the measured, filtered and + regridded traces, restricting the two wavelength-domain arrays to the + measurement window ``td.lamm_lims`` when set. + + Both restrictions matter on real data. An absolute-calibration curve + amplifies the background-subtracted noise far outside the signal band, so + scaling by ``max|·|`` lets a large *negative* excursion set the *positive* + scale: on one of our measured deep-UV traces, whose signal peaks at ``+1`` + but dips to ``-5.7`` at 1097 nm, every linear panel rendered its signal at + 18 % of the colour range. + + The three panels share this one number so the row's single colourbar means + something; each array is peak-normalised upstream, so they are comparable. + If no positive sample remains, fall back to the largest absolute value, + then 1.0, so the returned scale is always positive. + """ + + def _positive_max(arr, lam=None) -> float: + a = np.asarray(arr, dtype=float) + if lam is not None and td.lamm_lims is not None: + lo, hi = td.lamm_lims + band = (np.asarray(lam, dtype=float) >= lo) & ( + np.asarray(lam, dtype=float) <= hi + ) + if band.any(): + a = a[band] + hi = float(np.nanmax(a)) if a.size else 0.0 + return hi if hi > 0.0 else 0.0 + + vmax = max( + _positive_max(td.Ifrog_meas, td.lam_frog), + _positive_max(td.Ifrog_filt, td.lam_frog), + _positive_max(td.trace), + ) + if vmax > 0.0: + return vmax + # Degenerate (all-negative or all-zero) trace: keep a usable, positive scale. + fallback = max( + float(np.nanmax(np.abs(td.Ifrog_meas))), + float(np.nanmax(np.abs(td.Ifrog_filt))), + float(np.nanmax(np.abs(td.trace))), + ) + return fallback if fallback > 0.0 else 1.0 + + +def plot_frog_filter( + td, + *, + tracedb: float = 30.0, + cmap: str = "viridis", + figsize=(9.45, 4.72), + fig: Figure | None = None, +) -> Figure: + """6-panel measured/filtered (wavelength-domain) + regridded (frequency-domain). + + Positive values use the viridis (white-low) map; negative values — kept + through filtering rather than clamped — are drawn by magnitude on a + white→red map so they are visible and not mistaken for signal. Each row + (the dB row and the linear row) carries one positive + one negative + colorbar on the right-hand side. + """ + fig = _prepare_fig(fig, figsize) + axd = fig.subplot_mosaic("abe\ncdf") + cm = cmap_white(cmap) + # The linear row shares a single scale so its one colorbar is meaningful. + lin_vmax = filter_view_vmax(td) + + def _panel(ax, x, y, C, *, db, title, xlabel): + mpos, mneg = signed_pcolormesh( + ax, + x, + y, + C, + db=db, + tracedb=tracedb, + cmap_pos=cm, + vmax=None if db else lin_vmax, + ) + ax.set_xlabel(xlabel) + ax.set_ylabel("Delay (fs)") + ax.set_title(title) + return mpos, mneg + + def _row_cbars(axes, mpos, negs): + """One positive + (if any) one negative colorbar at the row's right.""" + fig.colorbar(mpos, ax=axes, shrink=0.5) + mneg = next((m for m in negs if m is not None), None) + if mneg is not None: + fig.colorbar(mneg, ax=axes, shrink=0.5, label="neg |·|") + + lam_nm = td.lam_frog / 1e-9 + freq = (td.omega + td.omega0_trace) / _TWOPI_PHZ + WL = "Wavelength (nm)" + HZ = "Frequency (PHz)" + + # top row: dB (log) — measured, filtered, regridded + pa, na = _panel( + axd["a"], + lam_nm, + td.tau_meas / 1e-15, + td.Ifrog_meas.T, + db=True, + title="Measured (dB)", + xlabel=WL, + ) + _pb, nb = _panel( + axd["b"], + lam_nm, + td.tau_filt / 1e-15, + td.Ifrog_filt.T, + db=True, + title="Filtered (dB)", + xlabel=WL, + ) + _pe, ne = _panel( + axd["e"], + freq, + td.delays / 1e-15, + td.trace.T, + db=True, + title="Regridded (dB)", + xlabel=HZ, + ) + _row_cbars([axd["a"], axd["b"], axd["e"]], pa, (nb, ne, na)) + + # bottom row: linear — measured, filtered, regridded + pc, nc = _panel( + axd["c"], + lam_nm, + td.tau_meas / 1e-15, + td.Ifrog_meas.T, + db=False, + title="Measured (lin)", + xlabel=WL, + ) + _pd, nd = _panel( + axd["d"], + lam_nm, + td.tau_filt / 1e-15, + td.Ifrog_filt.T, + db=False, + title="Filtered (lin)", + xlabel=WL, + ) + _pf, nf = _panel( + axd["f"], + freq, + td.delays / 1e-15, + td.trace.T, + db=False, + title="Regridded (lin)", + xlabel=HZ, + ) + _row_cbars([axd["c"], axd["d"], axd["f"]], pc, (nd, nf, nc)) + return fig + + +def _decimate_for_display(C, x, y, *, cap=1000): + """Stride a ``(ny, nx)`` image and its axes down to at most ``cap`` per side. + + The filter figure panels are only a few hundred screen pixels wide, so + rasterising a multi-thousand-sample trace is wasted work. Plain strided + subsampling is enough for a diagnostic preview (no anti-alias needed — the + physics-correct arrays are kept in ``td``; this only thins what is drawn). + """ + ny, nx = C.shape + sy = max(ny // cap, 1) + sx = max(nx // cap, 1) + if sy == 1 and sx == 1: + return C, x, y + return C[::sy, ::sx], x[::sx], y[::sy] + + +def plot_simulated_trace( + omega, + delays, + trace, + omega0_trace, + *, + tracedb: float = 40.0, + cmap: str = "viridis", + figsize=(7.87, 5.51), + fig: Figure | None = None, +) -> Figure: + """4-panel view of a simulated trace: dB, linear, and the two marginals.""" + trace = np.asarray(trace, dtype=float) + fig = _prepare_fig(fig, figsize) + axd = fig.subplot_mosaic("ab\ncd") + cm = cmap_white(cmap) + freq = (omega + omega0_trace) / _TWOPI_PHZ + + def _panel(ax, *, db, title): + mpos, mneg = signed_pcolormesh( + ax, delays / 1e-15, freq, trace, db=db, tracedb=tracedb, cmap_pos=cm + ) + ax.set_xlabel("Delay (fs)") + ax.set_ylabel("Frequency (PHz)") + ax.set_title(title) + return mpos, mneg + + ma, na = _panel(axd["a"], db=True, title="FROG (dB)") + mb, nb = _panel(axd["b"], db=False, title="FROG (lin)") + fig.colorbar(ma, ax=[axd["a"]], shrink=0.6) + if na is not None: + fig.colorbar(na, ax=[axd["a"]], shrink=0.6, label="neg |·|") + fig.colorbar(mb, ax=[axd["b"]], shrink=0.6) + if nb is not None: + fig.colorbar(nb, ax=[axd["b"]], shrink=0.6, label="neg |·|") + wmarg, tmarg = marginals(trace) + axd["c"].plot(delays / 1e-15, tmarg, c="C0") + axd["c"].set_xlabel("Delay (fs)") + axd["c"].set_ylabel("Marginal") + axd["c"].set_ylim(0, 1.05) + axd["d"].plot((omega + omega0_trace) / _TWOPI_PHZ, wmarg, c="C0") + axd["d"].set_xlabel("Frequency (PHz)") + axd["d"].set_ylabel("Marginal") + axd["d"].set_ylim(0, 1.05) + return fig diff --git a/croak/plotting/uncertainty.py b/croak/plotting/uncertainty.py new file mode 100644 index 0000000..8646b17 --- /dev/null +++ b/croak/plotting/uncertainty.py @@ -0,0 +1,233 @@ +"""Uncertainty figures: FWHM distributions, intensity bands, thickness systematic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from matplotlib.figure import Figure + +from .figure import _prepare_fig + +if TYPE_CHECKING: + from ..uncertainty import UncertaintyResult + + +# --------------------------------------------------------------------------- +# Uncertainty +# --------------------------------------------------------------------------- +def plot_fwhm_distribution( + ax, uresult: UncertaintyResult, *, truth: float | None = None +) -> None: + """Histogram of bootstrap FWHM samples with the point estimate and intervals. + + Parameters + ---------- + ax : matplotlib.axes.Axes + Axis to draw into. + uresult : UncertaintyResult + The bootstrap result (its ``samples`` are histogrammed). + truth : float, optional + A known true FWHM (s) to mark with a dashed line (synthetic tests). + """ + samples = np.asarray(uresult.samples, dtype=float) / 1e-15 + nbins = int(np.clip(samples.size // 5, 12, 40)) + ax.hist(samples, bins=nbins, color="C0", alpha=0.7, edgecolor="white", lw=0.4) + lo95, hi95 = uresult.interval_95 + lo68, hi68 = uresult.interval_68 + ax.axvspan(lo95 / 1e-15, hi95 / 1e-15, color="C0", alpha=0.10, label="95 %") + ax.axvspan(lo68 / 1e-15, hi68 / 1e-15, color="C0", alpha=0.20, label="68 %") + ax.axvline(uresult.point_estimate / 1e-15, c="C3", lw=1.5, label="estimate") + if truth is not None: + ax.axvline(truth / 1e-15, c="0.35", ls="--", lw=1.2, label="truth") + ax.set_xlabel("FWHM (fs)") + ax.set_ylabel("Replicates") + ax.set_title( + f"{uresult.point_estimate / 1e-15:.2f} ± {uresult.plus_minus / 1e-15:.2f} fs" + f" (B={uresult.n_converged})" + ) + ax.legend(loc="best", handlelength=1.0, frameon=False, fontsize="small") + + +def plot_intensity_band(ax, uresult: UncertaintyResult) -> None: + """Plot the median temporal intensity and pointwise 68 %/95 % resampling bands. + + Requires the bootstrap to have been run with ``collect_profiles=True``. The profiles + are peak-aligned (translation gauge removed by ``shiftnorm``), so the band is the + spread of the aligned intensity envelope across replicates. The bands are + pointwise, not simultaneous bands for the whole curve. + """ + if uresult.profiles is None or uresult.t_profile is None: + raise ValueError( + "uresult has no profiles; run the bootstrap with collect_profiles=True" + ) + t = np.asarray(uresult.t_profile, dtype=float) / 1e-15 + profiles = np.asarray(uresult.profiles, dtype=float) + med = np.median(profiles, axis=0) + lo68, hi68 = np.percentile(profiles, [16.0, 84.0], axis=0) + lo95, hi95 = np.percentile(profiles, [2.5, 97.5], axis=0) + ax.fill_between(t, lo95, hi95, color="C0", alpha=0.15, label="95 %") + ax.fill_between(t, lo68, hi68, color="C0", alpha=0.30, label="68 %") + ax.plot(t, med, c="C0", lw=1.5, label="median") + ax.set_xlabel("Time (fs)") + ax.set_ylabel("Intensity (a.u.)") + ax.set_ylim(bottom=0.0) + ax.set_title("Temporal intensity") + # zoom to where the envelope carries signal + support = t[med > 0.01 * med.max()] + if support.size: + pad = 0.2 * (support.max() - support.min() + 1.0) + ax.set_xlim(support.min() - pad, support.max() + pad) + ax.legend(loc="best", handlelength=1.0, frameon=False, fontsize="small") + + +def plot_uncertainty( + uresult: UncertaintyResult, + *, + truth: float | None = None, + figsize=(11.0, 4.2), + fig: Figure | None = None, +) -> Figure: + """Composite uncertainty figure: FWHM histogram + a confidence band if present. + + Parameters + ---------- + uresult : UncertaintyResult + The bootstrap result. + truth : float, optional + Known true FWHM (s) for the histogram (synthetic tests). + figsize : tuple, optional + Figure size. + fig : matplotlib.figure.Figure, optional + Existing figure to draw into (cleared first), e.g. a Qt canvas figure. + + Returns + ------- + matplotlib.figure.Figure + """ + # A thickness-systematic result carries per-draw thicknesses; show the dedicated + # sensitivity figure so the GUI canvas path needs no special-casing. + if getattr(uresult, "thicknesses", None) is not None: + return plot_thickness_sensitivity(uresult, truth=truth, fig=fig) + fig = _prepare_fig(fig, figsize) + if uresult.profiles is not None and uresult.t_profile is not None: + ax_h, ax_b = fig.subplots(1, 2) + plot_fwhm_distribution(ax_h, uresult, truth=truth) + plot_intensity_band(ax_b, uresult) + else: + ax_h = fig.subplots(1, 1) + plot_fwhm_distribution(ax_h, uresult, truth=truth) + fig.suptitle(uresult.summary(), fontsize="small") + return fig + + +# --------------------------------------------------------------------------- +# Substrate-thickness systematic +# --------------------------------------------------------------------------- +def _mark_thickness_prior(ax, uresult: UncertaintyResult) -> None: + """Shade the measured thickness prior ``L₀ ± σ_L`` (µm) on a thickness axis.""" + if uresult.thickness_central is None or uresult.thickness_sigma is None: + return + central_um = uresult.thickness_central / 1e-6 + sigma_um = uresult.thickness_sigma / 1e-6 + ax.axvspan( + central_um - sigma_um, + central_um + sigma_um, + color="0.8", + alpha=0.5, + label="L₀ ± σ_L", + ) + ax.axvline(central_um, color="0.4", ls=":", lw=1.0) + + +def plot_fwhm_vs_thickness( + ax, uresult: UncertaintyResult, *, truth: float | None = None +) -> None: + """Scatter the retrieved FWHM against the drawn substrate thickness. + + The horizontal bands give the point estimate and its 68 % interval; the vertical + band is the measured thickness prior. The slope of the cloud is the + sensitivity ``dFWHM/dL`` the analysis propagates; solver variation adds to + the spread. + + Parameters + ---------- + ax : matplotlib.axes.Axes + Axis to draw into. + uresult : UncertaintyResult + A ``method="thickness"`` result (its ``thicknesses`` are the x-values). + truth : float, optional + A known true FWHM (s) to mark with a dashed line (synthetic tests). + """ + if uresult.thicknesses is None: + raise ValueError("uresult has no thicknesses; run method='thickness'") + thick_um = np.asarray(uresult.thicknesses, dtype=float) / 1e-6 + fwhm_fs = np.asarray(uresult.samples, dtype=float) / 1e-15 + _mark_thickness_prior(ax, uresult) + ax.scatter(thick_um, fwhm_fs, s=14, color="C0", alpha=0.6, edgecolors="none") + ax.axhline(uresult.point_estimate / 1e-15, color="C3", lw=1.5, label="estimate") + lo68, hi68 = uresult.interval_68 + ax.axhspan(lo68 / 1e-15, hi68 / 1e-15, color="C3", alpha=0.15, label="68 %") + if truth is not None: + ax.axhline(truth / 1e-15, color="0.35", ls="--", lw=1.2, label="truth") + ax.set_xlabel("Substrate thickness (µm)") + ax.set_ylabel("FWHM (fs)") + ax.set_title("FWHM vs thickness") + ax.legend(loc="best", handlelength=1.0, frameon=False, fontsize="small") + + +def plot_error_vs_thickness(ax, uresult: UncertaintyResult) -> None: + """Scatter the FROG error against the drawn thickness (a consistency check). + + A flat cloud means the data do not constrain the thickness (the full prior + propagates); a clear minimum near ``L₀`` means the trace itself prefers a + thickness under the chosen model. The plot does not reweight the prior. + """ + if uresult.thicknesses is None: + raise ValueError("uresult has no thicknesses; run method='thickness'") + thick_um = np.asarray(uresult.thicknesses, dtype=float) / 1e-6 + errors = np.asarray(uresult.frog_errors, dtype=float) + _mark_thickness_prior(ax, uresult) + ax.scatter(thick_um, errors, s=14, color="C1", alpha=0.6, edgecolors="none") + ax.set_xlabel("Substrate thickness (µm)") + ax.set_ylabel("FROG error R") + ax.set_title("Trace misfit vs thickness") + ax.legend(loc="best", handlelength=1.0, frameon=False, fontsize="small") + + +def plot_thickness_sensitivity( + uresult: UncertaintyResult, + *, + truth: float | None = None, + figsize=(13.0, 4.2), + fig: Figure | None = None, +) -> Figure: + """Composite figure for the substrate-thickness systematic. + + Panels: FWHM vs thickness, FROG error vs thickness, and — when profiles were + collected — the temporal intensity confidence band. + + Parameters + ---------- + uresult : UncertaintyResult + A ``method="thickness"`` result. + truth : float, optional + Known true FWHM (s) for the FWHM panel (synthetic tests). + figsize : tuple, optional + Figure size. + fig : matplotlib.figure.Figure, optional + Existing figure to draw into (cleared first), e.g. a Qt canvas figure. + + Returns + ------- + matplotlib.figure.Figure + """ + fig = _prepare_fig(fig, figsize) + has_band = uresult.profiles is not None and uresult.t_profile is not None + axes = fig.subplots(1, 3 if has_band else 2) + plot_fwhm_vs_thickness(axes[0], uresult, truth=truth) + plot_error_vs_thickness(axes[1], uresult) + if has_band: + plot_intensity_band(axes[2], uresult) + fig.suptitle(uresult.summary(), fontsize="small") + return fig diff --git a/croak/plotting/units.py b/croak/plotting/units.py new file mode 100644 index 0000000..3de6b46 --- /dev/null +++ b/croak/plotting/units.py @@ -0,0 +1,78 @@ +"""Display units and number formatting shared by the plotters. + +Retrieval quantities are SI (s, rad/s, W); the axes show fs, PHz and an +SI-prefixed power, and read-outs use three significant digits (:func:`sig3`). +""" + +from __future__ import annotations + +import numpy as np + +_TWOPI_PHZ = 2 * np.pi * 1e15 + + +def sig3(value: float) -> str: + """Format a number to three significant digits, for a read-out. + + Three significant digits keeps a read-out equally informative across the + ranges croak covers (sub-fs attosecond pulses through hundreds of fs; + nanometre-wide through hundred-nanometre-wide spectra), where a fixed number + of decimals is too coarse at one end and spuriously precise at the other. + Unlike the ``g`` format this keeps trailing zeros (so ``7.00``, not ``7``) and + never switches to exponent notation, both of which read badly beside a sibling + entry in a legend or a table column. + + Parameters + ---------- + value : float + The number to format, already in its display unit. + + Returns + ------- + str + The formatted number, with no unit attached. + + Examples + -------- + >>> sig3(9.512), sig3(7.0), sig3(0.85), sig3(953.2) + ('9.51', '7.00', '0.850', '953') + """ + if not np.isfinite(value) or value == 0.0: + return f"{value:.2f}" + # digits before the decimal point (<= 0 for |value| < 1), so 3 - that many is + # how many decimals are still needed to reach three significant digits. + integer_digits = int(np.floor(np.log10(abs(value)))) + 1 + return f"{value:.{max(0, 3 - integer_digits)}f}" + + +def _fs(seconds: float) -> str: + """Format a duration (s) as femtoseconds to three significant digits.""" + return sig3(seconds / 1e-15) + + +_POWER_PREFIXES: tuple[tuple[float, str], ...] = ( + (1e15, "PW"), + (1e12, "TW"), + (1e9, "GW"), + (1e6, "MW"), + (1e3, "kW"), + (1.0, "W"), + (1e-3, "mW"), + (1e-6, "µW"), +) + + +def si_power(watts: float) -> tuple[float, str]: + """Return ``(scale, unit)`` to display ``watts`` with an SI power prefix. + + Picks the largest prefix not exceeding ``|watts|`` (so e.g. ``1.2e7`` W → + ``(1e6, "MW")``). Non-finite or non-positive inputs fall back to plain watts; + values below the smallest prefix use that smallest prefix. + """ + w = abs(float(watts)) + if not np.isfinite(w) or w <= 0: + return 1.0, "W" + for scale, unit in _POWER_PREFIXES: + if w >= scale: + return scale, unit + return _POWER_PREFIXES[-1] diff --git a/croak/save.py b/croak/save.py index 2e75926..b707faa 100644 --- a/croak/save.py +++ b/croak/save.py @@ -33,6 +33,7 @@ "result_from_saved", "save_uncertainty", "save_options", + "format_options", "load_options", ] @@ -390,18 +391,30 @@ def _croak_version() -> str: return "unknown" -def save_options(options: dict[str, Any], path: str) -> str: - """Write GUI session options to a TOML file. +def format_options(options: dict[str, Any]) -> str: + """Return session options as the TOML text :func:`save_options` writes. + + A ``croak_version`` / ``options_version`` header comes first so older files + can be reloaded (and migrated, if ever needed) gracefully. Nested dicts (one + per stage) become TOML tables; numpy values are coerced and ``None`` values + omitted. - A ``croak_version`` / ``options_version`` header is written first so older - files can be reloaded (and migrated, if ever needed) gracefully. Nested - dicts (one per stage) become TOML tables; numpy values are coerced. + Examples + -------- + >>> text = format_options({"load": {"energy_j": 1e-6, "frog_path": None}}) + >>> "[load]" in text and "frog_path" not in text + True """ # scalar header keys must precede the table sections in TOML data = {"croak_version": _croak_version(), "options_version": OPTIONS_VERSION} data.update(_tomlify(options)) - with open(path, "wb") as f: - tomli_w.dump(data, f) + return tomli_w.dumps(data) + + +def save_options(options: dict[str, Any], path: str) -> str: + """Write GUI session options to a TOML file (see :func:`format_options`).""" + with open(path, "w", encoding="utf-8") as f: + f.write(format_options(options)) return path diff --git a/croak/session/files.py b/croak/session/files.py new file mode 100644 index 0000000..13f1f10 --- /dev/null +++ b/croak/session/files.py @@ -0,0 +1,171 @@ +"""A session's files on disk: writing a saved session, and checking a saved result. + +A saved session is a folder holding ``options.toml`` (the settings, enough to +replay it), ``result.h5`` (the retrieval, with any uncertainty estimates +appended) and ``retrieval.pdf`` (the twelve-panel overview). The GUI adds what +its Analyse pages hold: the dispersed pulse and its figure when a dispersion is +applied (:data:`DISPERSION_FILE`, :data:`DISPERSION_FIGURE`) and the uncertainty +figure on screen (:data:`UNCERTAINTY_FIGURE`). Reopening one +replays the settings and reuses ``result.h5`` only when it was fitted to the +trace the replay produces (:func:`result_mismatch`). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import numpy as np + +from .. import save +from .options import SessionOptions + +if TYPE_CHECKING: + from ..plotting import RetrievalPlotData + from ..preprocess import TraceData + from ..processing import ProcessedResult, TruthPulse + from ..result import RetrievalResult + from .params import RetrieveParams + +__all__ = [ + "DISPERSION_FIGURE", + "DISPERSION_FILE", + "OPTIONS_FILE", + "OVERVIEW_DPI", + "OVERVIEW_FILE", + "RESULT_FILE", + "UNCERTAINTY_FIGURE", + "result_mismatch", + "save_session", +] + +OPTIONS_FILE = "options.toml" +"""The settings of a saved session.""" +RESULT_FILE = "result.h5" +"""The retrieval of a saved session.""" +OVERVIEW_FILE = "retrieval.pdf" +"""The overview figure of a saved session.""" +OVERVIEW_DPI = 600 +"""The overview's resolution: its trace maps are rasterised inside the PDF.""" +DISPERSION_FILE = "result_dispersion.h5" +"""The retrieved pulse with the GUI's dispersion applied.""" +DISPERSION_FIGURE = "dispersion.pdf" +"""The GUI's dispersion figure.""" +UNCERTAINTY_FIGURE = "uncertainty.pdf" +"""The GUI's figure of the uncertainty estimate on screen.""" + +# Relative tolerance for "the same array": both sides are stored exactly as the +# retrieval saw them, so only floating-point round-off may differ. +_SAME = 1e-9 + + +def save_session( + folder: str | Path, + options: SessionOptions, + *, + result: RetrievalResult | None = None, + processed: ProcessedResult | None = None, + truth: TruthPulse | None = None, + uncertainty: Mapping[str, Any] | None = None, + overview: RetrievalPlotData | None = None, +) -> list[str]: + """Write a session into ``folder`` and return the names of the files written. + + Parameters + ---------- + folder : str or Path + An existing folder; files of the same names are overwritten. + options : SessionOptions + The settings, always written to :data:`OPTIONS_FILE`. + result, processed, truth + The retrieval, its processed form and any known truth, written to + :data:`RESULT_FILE` when ``result`` is given. + uncertainty : mapping, optional + Uncertainty estimates by method, appended to :data:`RESULT_FILE`. + overview : RetrievalPlotData, optional + Drawn as the twelve-panel overview into :data:`OVERVIEW_FILE`. + + Raises + ------ + FileNotFoundError + If ``folder`` does not exist. + ValueError + If ``uncertainty`` is given without a ``result`` to append it to. + """ + folder = Path(folder) + if not folder.is_dir(): + raise FileNotFoundError(f"no such folder: {folder}") + if uncertainty and result is None: + raise ValueError("uncertainty estimates need the result they belong to") + written = [] + if result is not None: + path = folder / RESULT_FILE + save.save_result( + result, str(path), processed=processed, truth=truth, force=True + ) + written.append(RESULT_FILE) + if uncertainty: + save.save_uncertainty(dict(uncertainty), str(path), force=True) + save.save_options(options.to_dict(), str(folder / OPTIONS_FILE)) + written.append(OPTIONS_FILE) + if overview is not None: + from .. import plotting # matplotlib only when a figure is drawn + + figure = plotting.plot_retrieval_page(overview, plotting.RETRIEVAL_OVERVIEW) + figure.savefig(folder / OVERVIEW_FILE, dpi=OVERVIEW_DPI) + written.append(OVERVIEW_FILE) + return written + + +def result_mismatch( + saved: Mapping[str, Any], tracedata: TraceData, retrieve: RetrieveParams +) -> str: + """Why a saved result cannot be paired with ``tracedata``, or ``""`` if it can. + + Reuse is only sound when the saved retrieval was fitted to *this* trace, so + the saved measured trace and frequency axis are compared with the current + ones element-wise rather than by shape: any difference (a moved delay grid, + a changed filter, a new calibration) is grounds to re-run rather than to show + a fit against data it never saw. A file too old to carry ``trace_meas`` + cannot be verified, so it is refused too: declining costs only a re-run, + wrongly accepting costs a wrong residual panel. + + Parameters + ---------- + saved : mapping + A result file as read by :func:`croak.save.load_result`. + tracedata : TraceData + The trace the replayed session produced. + retrieve : RetrieveParams + The replayed retrieval settings (their solver must match the file's). + """ + current = tracedata.trace + meas = saved.get("trace_meas") + if meas is None: + return "the saved result does not store the trace it was fitted to" + meas = np.asarray(meas, float) + if meas.shape != np.shape(current): + return ( + f"the saved result is on a {meas.shape[0]}x{meas.shape[1]} grid but " + f"this trace regrids to {np.shape(current)[0]}x{np.shape(current)[1]}" + ) + peak = float(np.max(np.abs(current))) or 1.0 + if float(np.max(np.abs(meas - current))) > _SAME * peak: + return "preprocessing no longer reproduces the trace the result was fitted to" + omega_saved = saved.get("omega") + if omega_saved is not None: + omega = np.asarray(tracedata.grid.omega, float) + omega_saved = np.asarray(omega_saved, float) + span = float(np.max(np.abs(omega))) or 1.0 + if omega_saved.shape != omega.shape or ( + float(np.max(np.abs(omega_saved - omega))) > _SAME * span + ): + return "the saved result is on a different frequency grid" + algorithm = saved.get("algorithm") + if algorithm is not None and str(algorithm) != str(retrieve.solver): + return ( + f"the saved result came from {algorithm!s} but the loaded settings " + f"select {retrieve.solver}" + ) + return "" diff --git a/docs/_static/gui_analyse.png b/docs/_static/gui_analyse.png new file mode 100644 index 0000000..4f92468 Binary files /dev/null and b/docs/_static/gui_analyse.png differ diff --git a/docs/_static/gui_home.png b/docs/_static/gui_home.png new file mode 100644 index 0000000..3eaa67a Binary files /dev/null and b/docs/_static/gui_home.png differ diff --git a/docs/_static/gui_retrieve.png b/docs/_static/gui_retrieve.png index 12b9eaf..9fdf29e 100644 Binary files a/docs/_static/gui_retrieve.png and b/docs/_static/gui_retrieve.png differ diff --git a/docs/conf.py b/docs/conf.py index 44b6d2b..7d93b9b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,8 +8,12 @@ from __future__ import annotations +import importlib +import pkgutil from pathlib import Path +from sphinx.pycode import ModuleAnalyzer + import croak # -- Project information ----------------------------------------------------- @@ -81,6 +85,31 @@ autodoc_typehints = "description" autodoc_member_order = "bysource" + +# croak.plotting is a package whose __init__.py re-exports what its submodules +# define. autodoc reads a constant's ``#:`` doc-comment only from the source of the +# module it documents -- here __init__.py, where a re-exported constant has none -- +# and silently drops the constant as undocumented. Seed autodoc's cached analysis +# of the package with the comments (and annotations) of the defining submodules. +def _adopt_submodule_attr_docs(package_name: str) -> None: + """Give ``package_name``'s analyzer the attribute docs of its submodules.""" + package = importlib.import_module(package_name) + analyzer = ModuleAnalyzer.for_module(package_name) + analyzer.analyze() + for info in pkgutil.iter_modules(package.__path__, f"{package_name}."): + submodule = ModuleAnalyzer.for_module(info.name) + submodule.analyze() + for (scope, name), comment in submodule.attr_docs.items(): + if scope == "" and name in package.__all__: + analyzer.attr_docs.setdefault((scope, name), comment) + if (scope, name) in submodule.annotations: + analyzer.annotations.setdefault( + (scope, name), submodule.annotations[scope, name] + ) + + +_adopt_submodule_attr_docs("croak.plotting") + napoleon_numpy_docstring = True napoleon_google_docstring = False napoleon_use_rtype = False diff --git a/docs/developer_guide/architecture.md b/docs/developer_guide/architecture.md index 1255d4f..883d6f1 100644 --- a/docs/developer_guide/architecture.md +++ b/docs/developer_guide/architecture.md @@ -9,13 +9,34 @@ without opening a window. | Core physics | Grids, transforms, pulse construction, interactions, propagation, focusing, and collection | [Core physics API](../reference/api/core.md) | | Retrieval | Solver selection, objectives, optimisation, progress, and result assembly | [Retrieval API](../reference/api/retrieval.md) | | Workflow | Loading, preprocessing, post-processing, uncertainty, persistence, and session replay | [Workflow API](../reference/api/workflow.md) | -| Plotting | Plot data and visualisation of traces, fields, and diagnostics | [Plotting API](../reference/api/plotting.md) | +| Plotting | Plot data, matplotlib figures, and the Qt-free screen views the GUI draws | [Plotting API](../reference/api/plotting.md) | | JAX calculations | Differentiable forward models, residuals, and pulse parameterisation | [JAX API](../reference/api/jax.md) | +| GUI | The PySide6 window: Home, the stages, worker threads, and pyqtgraph views | [GUI and session integration](gui_sessions.md) | Keep physical calculations independent of GUI state and file I/O. Prefer the shared workflow operations when adding a command or GUI control so that the same settings retain the same meaning in scripts and saved sessions. +## The GUI + +The GUI (`croak.gui`, the `gui` extra) sits on two Qt-free layers. The session +layer, `croak.session`, holds every setting as parameter dataclasses, maps each +stage onto the library, replays a saved session and writes a session's files; +the GUI, `croak replay` and `croak script` all go through it. The screen layer, +`croak.plotting.screen`, turns the data behind a figure into the arrays an +interactive view draws, using the same helpers as the matplotlib panels, so +the screen and an exported figure agree; `croak.plotting.export` draws any +screen view as a matplotlib figure, for the GUI's figure export. + +On top of them, `croak.gui.window.MainWindow` takes a session from Home through +Data, Check, Prepare, Retrieve and Analyse. The route between the stages is a set of +pure rules (`croak.gui.workflow`); each stage is a page that binds its controls +to the session's parameters and hands long computations to worker threads; and +every on-screen plot is a pyqtgraph view of screen data. Numerics belong in the +library and the two layers beneath, not in `croak.gui`. [GUI and session integration](gui_sessions.md) describes the +modules and their contracts, and [GUI design](gui_design.md) the look and layout +rules. + The [forward-model chapter](../user_guide/models.md) explains the physics; [retrieval and numerical methods](../user_guide/retrieval.md) explains the objectives and derivatives. diff --git a/docs/developer_guide/gui_design.md b/docs/developer_guide/gui_design.md new file mode 100644 index 0000000..f2414f5 --- /dev/null +++ b/docs/developer_guide/gui_design.md @@ -0,0 +1,165 @@ +# GUI design + +This page records how croak's window is meant to look and behave, so that a new +page or control fits the ones already there. It is a living page: change it in +the same pull request as a change to the GUI's look, layout or page set. The +code it describes is `croak/gui/theme.py` (colours, type and style sheet), +`croak/gui/sheet.py` (the stage layout) and `croak/gui/window.py` (the top bar); +[GUI and session integration](gui_sessions.md) covers the modules and their +contracts, and the [GUI guide](../howto/gui.md) what a user sees. + +The window follows the look of LupoLab's solprop front end, so the two +applications read alike. + +## Principles + +- **One primary action.** Each place has one button that moves the session on, + at the right of the top bar, and it is the only button drawn in the accent + colour. + Everything else is secondary: plain buttons on the form, the ⋯ menu, the menu + bar. What the action is, and when a stage can open, are pure rules in + `croak.gui.workflow`, not decisions scattered through slots. A stage that + cannot open yet is disabled with the reason as its tooltip, never hidden. +- **Controls beside their plots.** A stage is one sheet: its settings on the + left, the plots they change on the right, and changing a setting redraws the + plots without a separate Apply where the computation is cheap enough. +- **No physics in the GUI.** `croak.gui` binds widgets to the parameters of + `croak.session` and calls the library. A calculation the GUI needs is added to + the library or to `croak.session`, where scripts and replay get it too and it + is tested without a window. +- **Screen data shared with figures.** What a plot shows is decided in + `croak.plotting.screen` from the same helpers as the matplotlib panels; the + GUI only draws it. An exported figure is the matplotlib figure of the same + data, so the screen and the saved file agree. +- **Help has one source.** A setting's help is written once, in its page's + `HELP`, and appears as its tooltip, as the form's one-line help when it has + focus, and in the page's help dialog. +- **Say it where it happens.** Whether a stage can go on is on its ready line, + at the foot of the form (Retrieve has none: its numbers say how a run goes, + and a failure goes to the advice box over the plots); a passing notice (saved, copied, why a stage is + closed) goes to the foot of the window, which hides on Home when it is empty. + Modal dialogs are + for choosing files and for confirming that work will be discarded. +- **Remember arrangement, not results.** The window's size, folded sections + and the display settings persist between launches; computed results persist + only in saved sessions. +- **One way to save.** Save session writes everything the session holds; a page + that adds files (Dispersion's dispersed pulse, Uncertainty's figure) does so + in `PageBase.save_extras`, which the window calls, rather than with a Save + button of its own. +- **One display for every plot.** The spectral axis and the logarithmic range + are the viewer's settings, at the foot of the window (`display.DisplayBar`), + not a control on each page. Every `ViewPanel` draws its view through + `croak.plotting.display.displayed` and redraws when they change; a page that + computes a view differently on each axis (Check's marginals) builds it on the + display's axis, and the conversion then leaves it alone. + +## Colour + +Every colour is a token of `theme.Palette`, and the one style sheet is built +from the tokens; nothing else in the GUI calls `setStyleSheet`. Add a token +rather than a literal colour. + +| Token | Value | Use | +|---|---|---| +| `ink` | `#1b2430` | text | +| `muted` | `#5a6675` | secondary text: help, units, plot tick labels | +| `heading` | `#3d4957` | small headings, unselected rail items | +| `disabled` | `#a4adb8` | disabled text | +| `axis` | `#b4bcc6` | plot axes | +| `accent` | `#0b6e99` | the primary action, focus, sliders and progress | +| `accent_hover` / `accent_off` | `#095d82` / `#b9c4cc` | the primary action under the pointer / disabled | +| `selected_bg` / `selected_ink` | `#e8f1f5` / `#0a5a7d` | the current stage, rail item or segment; links | +| `hover_bg` | `#f1f3f5` | a flat control under the pointer | +| `hairline` | `#e3e6ea` | lines between the window's regions | +| `border` | `#cdd3da` | inputs and plain buttons | +| `ok` / `warn` / `error` | `#1c7a4a` / `#8a5200` / `#b3261e` | the ready line's three states | +| `advice_bg` / `advice_border` | `#fdf8ee` / `#e6cfa8` | the amber advice box | +| `background` | `#ffffff` | everything else, plots included | + +Curves take their colours in order from `plots.COLOURS` (blue, orange, green, +purple, red, grey, ochre, cyan), each line 1.7 px wide; a curve's role +(retrieved, measured, truth, phase, …) fixes its colour and dash, as in the +figures. Maps start white at zero: viridis for positive values and, on a signed +linear map, red for negative ones. + +## Type + +The window uses IBM Plex Sans (regular, medium and semibold), bundled in +`croak/gui/assets/fonts` under the SIL Open Font License. If it fails to load, +the system font is used and a warning is logged. The scale, in logical pixels: + +| Role | Size | Weight | +|---|---|---| +| Home's heading (`h1`) | 28 | semibold | +| Page and brand headings (`h2`) | 15 | semibold | +| A derived value | 19 | medium | +| Body text, inputs, buttons | 13 | regular | +| Help, notes, plot titles, small labels | 12 | regular or medium | +| Plot ticks and axis labels | 11 | regular | + +Widgets take their style from their *role*, which is their `objectName` +(`theme.label(text, "h3")`, `theme.button(text, "primary")`, +`theme.frame("form")`), and from boolean properties such as `current`, `warn` +and `error`, which `theme.set_flag` sets and re-applies. + +## Layout + +- **Target**: 1366×768 logical pixels and up, opened maximised. +- **Top bar**: 52 px high, with a hairline beneath. Brand and context at the + left; Home, a hairline divider and the stage buttons in the middle; the + primary action and ⋯ at the right. +- **Sheet**: the form is a fixed 340 px column (`sheet.FORM_WIDTH`) and the plots + take the rest of the width. Below a window width of 1000 px the plots move + under the form, which then spans the window. The form scrolls on its own; its + help line and ready line stay at its foot, and its combo boxes shrink to the + column rather than widen it. A slider's spin box is as wide as its numbers + (`widgets.CompactSpinBox`, up to seven characters), so the slider has the rest + of the row. +- **Sections** fold in place. Settings most sessions leave alone start folded + and remember their state (a `settings_key`), as Retrieve's advanced sections + do. +- **Plots** are white, closed boxes (top and right axes drawn without numbers), + with a crosshair and readout on hover, mouse zoom and pan, and a double-click + back to the starting view. A map's colour bar can be dragged, for that map + alone. Plots that share an axis are linked. A log axis is labelled at decades + only. A plot is at least 140×100 px. +- **Numbers** are a `sheet.DerivedGrid`: a small grey name over a large value, + as solprop's design numbers. Retrieve's sit in a strip of fixed height beneath + its plots, beside the run's convergence. +- **Analyse** has a 170 px rail of pages on the left. +- **Export**: every plot has a right-click menu (figure, image, copy), and every + page a figure (`PageBase.figure`), saved by Ctrl+E. Figures are drawn from the + screen views by `croak.plotting.export`, with maps rasterized in a vector + file. +- **Drawer**: 440 px on the right, for text the user may copy (the settings, + the script). + +## Page catalogue + +| Place | Page | Primary action | Plots | +|---|---|---|---| +| Home | four tiles (three entries, Open a session), About croak, recent sessions | none | none | +| About | version, links (manual, GitHub, PyPI, issues), authors, copyright | none | none | +| Data | Load the trace / Load the scan / Generate the trace | Continue | the loaded trace; for a scan its maps and marginals; for a synthetic pulse the pulse, spectrum, spectrogram and trace | +| Check | — | Continue | the trace and the marginal comparison | +| Prepare | — | Continue | the grid's numbers over the measured, filtered and regridded traces in dB and linear (2×3) | +| Retrieve | — | Retrieve / Stop / Analyse, with Retrieve again beside it | the live overview; the run's numbers and its convergence | +| Analyse | Overview, Traces, Pulse, Diagnostics | Save | the retrieval's panels, two by two | +| Analyse | Dispersion | Save | dispersed spectrum, pulse, applied phase, spectrogram | +| Analyse | Uncertainty | Estimate / Stop | FWHM distribution (or FWHM and error against thickness), the temporal band | + +## Left for later + +These are known gaps, kept out of the redesign to keep it reviewable: + +- **A dark theme.** The style sheet is built from one `Palette`, so a dark one + would be a second instance, but the plots' white background and colour maps + would need choosing too. +- **Few derived numbers.** `sheet.DerivedGrid` holds Retrieve's and Prepare's + numbers so far. `AdviceBox` carries two pieces of advice: Prepare's, when the + λ grid clips the trace, and Retrieve's, when a retrieval fails. +- **The stage pages' interface.** The stage modules still build through + `base.Stage`, which keeps the calls they were written against for the old + wizard; moving them onto `StagePage` directly is a later clean-up. +- **Find a setting** is offered on Retrieve, the longest form, only. diff --git a/docs/developer_guide/gui_sessions.md b/docs/developer_guide/gui_sessions.md index f1206db..fcfcb53 100644 --- a/docs/developer_guide/gui_sessions.md +++ b/docs/developer_guide/gui_sessions.md @@ -13,28 +13,161 @@ The [session API](../reference/api/workflow.md#sessions-headless-replay) documen the serialisable settings and execution interfaces. The [command-line and scripting API](../reference/api/workflow.md#command-line-and-script-generation) covers headless use and generated scripts. User-facing instructions belong in -[saving, sessions, and reproducible workflows](../user_guide/workflows.md). - -## Live views (GUI support) - -Two classes redraw nothing when new data arrive: they draw a figure once and then -push later data onto the existing artists, which is what keeps the wizard's live -previews cheap and steady. {class}`~croak.plotting.FrogFilterView` does this for -the six-panel filter figure (`attach(fig)` then `update(td)`), and -{class}`~croak.plotting.RetrievalPageView` for one page of retrieval panels -(`draw(fig, data)`, then `update(data)` whenever `can_update(data)` says the new -result differs only in values — same grid, same optional curves). Behind the page -view, every `draw_` has an `update_(artists, data)` twin, and the -invariant the tests hold them to is that `update(draw(d1), d2)` shows exactly -what `draw(d2)` shows. {func}`~croak.plotting.plot_convergence` returns its -artists for the same reason, and `update_convergence_curve` grows the error -history in place. - -An embedded `MplCanvas` keeps its owning Qt window alive for the duration of a -draw. A queued Matplotlib draw holds the Python canvas wrapper, but that alone -does not keep Qt from deleting the canvas when its parent is garbage-collected. -Retaining the window during rendering prevents a discarded stage from losing -its canvas midway through a draw; it does not retain the window after drawing. +[saving, sessions, and reproducible workflows](../user_guide/workflows.md) and +the [GUI guide](../howto/gui.md); the look and layout rules are in +[GUI design](gui_design.md). + +## Three layers + +The GUI is the thinnest of three layers, and only it imports Qt: + +- **`croak.session`** — the per-stage parameter dataclasses + (`croak.session.params`), the serialisable `SessionOptions`, the mapping of + each stage onto the library (`croak.session.pipeline`), headless replay + (`croak.session.engine`), retargeting (`croak.session.retarget`) and a saved + session's files (`croak.session.files`: `save_session` writes `result.h5`, + `options.toml` and `retrieval.pdf`; `result_mismatch` says whether a saved + result was fitted to the trace a replay produced). +- **`croak.plotting.screen`** — what an on-screen view shows, as plain data: + `MapView` for an image and `LineView` for curves, built by functions such as + `trace_map`, `temporal_view`, `filter_views` and `uncertainty_views`. They use + the same helpers as the matplotlib panels, so a curve on screen is the curve in + an exported figure. +- **`croak.gui`** — Qt glue: widgets bound to the session's parameters, worker + threads, and pyqtgraph views that draw the screen data. + +Importing `croak` or `croak.gui` does not import Qt; {func}`croak.gui.run_gui` +does. A change that needs numerics belongs in the first two layers, where it is +tested without a window. + +## The window + +| Module | Holds | +|---|---| +| `window` | `MainWindow`: top bar, stage stack, drawer, menus, and the session actions (new, open, replay, retarget, save) | +| `workflow` | the route as pure rules: `Stage`, `Progress`, `blocked_reason`, `next_stage`, `primary_action`, `secondary_action`, `remember_recent` | +| `home` | Home: the entry tiles and Open a session (each with its drawing from `assets/tiles`), About croak and the recent sessions | +| `about` | About: the version, links, authors and copyright, from the package metadata | +| `pages` | `default_pages` (the page factory), `DataPage` (the entry's loader, and a reopened session's restore) and `AnalysePage` (the rail) | +| `stage` | `PageBase`, the hooks the window reads, and `StagePage`, a page that is a sheet | +| `base` | `Stage`, the interface the stage modules are written against (`controls`, `set_plot_area`, `set_status`, `advance`), and `RunControls` | +| `sheet` | the sheet's parts: `Form`, `Section`, `ReadyLine`, `Segments`, `AdviceBox`, `DerivedGrid` and `Sheet` itself | +| `stage_load`, `stage_simulated`, `stage_synthetic` | the three loaders (Data) | +| `stage_marginal_check`, `stage_preprocess`, `stage_retrieve`, `stage_dispersion`, `stage_uncertainty` | the other stage pages | +| `view_panel` | `ViewPanel`, a titled plot of one screen view, drawn as the display settings show it, with its right-click menu, and the figure export of a plot or a page (`page_figure`, `export_figure`) | +| `display` | the display settings every plot follows (spectral axis, logarithmic range): `current`, `set_display`, the `hub` that tells the plots, and `DisplayBar` at the foot of the window | +| `retrieval_views` | the retrieval's panels on screen (`RetrievalPanels`) and Analyse's result pages (`ResultPage`, with Export and Pop out) | +| `plots` | the pyqtgraph views: `Plot`, `MapPlot`, `LinePlot`, the colour maps and the hover readers | +| `theme` | the palette, type scale, style sheet and the role helpers (`label`, `button`, `frame`, `set_flag`) | +| `controls`, `widgets`, `readout`, `mirror_stack` | form builders, slider/spin-box composites, Retrieve's numbers, Dispersion's mirror stack | +| `help` | per-stage help: the dialog and the tooltips, from one `HELP` list | +| `state` | `WizardState`: the parameters and pipeline outputs, with cascading invalidation | +| `worker` | the `QThread` workers for preprocessing, retrieval and uncertainty | +| `settings` | remembered preferences in a `QSettings` store | +| `branding` | the application's name and icon | + +### The route + +Which stage can open and what the primary action does depend only on +`workflow.Progress`, a frozen snapshot of what the session holds. The rules are +pure functions with doctests and their own tests (`tests/gui/test_workflow.py`); +`MainWindow.refresh` re-applies them to the top bar whenever the state or a page +says something changed. Put a new navigation rule there, not in a widget's slot. + +### Pages + +A page tells the window what it can do through the `PageBase` hooks: +`on_enter`, `can_advance`, `busy`, `page_key` (which sub-page is shown, for Data +and Analyse), `act(kind)` for the primary action, `overview` (the retrieval +behind a saved session's PDF) and `show_help`. It emits `changed` when the +answers change and `message` for a status-bar notice. + +`MainWindow` builds a session's four pages with a factory +(`pages.default_pages`), and rebuilds them for every new or reopened session, +because their controls bind to the parameter objects on the shared +`WizardState`. A test can pass its own factory to `MainWindow`. + +Most stage pages subclass `base.Stage`, a `StagePage` that keeps the calls the +stage modules were first written against: sections go into `self.controls` +(from `controls.group(title, folded=…, settings_key=…)`), plots into +`set_plot_area(plot, *strips)`, and messages onto the ready line with +`set_status(text, "ok" | "warn" | "error")` (Retrieve overrides it: a notice at +the foot of the window, and a failure in its advice box). A loader implements `advance` and +emits `advanced("check")` or `advanced("retrieve")`; `DataPage` calls it on +Continue. A page that runs a worker mixes in `RunControls`, whose `busy` turns +the primary action into Stop. + +Help is declared once per page as `HELP = [(section, [(label, text), ...])]`. +`apply_help` attaches each text as the tooltip of the row with that +`(section title, row label)`, the form shows the focused control's tooltip as +its one-line help, and `HelpDialog` lists them all. A row label spells a literal +ampersand `&&` (Qt's mnemonic escape); `HELP` keys are written as the user reads +them. + +## Screen views (GUI support) + +What a view shows is decided in `croak.plotting.screen`; how it is drawn is +decided in `croak.gui.plots`. The screen functions take the same inputs as the +matplotlib panels — for the retrieval, a `RetrievalPlotData` from +{func}`~croak.plotting.retrieval_plot_data` — and return arrays, labels, ranges +and curve roles. Two display choices differ from the figures: an image is +resampled onto evenly spaced axes at its finest spacing (an interactive image is +placed by its corners, a `pcolormesh` by every edge), and a logarithmic trace +shows only the positive part, leaving negative samples to the signed linear maps. + +`retrieval_views.PANELS` maps each key of +{data}`~croak.plotting.RETRIEVAL_PANELS` to its screen function, so the GUI's +pages and the exported figures share the panel names and layouts +({data}`~croak.plotting.RETRIEVAL_PAGES`). A live retrieval calls +`RetrievalPanels.set_data` on the plots it already has, which replaces their data +without rebuilding the widgets. The figures of Analyse's result pages, the +dispersion and uncertainty figures a saved session holds and its +`retrieval.pdf` go through the +matplotlib `draw_*` and `plot_*` functions. Every other figure — a plot's +**Export this plot as a figure…**, or **Export this page as a figure…** on a page +without a figure of its own — is drawn from the screen views themselves by +`croak.plotting.export`, with the same role colours (`screen.ROLE_STYLE`), so a +new view can be exported without writing its figure. + +The screen functions are Qt-free and tested in `tests/test_plotting_screen.py`; +a new view starts there. + +## Threads + +The workers in `croak.gui.worker` add only threading, progress signals and +cancellation to the stage operations in `croak.session.pipeline`, so the GUI and +the headless engine run identical computations. `RetrievalWorker` emits live +previews at most every `PREVIEW_MIN_INTERVAL` (0.25 s). Retrieve reports what +presenting each one cost, and the worker stretches the interval to +`cost / PREVIEW_DUTY` (a duty of 0.25), so presenting never takes more than about +a quarter of the wall-clock time on a slow machine. + +## Remembered preferences + +`croak.gui.settings` keeps the window geometry, each folding section's state +(sections with a `settings_key`), the display settings (`display/spectral_axis` +and `display/db_range`, read and written by `croak.gui.display`) and the recent +sessions in a `QSettings` INI store, opening a short-lived store +for every read or write. It always names the organisation and application +explicitly, which is what lets the test suite redirect the store to a per-test +directory with `QSettings.setPath` (`tests/conftest.py`). + +## Testing the GUI + +The GUI tests use pytest-qt with `qt_api = "pyside6"` and run offscreen +(`QT_QPA_PLATFORM=offscreen`, set in `tests/conftest.py` and the pytest +environment), driving the real widgets through `qtbot`. They count towards the +100 % coverage gate like everything else. + +Many stage tests were written for the old six-stage wizard. +`tests/gui/gui_session.py` keeps them working: its `Driver` builds a +`MainWindow`, maps the old stage numbers onto the window (1 the loader on Data, +2 Check, 3 Prepare, 4 Retrieve, 5 Dispersion and 6 Uncertainty on +Analyse) and hands back the stage pages, which keep their behaviour. `goto` +bypasses the route rules, as the old stage setter did, so a test can look at a +page before its input exists. Tests of navigation itself use the window and the +route rules directly (`test_window.py`, `test_window_session.py`, +`test_workflow.py`). When testing saved window geometry, expose both the original and restored window with `qtbot.waitExposed` before comparing their displayed sizes. Qt diff --git a/docs/developer_guide/index.md b/docs/developer_guide/index.md index 5b16279..8ec39e0 100644 --- a/docs/developer_guide/index.md +++ b/docs/developer_guide/index.md @@ -18,6 +18,7 @@ environment architecture extending gui_sessions +gui_design validation documentation releasing diff --git a/docs/examples/thickness_uncertainty.md b/docs/examples/thickness_uncertainty.md index dec84ca..0f425f6 100644 --- a/docs/examples/thickness_uncertainty.md +++ b/docs/examples/thickness_uncertainty.md @@ -118,7 +118,7 @@ The central thickness is `options.retrieve.thickness_um`, and the retrieval must be dispersive. To call {func}`croak.thickness_bootstrap` directly, pass it `session.result` and `session.tracedata.trace`, with the material, quadrature `npoints` and `algorithm` the retrieval used. In the GUI the same estimate is -stage 5 with method "thickness". See [saving and loading](../howto/saving_and_loading.md). +the Uncertainty page under Analyse, with method "thickness". See [saving and loading](../howto/saving_and_loading.md). ### Limits diff --git a/docs/explanation/uncertainty_estimation.md b/docs/explanation/uncertainty_estimation.md index f8b2d8a..d292ef2 100644 --- a/docs/explanation/uncertainty_estimation.md +++ b/docs/explanation/uncertainty_estimation.md @@ -365,8 +365,8 @@ What this does and does not capture: optics' own uncertainty. (A `thickness_bootstrap`-style systematic on the propagation optics is the natural extension.) -In the GUI (stage 5) tick *"at dispersion-stage point"* to evaluate the bar at the beamline -point configured in the Dispersion stage. +In the GUI (the Uncertainty page under Analyse) tick *"at dispersion-stage point"* to +evaluate the bar at the beamline point configured on the Dispersion page. ## The noise model diff --git a/docs/getting_started/gui.md b/docs/getting_started/gui.md index bc00ea0..caae3e6 100644 --- a/docs/getting_started/gui.md +++ b/docs/getting_started/gui.md @@ -3,7 +3,7 @@ Install the graphical interface to inspect and retrieve a FROG trace without writing Python. [uv](https://docs.astral.sh/uv/) supplies Python and creates an isolated environment for croak. You will use a terminal for installation and -launching; subsequent analysis takes place in the wizard. +launching; subsequent analysis takes place in the GUI's window. ## Before you start @@ -56,29 +56,61 @@ configuration. If the window fails to appear on Windows, run output in the terminal. The window opens maximised and is laid out for displays of 1366×768 and up; -un-maximised, it returns to the size you last left it. +un-maximised, it returns to the size you last left it. It opens on **Home**: -```{image} ../_static/gui_retrieve.png -:alt: The wizard after a retrieval — the Traces page (measured and retrieved traces, log and linear) with the Pulse and Diagnostics tabs beside it, and the numeric read-out beneath. +```{image} ../_static/gui_home.png +:alt: Home — four tiles, each with a small drawing: start from a measured trace, a simulated scan or a synthetic trace, or open a saved session; the list of recent sessions is on the right. :width: 95% :align: center ``` ## 4. Use it -The wizard walks through the retrieval in stages: **Load** (your measured trace -from an HDF5 or `.npz` file, with an optional independent spectrum from HDF5, -`.npz` or CSV and calibration curves from CSV; or a simulated scan), -**Marginal check**, **Preprocess** (filtering, background removal, regridding), -**Retrieve**, **Dispersion** (tuning with materials and chirped mirrors) and -**Uncertainty** (error bars on the retrieved duration). Hover over a control for -help with that setting, and use each stage's **? Help** button for guidance. - -The [GUI guide](../howto/gui.md) documents each stage in detail. Sessions save -to a TOML file that records every setting, so a retrieval can be re-run later -— including headlessly with `croak replay session.toml` — or exported as a -standalone Python script with `croak script session.toml` (the `croak` command -is installed alongside `croak-gui`). +Pick a tile on Home — **Measured trace** (an HDF5 or `.npz` file, with an +optional independent spectrum from HDF5, `.npz` or CSV and calibration curves +from CSV), **Simulated scan** or **Synthetic trace** (a pulse you specify, to try +croak without data). The session then runs through five stages, shown as +buttons in the top bar: + +- **Data** — load (or generate) the trace; +- **Check** — compare its frequency marginal with the independent spectrum; +- **Prepare** — filtering, background removal, windowing and regridding; +- **Retrieve** — choose a solver and run it, with a live preview; +- **Analyse** — the retrieval's pages of panels, then **Dispersion** (tuning with + materials and chirped mirrors) and **Uncertainty** (error bars on the + retrieved duration). + +The button at the right of the top bar does the next thing — **Continue**, +**Retrieve**, **Save** — and a stage you cannot open yet is greyed out, with the +reason in its tooltip; **Home** at the start of the row goes back to Home. +Settings are in a narrow column on the left of each page and the plots fill the +rest; hover over a control for help with that setting, and over a plot to read +its values. Scroll to zoom, drag to pan, and double-click to return to the +starting view. Right-click a plot, or press Ctrl+E (⌘E) for the whole page, to +save it as a figure for publication: a vector PDF or SVG drawn by matplotlib. At the foot of the window, **Spectral axis** draws every spectral plot +against wavelength or frequency, and **Log range** sets how far down every +logarithmic map reaches; drag a map's colour bar to adjust that map alone. + +```{image} ../_static/gui_retrieve.png +:alt: Retrieve after a run — the solver settings on the left; on the right the measured and retrieved traces above the retrieved pulse and spectrum, with the run's numbers and its convergence beneath; Analyse and Retrieve again in the top bar, and the display settings at the foot of the window. +:width: 95% +:align: center +``` + +```{image} ../_static/gui_analyse.png +:alt: Analyse — a rail of pages (Overview, Traces, Pulse, Diagnostics, Dispersion, Uncertainty) beside the Pulse page: the retrieved pulse and spectrum above the delay and frequency marginals, with Save as the primary action. +:width: 95% +:align: center +``` + +The [GUI guide](../howto/gui.md) documents each stage in detail. **Save +session…** in the ⋯ menu writes the retrieval, a TOML file that records every +setting and an overview PDF into a folder (with the dispersed pulse and the +uncertainty figure, when Analyse holds them), and the session then appears under +Recent sessions on Home. A saved session can be re-run later — including +headlessly with `croak replay options.toml` — or exported as a standalone Python +script with `croak script options.toml` (the `croak` command is installed +alongside `croak-gui`). ## Updating diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index bdca53b..c146ba6 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -62,7 +62,7 @@ activate it, then `pip install -e .`. For work on croak itself, continue with th ## Supported platforms -croak's binary dependencies (`jax`, `nlopt`, `finufft` and, for the GUI, `pyqt6`) +croak's binary dependencies (`jax`, `nlopt`, `finufft` and, for the GUI, `pyside6-essentials`) publish wheels for a limited set of platforms. Where a wheel is missing, the installer falls back to building from source, which needs CMake and a C++ toolchain. @@ -141,7 +141,7 @@ list current driver and hardware requirements. | Delay filtering | `finufft` | | Data and sessions | `h5py`, `tomli-w` | | Plotting and progress | `matplotlib`, `tqdm` | -| GUI (`gui` extra) | `pyqt6`, `superqt` | +| GUI (`gui` extra) | `pyside6-essentials`, `pyqtgraph`, `superqt` | Importing `croak` does not import Qt. The library and session replay work without the graphical interface. diff --git a/docs/howto/collection_aperture.md b/docs/howto/collection_aperture.md index aca2023..ca3c952 100644 --- a/docs/howto/collection_aperture.md +++ b/docs/howto/collection_aperture.md @@ -95,7 +95,8 @@ hard-edged pinhole at the phase-matched corner) and `spectrum_frame_p` (the spectral-frame exponent — the mixture's field is the *on-axis* one, bluer than an integrated measured spectrum; the exponent reweights the initial guess and the `reg_spectrum` target together into that frame). The GUI exposes all of -these in the *Chromatic focal mixture (advanced)* group of the retrieve stage, +these in the *Chromatic focal mixture (advanced)* section of the Retrieve stage +(folded by default), mutually exclusive with the reduced-kernel checkbox, and `croak.session.pipeline.focal_mixture_from_params(params, td, scan_path)` builds the object — `run_retrieval` calls it automatically when `params.focal` diff --git a/docs/howto/fitting_thickness_tau0.md b/docs/howto/fitting_thickness_tau0.md index c405e6c..e0846cb 100644 --- a/docs/howto/fitting_thickness_tau0.md +++ b/docs/howto/fitting_thickness_tau0.md @@ -90,7 +90,7 @@ fit the extras from a **good pulse estimate**, not a cold start. Two ways: than restarting from the priors (a spectrum that was retrieved jointly with a non-zero $\tau_0$ is otherwise re-fitted against a differently-referenced delay axis). The GUI does this for you: *Reuse previous result* seeds every - extra that is **still being fitted**, and says which in the status line. An + extra that is **still being fitted**, and says which on the ready line. An extra whose fit box you have unticked keeps the value you set — an unticked box means "hold this where I put it", and the model is evaluated there. @@ -142,7 +142,8 @@ compare with repeated fits or a suitable bootstrap. ## In the GUI -The retrieve stage has an **Extra fit parameters** group with *Fit medium +The Retrieve stage has an **Extra fit parameters** section (folded by default; +click its title to open it) with *Fit medium thickness*, *Fit τ₀ delay offset*, *Fit smearing width* and *Two-phase polish*. *Fit smearing width* is enabled only when *Enable geometrical smearing* is on (see [Geometric smearing](geometric_smearing.md)). *Fit medium thickness* diff --git a/docs/howto/geometric_smearing.md b/docs/howto/geometric_smearing.md index 4556178..9e5770d 100644 --- a/docs/howto/geometric_smearing.md +++ b/docs/howto/geometric_smearing.md @@ -265,7 +265,7 @@ chosen solver supports it: - *Quadrature nodes* *Fit smearing width* sits in **Extra fit parameters** and is enabled only once the -kernel is on. When it has been fitted, the post-retrieval status line reports the +kernel is on. When it has been fitted, the ready line under the form reports the result next to the other fitted extras, as the physical delay width and the multiplier it came from: diff --git a/docs/howto/gui.md b/docs/howto/gui.md index 5872319..88aa6a6 100644 --- a/docs/howto/gui.md +++ b/docs/howto/gui.md @@ -1,15 +1,14 @@ -# The wizard GUI +# Using the GUI -The graphical wizard guides you from a loaded trace to a retrieved pulse, -dispersion analysis and uncertainty estimates. Its controls use the same -calculations as the Python interface. If this is your first visit, follow +The graphical interface takes you from a loaded trace to a retrieved pulse, +dispersion analysis and uncertainty estimates in one window. Its controls use the +same calculations as the Python interface. If this is your first visit, follow [installing the GUI](../getting_started/gui.md), then try a synthetic trace whose pulse is known. -Every plot carries the standard matplotlib navigation toolbar (Home, Pan, -Zoom-to-rectangle, Save, and a live cursor read-out), so you can zoom and pan to -inspect a trace or marginal. A full redraw (changing a control, recomputing) -resets the view; **Home** restores it. +A session runs through five stages — **Data → Check → Prepare → Retrieve → +Analyse** — which you reach from **Home**. The top bar always shows where you are, which +stages you can open and the one action that moves the session on. ## Launching @@ -20,33 +19,45 @@ croak-gui ``` Within a uv project or source checkout, use `uv run croak-gui` or -`uv run python -m croak.gui`. From Python, call {func}`croak.gui.run_wizard`: +`uv run python -m croak.gui`. From Python, call {func}`croak.gui.run_gui`: ```python import croak.gui -croak.gui.run_wizard() +croak.gui.run_gui() ``` {func}`croak.gui.main` is the console-script entry point; -{func}`~croak.gui.run_wizard` creates the `QApplication` and shows the window -**maximised**, because the plot pages need the room. Un-maximising gives the -window the size you last left it (remembered between launches and clamped to the -screen it opens on), or on a first run a window filling 90 % of the screen. - -The wizard is laid out for displays of **1366×768 logical pixels and up** (a -1920×1080 monitor at Windows' 125 % scaling is 1536×864 logical pixels). Plot -text (titles, axis labels, tick labels, legends and their padding) shrinks as a -canvas gets smaller than the size its figure was designed for, down to 70 % of -the default size, and grows back when the window does. A laptop therefore shows -the same panels at a smaller type size rather than losing the axes behind their -labels. Figures written by a stage's **Save…** button are drawn afresh at full -size, whatever the window size ({class}`~croak.plotting.PlotScale` is the -mechanism). +{func}`~croak.gui.run_gui` creates the `QApplication` and shows the window +({class}`croak.gui.MainWindow`) **maximised**, because the plots need the room. +Un-maximising gives the window the size you last left it (remembered between +launches and clamped to the screen it opens on), or on a first run a window +filling 90 % of the screen. + +The GUI is built on PySide6. Every plot on screen is an interactive pyqtgraph +view; figures you export or save are drawn by matplotlib ({mod}`croak.plotting`), +so they look the same as the figures a script makes. + +### Small screens + +The window is laid out for displays of **1366×768 logical pixels and up** (a +1920×1080 monitor at Windows' 125 % scaling is 1536×864 logical pixels). Each +stage puts its settings in a narrow column on the left (340 pixels) and gives the +rest of the window to its plots; when the window is narrower than 1000 pixels +the plots move below the settings instead. The +settings are grouped in sections that fold, and Retrieve's +advanced sections start folded, so a laptop screen shows the settings you use +most without scrolling. Beneath Retrieve's plots, the run's numbers and its +convergence share one short strip. + +Plot text stays at the same size whatever the window size. Exported figures +(see [Figures for publication](#figures-for-publication)) and the files a +**Save** writes are drawn afresh at their own design size, so they do not depend +on the window either. ### Desktop integration -The wizard carries its own icon (`croak/gui/resources/croak.svg`) and identifies +The GUI carries its own icon (`croak/gui/resources/croak.svg`) and identifies itself as **croak** rather than as the Python interpreter running it: that is the icon in the macOS Dock, the Windows taskbar and the Linux dock, and the name in the macOS menu bar. Two platform details remain: @@ -58,249 +69,497 @@ the macOS menu bar. Two platform details remain: console window, but a crash before the window appears is then silent. Run `uv run python -m croak.gui` from a terminal to see the traceback. -## Navigation - -Every page except the Welcome menu carries the same controls: a header bar with the -page title and a **? Help** button that explains that page's options, and a -footer with **Cancel** and **Main menu** at the bottom left and **← Back** / -**Next →** at the bottom **right**. That includes the two entry pages, where the -forward button does the page's own work and is labelled accordingly — -**Generate →** on the synthetic generator, **Load & continue →** (or -**Load raw → Retrieve →**) on the simulated loader — and is disabled until the -page has something to hand on. **Back** from either entry page returns to the -Welcome menu, as does **Back** on Stage 1 and the **Main menu** button. If the -session already holds a loaded trace or a retrieval, the wizard asks first: every -menu entry starts a new session, so choosing one would discard them. - -### Layout - -Each stage keeps its controls in a scrollable column on the left and its plots on -the right, separated by a draggable divider. Drag it to trade control space for -plot space, or drag it fully to the left to hide the controls altogether; the width -you choose applies to every stage and is remembered for the next launch. A strip -beneath a plot with a titled header (the Retrieve stage's numeric read-out) folds -away with a click on the header, and that is remembered too. +## Home + +Home is where a session starts. Its first three tiles each start a session +from one kind of data: + +- **Measured trace** — load a FROG measurement from HDF5 or NPZ, with its + independent spectrum, backgrounds and calibration curves. +- **Simulated scan** — load a numerically simulated scan (a `scansave` file), + with its known input pulse and instrument geometry. +- **Synthetic trace** — generate a trace from a pulse you specify, with + dispersion and noise, and test a retrieval against the known answer. + +The fourth tile, **Open a session**, reopens a saved session from its +`options.toml` (see [Sessions](#sessions)). Beneath the tiles, **About croak** opens the About +page: the version, links to this manual, the source on GitHub, the package on +PyPI and the issue tracker, the authors, and the copyright and licences. The **Recent sessions** list on the +right holds the last six sessions you saved or opened, named by their folder, +with the folder's location and the time the session was last saved; a session +whose file has gone is left out. Click one to reopen it. + +Starting a new session, or opening one, replaces the session in the window. If +that session holds a trace or a retrieval, the GUI asks first, with **Discard** +or **Cancel**; **Save session…** in the ⋯ menu keeps it. + +## The top bar + +The top bar carries, from left to right: + +- the **croak** brand and a context label: what the session is (*Measured + trace*, *Simulated scan* or *Synthetic trace*) and, once it has been saved or + opened, its folder's name; +- **Home**, which returns to Home (so does the brand). The session stays open: + its stage buttons are still there, so you can go back to it; +- the five stage buttons **Data · Check · Prepare · Retrieve · Analyse**. A + stage that cannot be opened yet is greyed out, and its tooltip says why; +- the **primary action**, the button that moves the session on, and on + Retrieve, once a retrieval is done, **Retrieve again** beside it; +- the **⋯** menu: **Save session…**, **Open a session…**, **Export this page as + a figure…**, **Show the settings (options.toml)**, **Show as a Python + script**, **Help for this stage** and **About croak**. + +The menu bar repeats these under **File** (Home, Open a session…, Save +session… and Export this page as a figure…, with the platform's Open and Save +shortcuts and Ctrl+E, ⌘E on macOS), **View** (the settings and the script) and +**Help** (Help for this stage, F1 on Windows and Linux, ⌘? on macOS, and About +croak, which macOS moves to the application menu). Passing notices, such as "Saved …" or why a stage cannot open, +appear at the foot of the window, which also holds the +[display settings](#display-spectral-axis-and-log-range) while a session is +open, and hides on Home when there is nothing to say. + +### The route + +Which stages can be opened depends only on what the session holds: + +| Stage | Opens once | +|---|---| +| Data | a session has started | +| Check | a trace has been loaded | +| Prepare | a trace has been loaded | +| Retrieve | the trace has been prepared | +| Analyse | a retrieval has run | + +A simulated scan loaded with **Skip filtering & regrid** is retrieved on its own +grid, so it goes from Data straight to Retrieve and Prepare stays disabled +(Check stays open, to look at the scan, and its Continue also goes to +Retrieve). +Changing a setting upstream clears what was derived from it — a new trace clears +the prepared trace and the retrieval — so the GUI never shows a pulse retrieved +from superseded settings, and the later stages close again until they are +recomputed. + +The primary action follows the stage: + +| Where | Primary action | +|---|---| +| Data | **Continue**: load or generate the trace and go on to Check | +| Check | **Continue** to Prepare | +| Prepare | **Continue** to Retrieve | +| Retrieve | **Retrieve**, **Stop** while it runs, then **Analyse**, with **Retrieve again** beside it | +| Analyse | **Save** (the session); on the Uncertainty page **Estimate**, or **Stop** while it runs | + +The action is greyed out until it can be done: Continue on Data, for example, +until there is a trace to hand on. + +## The stage pages + +Every stage page has the same layout. On the left is a form of **sections**, +each headed by a title you click to fold or unfold it. Hover over a control for +its help; when a control has the keyboard focus, the same help is shown as one +line under the form. **Help for this stage** (⋯ menu or F1) opens a summary of +every setting on the page. The Retrieve form also has a **Find a setting** box, +which shows only the rows whose label or help contains what you type; typing +part of a section's title shows that whole section. + +At the foot of the form a **ready line** says whether the stage can go on: green +when it can, amber with a warning, red when something has to be fixed first. +Retrieve has none: the numbers beneath its plots say how a run goes, anything +else it has to say appears at the foot of the window, and a failed retrieval +shows in an amber box over the plots until the next run. On the right are the +stage's plots. + +A setting with a slider has its value in a box just wide enough for the numbers +it takes, so the slider has the rest of the row; a range has its two ends either +side of one slider. + +## Plots + +Every plot shares the same handling: + +- **Hover** to show a crosshair and a read-out: the position, and the value + of the map or of each curve under the pointer. +- **Scroll** to zoom about the pointer, and **drag** to pan. +- **Double-click** to return to the view the data were first shown with, and + a map to its colour scale. +- **Drag a colour bar's ends** (or the bar itself) to change that map's colour + scale on its own, for example to look further down a logarithmic trace. A + signed map keeps its white at zero wherever the scale is dragged. + +Plots that share an axis are linked, so zooming one zooms its partners: the +trace maps on Prepare, and the measured and retrieved traces on Retrieve and +Analyse. Images are shown on evenly spaced axes at their finest sample spacing, +so no detail is lost where the samples are densest. A logarithmic (dB) trace +shows the positive part of the signal; negative samples, left by background +subtraction, show on the linear maps, which run from white to viridis for +positive values and from white to red for negative ones. + +### Display: spectral axis and log range + +Two settings at the foot of the window hold for every plot on every page. They +are remembered between launches and stay until you change them or click +**Reset**: + +- **Spectral axis**: **As computed** keeps each plot on the axis its data were + computed on — wavelength for a loaded trace and the generator's previews, + frequency for the prepared trace and the retrieval. **Wavelength** or + **Frequency** draws every spectral plot, maps and curves alike, against that + axis, with ν = c/λ. An intensity is a density per unit of its axis, so it + takes the Jacobian too, $I_\nu = I_\lambda\,\lambda^2/c$ and + $I_\lambda = I_\nu\,\nu^2/c$: a broad spectrum changes shape, not only + position. Phases and filter factors only move. Each plot keeps its peak, and a + map is resampled onto an even grid on the new axis, for drawing only + ({mod}`croak.plotting.display`). +- **Log range**: how far below its peak every logarithmic (dB) map reaches, + from 5 to 120 dB (30 dB by default). A dragged colour bar overrides it for one + map until that map is next drawn. + +Exported figures follow both: a plot or page exported from the screen is drawn +as shown, and croak's own figures of a result (Analyse's Overview, Traces, Pulse +and Diagnostics pages, and the `retrieval.pdf` a saved session holds) take the +log range and draw their spectrum on the chosen axis. Their trace maps are +always against frequency, so with the display on wavelength Analyse's pages +are exported as they are shown instead. + +**Right-click** a plot for its own menu: **Export this plot as a figure…**, +**Save as an image…** (a PNG of the plot as it is on screen) and **Copy as an +image**. + +The pages on Analyse also have **Export ▾**: **Figure…** saves that page as a +figure (as **Export this page as a figure…** does), and **Pop out** opens it in +a window of its own, which follows later results. + +### Figures for publication + +Any page, or any single plot, can be saved as a figure drawn by matplotlib: +**Export this page as a figure…** (⋯ menu, File menu or Ctrl+E) for the page on +screen, or **Export this plot as a figure…** from a plot's right-click menu. +Choose **PDF** or **SVG** for a vector figure, or **PNG**. In a vector figure the +axes, curves and text stay sharp and the text stays editable text; the colour +maps are embedded as images at 300 dpi (rasterized), so a trace map stays small +and opens quickly. A PNG is written at 300 dpi. + +The figure shows the same data as the screen, with the same colours, line styles +and labels, sized for print: each plot is 3.4 × 2.6 inches (a journal's single +column) in 8-point type, and a page keeps its plots' arrangement. Analyse's +Overview, Traces, Pulse and Diagnostics pages are saved as croak's own figure of +that page ({func}`~croak.plotting.plot_retrieval_page`), as a script makes it. +From a script, {mod}`croak.plotting.export` draws the same screen views. ## The stages -The wizard is a stacked sequence of pages with shared navigation. The six linear -stages — **Load → Marginal check → Preprocess → Retrieve → Dispersion → -Uncertainty** — sit behind three entry pages (Welcome, Synthetic, Simulated); the -synthetic generator and the simulated loader are each their session's "stage 1" -and hand the trace straight to the marginal-check stage. - -1. **Welcome** — choose to load a measured file, load a numerically simulated - scan, load a previous retrieval, or generate a synthetic trace. -2. **Synthetic** *(optional)* — build a test trace (pulse shape, interaction, - delays, optional dispersive slab) using {func}`~croak.forward.maketrace`. Useful - for trying the workflow without data. The **duration** is the intensity - (power) FWHM of the temporal envelope — both Gaussian and sech² honour this - convention ({func}`~croak.pulses.gaussian_pulse`, - {func}`~croak.pulses.sech_pulse`). The delay and wavelength samplings each - expose an interlocked **count** and **step** at fixed range (editing one - updates the other), and a **Spectrum (independent)** group generates a - fundamental spectrum that is passed downstream (range / step / # points); by - default it **mirrors the FROG-trace λ window**, scaled to the fundamental - (×2 for SHG). With it present, the marginal-check overlay and the SHG marginal - correction work on synthetic traces too. The known pulse (its temporal - intensity envelope `It`) is carried through and shown beneath the retrieved - pulse, and saved with the result. -3. **Simulated** *(optional)* — load a numerically simulated `scansave` scan and - preview it, then hand it to the marginal-check stage. A **thickness** dropdown - selects which saved propagation depth to retrieve from a multi-thickness scan - (disabled for older single-thickness files). See - [Loading numerically simulated traces](loading_simulated.md). -4. **Load** — pick an HDF5/NPZ file and the trace/wavelength/delay datasets, with - unit and axis auto-detection ({mod}`croak.io`). An optional independent - spectrum can come from HDF5, NPZ or CSV, with background subtraction, and - wavelength calibration curves from CSV. **Transpose** fixes a trace - that loaded as (delay × wavelength), and **Reverse delay axis** flips the - delay-sign convention — negating the scan axis, so the trace is mirrored about - τ = 0. Which end of a stage scan is "gate late" is a wiring convention the - file does not record. It matters for **PG and SD**, whose kernels fix the - direction of time: a wrong setting time-reverses the retrieved pulse and flips - the sign of every phase order. The error is invisible on a symmetric - transform-limited pulse, so check the setting once against a pulse of known - chirp (for example after adding a known glass thickness). An **SHG** trace - is symmetric in delay even for a chirped pulse, so reversing it changes - nothing and cannot settle SHG's own - [direction-of-time ambiguity](../explanation/pnps_framework.md#ambiguities) — - that is resolved after retrieval instead - ([SHG: settle the direction of time first](postprocessing.md#shg-settle-the-direction-of-time-first)). - It is the measured counterpart of the simulated loader's switch of the same - name. - Optionally enter the measured - **pulse energy** (J) here to rescale the temporal plots to absolute power (see - the Dispersion stage). The trace preview uses the same colour scheme as the - preprocessing stage (white→viridis for positive, white→red for negative), with - the display scaled to the in-trust-band signal so an aggressive efficiency tilt - cannot wash the image out. -5. **Marginal check** — compare the trace's frequency marginal with the marginal - **predicted** from the independent spectrum ({mod}`croak.marginal_checks`): - exact for SHG (the spectral autoconvolution) and transform-limited for - PG/TG/SD, within the thin local model; the [marginal guide](marginal_checks.md) - states its assumptions. It overlays the predicted marginal, marks the predicted vs measured **centroid**, - reports the **width-bracket** check, and exposes the spectral **trust region** - (defaults to 190–1000 nm for a measured trace, or the full loaded range for - the noise-free simulated/synthetic traces), a **frequency/wavelength** axis - toggle (default frequency, the space the retrieval works in; the wavelength view - applies the $1/\lambda^2$ density Jacobian, which shifts a broad marginal towards - the blue), a **log/linear** - marginal toggle (default linear, - peak-normalised) and the **3rd-order** `(λ/µm)^exp` efficiency correction. Its - **Auto** button picks the exponent whose corrected-marginal centroid matches the - prediction ({func}`~croak.marginal_checks.auto_third_order_exponent`). The - **SHG marginal correction** is available here: it - scales the trace's frequency rows onto the predicted SHG autoconvolution - ({func}`~croak.marginal_checks.apply_marginal_correction`), is enabled only for - an SHG trace with an independent spectrum, and previews the snap onto the - prediction live (the correction itself is applied during Preprocess). All three - entry paths (experimental, simulated, synthetic) pass through this stage; see - [Marginal consistency checks](marginal_checks.md). The trace and the marginal - comparison share a draggable divider, so either plot can be given the height. -6. **Preprocess** — fringe/DC/low-pass filtering, windowing and regridding - ({func}`~croak.preprocess.load_and_clean`), with a live before/after preview. - The controls always show the settings that were actually applied: re-entering - the stage re-seeds every widget from the current parameters, and those - parameters survive a round trip back to the loader as long as the trace is - unchanged (see [Preprocessing](preprocessing.md#settings-across-a-re-load)). - With **Baseline (arPLS)** on, the **Auto** button beside *τ exclude (fs)* sets the - hold-out to twice the loaded trace's delay-marginal FWHM. Use it on measured - traces: without a hold-out the baseline bows into the τ ≈ 0 peak and removes - signal, worst on the rows that constrain the fit least. The failure shows up - as a thin stripe at one wavelength in the cleaned trace (see - [Peak suck-up](preprocessing.md#peak-suck-up-and-the-hold-out)). -7. **Retrieve** — choose the algorithm (any of the ten, including the global - [`cma-es`](../explanation/algorithms.md#global-retrieval-cma-es)) and its - parameters and run. Retrieval runs in a background thread, driven by the - solver `callback`, so the interface stays responsive. The result is shown on three - **pages** of panels, as tabs: **Traces** (measured and retrieved trace, log and - linear, with their colour bars), **Pulse** (retrieved pulse over the delay marginal, spectrum over the - frequency marginal) and **Diagnostics** (convergence, residuals, spectral - filter, spectrogram) — the same twelve panels as - {func}`~croak.plotting.plot_retrieval`, two by two so each has room even on a - laptop display. **Pop out** (top right of the tabs) opens the current page in - its own window, which follows later results; each tab's toolbar **Save** writes - that page, and the stage's **Save…** writes the full twelve-panel overview as - `retrieval.pdf` beside `result.h5` and `options.toml`. The **Display** group's - **Spectrum axis** draws the spectrum panel against **frequency** (PHz, the - default — the space the retrieval works in) or **wavelength** (nm, the - spectrometer's axis, carrying the $1/\lambda^2$ density Jacobian); both are unit-peak densities - in their own variable, the choice is remembered, and the exported PDF follows - it. - - By default **Live full-plot preview** redraws the visible page as the run - converges (other pages redraw when selected). Previews arrive at most four - times a second, and less often on a machine where presenting one is slow: the - wizard measures what each preview costs and stretches the interval so that - presenting stays below about a quarter of the wall-clock time, so the preview - never starves the solver. Until the first preview arrives, the convergence - curve is shown on the visible page, redrawn at up to ten frames a second - however fast the solver iterates. A page is laid out once per run and later - snapshots update its existing curves, images and labels, so nothing shifts - between frames; the spectrogram, the costliest panel, is computed only when - the Diagnostics page is drawn. - Pressing **Stop** halts the run at that point and leaves the latest full plot - on screen as a (non-converged) result you can inspect, save or post-filter. - Untick **Live full-plot preview** to fall back to the cheaper - trace-error-vs-iteration curve (the full plot is still drawn once the run - finishes). Every solver supports the preview, including `copra`/`copra-jax` - and the COPRA warm-up of `warm-lbfgs`, which is 300 iterations by default. - `warm-lbfgs` plots **both** stages on one convergence curve - with a dashed rule at the handover; read the two sides as different units of - work (COPRA iterations, then L-BFGS function evaluations). The **Initial - guess** selector makes automatic, transform-limited, random, perfect-Gaussian, - known **truth**, and reuse-previous starts mutually exclusive. Truth uses the - complex field stored by ModelPNPS (and reports an explicit error for an older - intensity-only file). **Reuse previous result** warm-starts one run from the - last — handy for the global-then-local refine recipe. It seeds the previous **spectrum** as the - initial guess *and* the extras that run fitted (thickness, τ₀, smearing width) - as the starting values for fitting them again, naming them in the status line; - an extra you are no longer fitting keeps the value set in the controls. When - the run finishes, the status line reports the fitted extras alongside the - error and FWHMs — including the geometric-smearing width as a delay σ in fs - with its multiplier on the nominal mask geometry (which is left untouched). When the trace came from the synthetic or - simulated workflow, the **known-truth** temporal intensity is drawn as a thin - black line beneath the retrieved and transform-limited pulses. When its complex - field is available, dotted truth-phase lines appear in both time and wavelength - panels and are named in their legends. The truth arrays are also written into - the saved `result.h5`. - - Beneath the pages sits a **numeric read-out** that updates as the run - converges; click its header to fold it away and give the plots its height - (remembered between launches). Its *Pulse parameters* table has one row per - curve in the figure, - labelled to match the legends — `R` (retrieved), `M` (the independent measured - spectrum), `TL` (transform-limited) and `truth` — and four columns: peak - wavelength, spectral FWHM, temporal FWHM and peak power. Cells that are not - defined show an en dash rather than a misleading number: the measured spectrum - carries no phase, so it has no temporal envelope; the transform-limited pulse is - the retrieved $|E(\omega)|$ with the phase removed, so its spectrum *is* the - retrieved one and is reported once, on the `R` row. The peak-power column needs - a measured **pulse energy** (set it on the Load or Dispersion stage) and shares - one SI prefix, shown in its header, so the values compare at a glance. - - Below the table, `Iteration`, `Elapsed` and `Error R` track the run itself, and - `τ₀`, `Thickness` and `Smearing` show the forward model's extra parameters, each - marked **(fit)** or **(fixed)** so it is clear whether the retriever was free to - move it. The three run counters tick every iteration; the table refreshes with - the live full-plot preview, so with that preview turned off it stays blank until - the run finishes. - - The last line, `ε(Iₜ)`, `ε(I𝜔)` and `ε(E𝜔)`, appears when the trace came from - the synthetic or simulated workflow and so has a **known** pulse: the temporal - intensity, spectral intensity and complex-field errors against it - ({mod}`croak.truth_metrics`). They answer a different question from the - `Error R` above them — that scores the fit to the *trace*, these score the - distance to the true *pulse*, and a FROG trace does not determine the pulse - uniquely, so a small `R` can sit on a wrong answer. Read them together: a small - `ε(I𝜔)` with a large `ε(E𝜔)` means the amplitude is right and the phase is not. - The two spectral ones need the truth's complex field, so an intensity-only file - shows `ε(Iₜ)` alone rather than a number derived from something else. - - Forward-model groups sit alongside the solver options. *Dispersive - propagation* models a slab (material, thickness, Δz/Npoints — see - [The forward model](../explanation/forward_model.md)). *Geometrical smearing* - models the tilted-pulse-front temporal blur of a non-collinear BOXCARS setup - from the mask hole diameter and edge-to-edge spacing, with the resulting delay - width shown — and editable, which back-computes the spacing. Both groups grey - out for solvers that cannot use them; smearing needs one of the autodiff - solvers. The chromatic focal and collection settings are described in - [collection aperture modelling](collection_aperture.md). See also - [geometric smearing](geometric_smearing.md) for the reduced model. -8. **Dispersion** — apply and auto-tune residual dispersion - ({mod}`croak.dispersion`) to compress the retrieved pulse, including real glass - (built-in or [custom](../howto/materials_and_mirrors.md) materials) and a **mirror stack** — add rows of `(mirror, polarisation, bounces, - remove/add)` to back-propagate (remove) beam-path coating mirrors or add - chirped-mirror bounces — with the temporal profile and spectrogram updating as you - drag the sliders. Enter a **pulse energy** (J) (shared with the Load stage) to - switch the temporal plot to absolute power and read off the current and - transform-limited **peak power**; compressing the pulse raises the peak power - towards the transform-limited maximum at fixed energy. -9. **Uncertainty** — put an error bar on the retrieved FWHM - ({mod}`croak.uncertainty`). Choose the estimator (parametric or resampling - bootstrap, the substrate-thickness systematic, or the fast analytic - covariance), set the replicate count and confidence level, and run it on a - worker thread with a progress bar. The stage reports the point estimate with - its 68 % and 95 % intervals, draws the temporal confidence band over the - retrieved profile, and saves everything alongside the result. See - [Estimating FWHM uncertainty](uncertainty.md). +### Data + +Data holds the loader for the session's kind of data. **Continue** loads (or +generates) the trace and moves on to [Check](#check). Check opens as soon as a +trace is loaded, so a measured trace loaded with **Load & Preview** can be +checked from the top bar straight away. + +#### Load the trace (measured) + +Pick an HDF5/NPZ file and the trace/wavelength/delay datasets, with unit and axis +auto-detection ({mod}`croak.io`), then press **Load & Preview**; Continue is +available once the trace has loaded. An optional independent spectrum can come +from HDF5, NPZ or CSV, with background subtraction, and wavelength calibration +curves from CSV. **Transpose** fixes a trace that loaded as (delay × +wavelength), and **Reverse delay axis** flips the delay-sign convention — +negating the scan axis, so the trace is mirrored about τ = 0. Which end of a +stage scan is "gate late" is a wiring convention the file does not record. It +matters for **PG and SD**, whose kernels fix the direction of time: a wrong +setting time-reverses the retrieved pulse and flips the sign of every phase +order. The error is invisible on a symmetric transform-limited pulse, so check +the setting once against a pulse of known chirp (for example after adding a +known glass thickness). An **SHG** trace is symmetric in delay even for a +chirped pulse, so reversing it changes nothing and cannot settle SHG's own +[direction-of-time ambiguity](../explanation/pnps_framework.md#ambiguities) — +that is resolved after retrieval instead +([SHG: settle the direction of time first](postprocessing.md#shg-settle-the-direction-of-time-first)). +It is the measured counterpart of the simulated loader's switch of the same +name. + +Optionally enter the measured **pulse energy** (J) here to rescale the temporal +plots to absolute power (see the Dispersion page under [Analyse](#analyse)). +The trace preview uses the same colour scheme as Prepare (white→viridis for +positive, white→red for negative), with the display scaled to the in-trust-band +signal so an aggressive efficiency tilt cannot wash the image out. + +#### Load the scan (simulated) + +Load a numerically simulated `scansave` scan and preview it live. A +**thickness** dropdown selects which saved propagation depth to retrieve from a +multi-thickness scan (disabled for older single-thickness files). Continue loads +the scan and moves to Check; with **Skip filtering & regrid (raw → +retrieval)** ticked it builds the as-simulated trace on its native grid and goes +straight to Retrieve. See +[Loading numerically simulated traces](loading_simulated.md). + +#### Generate the trace (synthetic) + +Build a test trace (pulse shape, interaction, delays, optional dispersive slab +and noise) using {func}`~croak.forward.maketrace`, previewed live. Useful for +trying the workflow without data. The **duration** is the intensity (power) FWHM +of the temporal envelope — both Gaussian and sech² honour this convention +({func}`~croak.pulses.gaussian_pulse`, {func}`~croak.pulses.sech_pulse`). The +delay and wavelength samplings each expose an interlocked **count** and +**step** at fixed range (editing one updates the other), and a **Spectrum +(independent)** section generates a fundamental spectrum that is passed +downstream (range / step / # points); by default it **mirrors the FROG-trace λ +window**, scaled to the fundamental (×2 for SHG). With it present, the +marginal-check overlay and the SHG marginal correction work on synthetic traces +too. Continue generates the trace and moves to Check. The known +pulse (its temporal intensity envelope `It`) is carried through, shown beneath +the retrieved pulse, and saved with the result. + +### Check + +Compare the trace's frequency marginal with the marginal **predicted** from the +independent spectrum ({mod}`croak.marginal_checks`): exact for SHG (the spectral +autoconvolution) and transform-limited for PG/TG/SD, within the thin local +model; the [marginal guide](marginal_checks.md) states its assumptions. The page +overlays the predicted marginal, marks the predicted vs measured **centroid**, +reports the **width-bracket** check, and exposes the spectral **trust region** +(defaults to 190–1000 nm for a measured trace, or the full loaded range for the +noise-free simulated/synthetic traces), a **log/linear** marginal toggle (default +linear, peak-normalised) +and the **3rd-order** `(λ/µm)^exp` efficiency correction. Its **Auto** button +picks the exponent whose corrected-marginal centroid matches the prediction +({func}`~croak.marginal_checks.auto_third_order_exponent`). The **SHG marginal +correction** is available here: it scales the trace's frequency rows onto the +predicted SHG autoconvolution +({func}`~croak.marginal_checks.apply_marginal_correction`), is enabled only for +an SHG trace with an independent spectrum, and previews the snap onto the +prediction live (the correction itself is applied in Prepare). All three kinds +of session pass through this stage, except a raw simulated scan; see +[Marginal consistency checks](marginal_checks.md). + +The marginals are drawn against frequency, the space the retrieval works in, +unless the [display](#display-spectral-axis-and-log-range) asks for wavelength. +The wavelength view applies the $1/\lambda^2$ density Jacobian to the predicted +marginal, which shifts a broad marginal towards the blue even when its +frequency centroid matches. + +### Prepare + +Fringe/DC/low-pass filtering, windowing and regridding +({func}`~croak.preprocess.load_and_clean`), recomputed automatically as you +change a setting. The plots show six maps in two rows of three — the measured, +filtered and regridded trace, in dB above and linear below — with linked axes: +all six share the delay axis, and the measured and filtered maps share the +wavelength axis, so a zoom on one follows on the others. Above the maps, five +numbers say what the grid gives: its size (delay × frequency samples), its time +step **Δt** (the shortest feature it can show), its **time window** (the longest +pulse it can hold), its **λ span**, and the **taper loss**, the share of the +trace the grid's edge taper removes, in amber above 1 %, when an advice box +also says to widen the λ range. + +The controls always show the settings that were actually applied: re-entering +the stage re-seeds every widget from the current parameters, and those +parameters survive a round trip back to the loader as long as the trace is +unchanged (see [Preprocessing](preprocessing.md#settings-across-a-re-load)). +**Reset defaults** restores the data-seeded defaults. With **Baseline (arPLS)** +on, the **Auto** button beside *τ exclude (fs)* sets the hold-out to twice the +loaded trace's delay-marginal FWHM. Use it on measured traces: without a +hold-out the baseline bows into the τ ≈ 0 peak and removes signal, worst on the +rows that constrain the fit least. The failure shows up as a thin stripe at one +wavelength in the cleaned trace (see +[Peak suck-up](preprocessing.md#peak-suck-up-and-the-hold-out)). + +### Retrieve + +Choose the algorithm (any of the ten, including the global +[`cma-es`](../explanation/algorithms.md#global-retrieval-cma-es)) and its +parameters and run it with **Retrieve** in the top bar or **Run** on the form. +Retrieval runs in a background thread, driven by the solver `callback`, so the +interface stays responsive. The plots show a live overview: the measured and +retrieved traces (log) above the retrieved pulse and its spectrum. The full pages +of panels are on [Analyse](#analyse). + +The form's sections, in order, are the solver and its mode, the forward-model +sections (*Dispersive propagation*, *Geometrical smearing*), the advanced +sections and *Post-filter (applied to result)*. The advanced sections — +*Chromatic focal mixture (advanced)*, *Extra fit parameters*, *Regularisation* +and *Global search (cma-es)* — start folded, and whether each is open is +remembered between launches. Below them are **Reset** (the default retrieval +settings) and **Retarget to new dataset…** (see [Sessions](#sessions)). A run +starts and stops from the top bar's **Retrieve** and **Stop**, and **Save +session…** saves it. + +By default **Live full-plot preview** redraws the overview as the run converges. +Previews arrive at most four times a second, and less often on a machine where +presenting one is slow: the GUI measures what each preview costs and stretches +the interval so that presenting stays below about a quarter of the wall-clock +time, so the preview never starves the solver. The convergence curve beside the +numbers follows the run, redrawn at up to ten frames a second however fast the +solver iterates. Later snapshots update the existing curves and +images in place, so nothing shifts between frames. Pressing **Stop** halts the +run at that point and leaves the latest result on screen as a (non-converged) +result you can inspect, save or post-filter. Untick **Live full-plot preview** to +leave the overview until the run finishes, with only the convergence curve +moving. Every solver supports the preview, including +`copra`/`copra-jax` and the COPRA warm-up of `warm-lbfgs`, which is 300 +iterations by default. `warm-lbfgs` runs **both** stages on one convergence +curve; the convergence panel on Analyse's Diagnostics page marks the handover +with a dashed rule. Read the two sides as different units of work (COPRA +iterations, then L-BFGS function evaluations). + +The **Initial guess** selector makes automatic, transform-limited, random, +perfect-Gaussian, known **truth**, and reuse-previous starts mutually exclusive. +Truth uses the complex field stored by ModelPNPS (and reports an explicit error +for an older intensity-only file). **Reuse previous result** warm-starts one run +from the last — handy for the global-then-local refine recipe. It seeds the +previous **spectrum** as the initial guess *and* the extras that run fitted +(thickness, τ₀, smearing width) as the starting values for fitting them again, +naming them in a notice at the foot of the window; an extra you are no longer +fitting keeps the value set in the controls. The numbers beneath the plots show +the fitted extras — including the geometric-smearing width as a delay σ in fs +with its multiplier on the nominal mask geometry (which is left untouched). When the trace came from a synthetic or simulated session, the +**known-truth** temporal intensity is drawn as a thin black line beneath the +retrieved and transform-limited pulses. When its complex field is available, +dotted truth-phase lines appear in both time and spectrum panels and are named +in their legends. The truth arrays are also written into the saved `result.h5`. + +Beneath the plots, the run's **numbers** sit beside its **convergence**, the +error against iteration on a log scale (the whole history once the run ends, +with the hand-overs between a solver's stages marked). The first row tracks the +run and its pulse: `Iteration`, `Error R` and `Elapsed`, then the retrieved +spectrum's peak wavelength `λ peak` and the pulse's `FWHM`. The second row shows +the forward model's extra parameters, `τ₀`, `Thickness` and `Smearing`, each +named **fit** or **fixed** so it is clear whether the retriever was free to move +it (a smearing width also carries its factor on the nominal kernel). The run +counters tick every iteration; the peak wavelength and FWHM refresh with the live +full-plot preview, so with that preview turned off they stay blank until the run +finishes. A number that is not defined shows an en dash. The transform-limited +and known FWHMs, and the peak powers once a pulse energy is set, are in the +retrieved pulse's legend. + +The last three, `ε(Iₜ)`, `ε(I𝜔)` and `ε(E𝜔)`, are filled when the trace came from a +synthetic or simulated session and so has a **known** pulse: the temporal +intensity, spectral intensity and complex-field errors against it +({mod}`croak.truth_metrics`). They answer a different question from the +`Error R` above them — that scores the fit to the *trace*, these score the +distance to the true *pulse*, and a FROG trace does not determine the pulse +uniquely, so a small `R` can sit on a wrong answer. Read them together: a small +`ε(I𝜔)` with a large `ε(E𝜔)` means the amplitude is right and the phase is not. +The two spectral ones need the truth's complex field, so an intensity-only file +shows `ε(Iₜ)` alone rather than a number derived from something else. + +*Dispersive propagation* models a slab (material, thickness, Δz/Npoints — see +[The forward model](../explanation/forward_model.md)). *Geometrical smearing* +models the tilted-pulse-front temporal blur of a non-collinear BOXCARS setup +from the mask hole diameter and edge-to-edge spacing, with the resulting delay +width shown — and editable, which back-computes the spacing. Both sections grey +out for solvers that cannot use them; smearing needs one of the autodiff +solvers. The chromatic focal and collection settings are described in +[collection aperture modelling](collection_aperture.md). See also +[geometric smearing](geometric_smearing.md) for the reduced model. + +### Analyse + +Analyse reads the retrieved result. A rail on the left lists its pages; click +one, or step through them with the Up and Down arrow keys: + +- **Overview** — the live overview from Retrieve, without the form beside it; +- **Traces** — the measured and retrieved trace, log and linear; +- **Pulse** — the retrieved pulse over the delay marginal, and the spectrum + over the frequency marginal; +- **Diagnostics** — convergence, residuals, spectral filter and spectrogram; +- **Dispersion** and **Uncertainty**, described below. + +Overview, Traces, Pulse and Diagnostics are the panels of +{func}`~croak.plotting.plot_retrieval`, two by two so that each has room even on +a laptop display. A page is redrawn when it is shown, so the spectrogram, the +costliest panel, is computed only when you open Diagnostics. Each has +**Export ▾** with **Figure…** and **Pop out** (see [Plots](#plots)). The primary +action on these pages, and on Dispersion, is **Save**: it saves the session. + +#### Dispersion + +Apply and auto-tune residual dispersion ({mod}`croak.dispersion`) to compress +the retrieved pulse, including real glass (built-in or +[custom](../howto/materials_and_mirrors.md) materials), gases and a **mirror +stack** — add rows of `(mirror, polarisation, bounces, remove/add)` to +back-propagate (remove) beam-path coating mirrors or add chirped-mirror bounces — +with the temporal profile and spectrogram updating as you drag the sliders. +Enter a **pulse energy** (J) (shared with the measured loader) to switch the +temporal plot to absolute power and read off the current and transform-limited +**peak power**; compressing the pulse raises the peak power towards the +transform-limited maximum at fixed energy. When a dispersion is set, **Save +session…** also writes the dispersed result as `result_dispersion.h5`, with +`dispersion.pdf` (see [Sessions](#sessions)). + +#### Uncertainty + +Put an error bar on the retrieved FWHM ({mod}`croak.uncertainty`). Choose the +estimator (parametric or resampling bootstrap, the substrate-thickness +systematic, or the fast analytic covariance), set the replicate count and +confidence level, and run it with **Estimate** in the top bar. It runs on a +worker thread; the ready line counts the replicates, and **Stop** in the top bar +cancels the run. The page reports the +point estimate with its 68 % and 95 % intervals, draws the temporal confidence +band over the retrieved profile. Each estimate is kept in a list; **Combine +ticked** adds the ticked independent ones in quadrature. **Save session…** +appends every estimate to `result.h5` and writes the figure on screen as +`uncertainty.pdf`. See +[Estimating FWHM uncertainty](uncertainty.md). ## Sessions -The GUI persists its settings — preprocessing choices, algorithm and options — to -a TOML file via {func}`croak.save.save_options` / {func}`croak.save.load_options`, -so you can reload a prior session. Retrievals are saved to HDF5 with -{func}`croak.save.save_result`. **Load previous retrieval** reloads the saved -`options.toml`; if a `result.h5` sits beside it, the retrieved result is -**restored from the file** ({func}`croak.save.result_from_saved`) after the cheap -load + preprocess steps replay — so the solver is **not** re-run (it falls back to -re-running only if the file is missing or its grid no longer matches). See -[Saving and loading](saving_and_loading.md). +A session is the settings of every stage — the loader, preparation, retrieval, +dispersion and uncertainty choices — kept as a TOML file +({func}`croak.save.save_options` / {func}`croak.save.load_options`), with the +retrieval in HDF5 ({func}`croak.save.save_result`). + +**Save session…** (⋯ menu, File menu, or the primary action on Analyse) asks for +a folder and writes into it: `result.h5` (the retrieval, with any +uncertainty estimates appended), `options.toml` (the settings) and +`retrieval.pdf` (the twelve-panel overview, drawn by matplotlib). It also writes +what Analyse holds: with a dispersion set on the Dispersion page, +`result_dispersion.h5` (the dispersed pulse, with its peak power when a pulse +energy is set) and `dispersion.pdf`; with an uncertainty estimate on screen, +`uncertainty.pdf`. It is the one way to save: no page has a Save button of its +own. The session is +added to Recent sessions and its folder's name appears in the top bar. Saving +needs a retrieval: before one, the GUI says so. + +**Open a session…** (Home, ⋯ menu or File menu) or a click on a recent session +reopens a saved `options.toml` and replays it: + +1. The trace is reloaded with the saved loader settings and prepared with the + saved preparation settings. +2. If a `result.h5` sits beside the `options.toml`, its retrieval is + **restored from the file** ({func}`croak.save.result_from_saved`) without + re-running the solver — but only when it was fitted to exactly the trace the + replay produced. The saved measured trace and frequency axis are compared + element by element ({func}`croak.session.files.result_mismatch`). +3. Otherwise the retrieval is re-run, and a notice at the foot of the window says why + (no saved result, an unreadable file, or a trace that differs). + +A synthetic session cannot be replayed this way: the generator's settings are not +part of the saved options, so the GUI loads the settings and asks you to +regenerate the trace. If the trace cannot be reloaded (a file has moved, say), +the loader says why. See [Saving and loading](saving_and_loading.md). + +**Retarget to new dataset…** on the Retrieve form, available once a retrieval +has run, points a measured session at another dataset folder with the same file names, keeps the calibration files, and +replays load → prepare → retrieve with the current settings +({func}`croak.session.retarget.retarget`). Only a measured session has such a +folder; for the others the status bar gives the reason instead. + +**Show the settings (options.toml)** and **Show as a Python script** open a +drawer on the right of the window with the session's settings as the +`options.toml` it would save, or as a standalone script that replays it without +the GUI ({func}`croak.scripting.generate_script`). **Copy** puts the text on the +clipboard; **Close** hides the drawer. ## Scripting the same workflow -Everything in the wizard maps to a few library calls — load → clean → retrieve → +A saved session runs without the GUI: `croak replay options.toml` replays it +headlessly, and `croak script options.toml` writes the same Python script that +**Show as a Python script** shows (see +[saving, sessions, and reproducible workflows](../user_guide/workflows.md)). + +Everything in the GUI maps to a few library calls — load → clean → retrieve → process → plot → save: ```python diff --git a/docs/howto/loading_simulated.md b/docs/howto/loading_simulated.md index 2710bfa..33cc8b1 100644 --- a/docs/howto/loading_simulated.md +++ b/docs/howto/loading_simulated.md @@ -196,7 +196,7 @@ retrieved spectrum blue-shifted. Three alternatives avoid the guesswork: If you trust the measured gating spectrum, **Rω with a light `reg_spectrum`**, or **phase-only** retrieval, removes the exponent; a single exponent (the -marginal-check stage's **Auto** picks it from your spectrum) is the quick +marginal check's **Auto** picks it from your spectrum) is the quick approximation. None of these corrections removes delay-dependent collection effects, which need the [collection model](collection_aperture.md). @@ -314,8 +314,8 @@ The options live on {class}`~croak.session.params.SimulatedLoadParams`: ```{note} **What "load λ band" does.** `lam_min_nm`/`lam_max_nm` crop the *loaded* trace to bins with λ in that range (and always drop the unphysical ω ≤ 0 bins). It sets the -**extent of the data** handed to the marginal-check and preprocess stages — it is -*not* the retrieval grid (that is the Preprocess stage's own λ range). The GUI +**extent of the data** handed to the marginal check and Prepare — it is +*not* the retrieval grid (that is Prepare's own λ range). The GUI defaults it to the file's **full** positive-frequency span; narrow it only to crop noisy or empty wings. ``` @@ -324,7 +324,7 @@ noisy or empty wings. TG-FROG keeps the carrier (`omega0_scale = 1`), so the trace and the pulse sit at the same wavelengths and `"pg"`/`"sd"` are the only sensible interactions — never `"shg"`. The default `third_order_exp = 2.0` is the radiated-field $\omega^2$ -factor; the marginal-check stage's **Auto** refines it against your spectrum (the +factor; the marginal check's **Auto** refines it against your spectrum (the `Iω_win` + beamlet combination usually wants a little more, the `"source"` spectrum more again). It is only an approximation — see the correction options above (Rω / phase-only / calibration). @@ -361,7 +361,7 @@ data = assemble_simulated_load_data( ) ) -# stage 2 onward is identical to a measurement: filter / window / regrid … +# from here on it is identical to a measurement: filter / window / regrid … td = build_tracedata( PreprocParams(lam_min_nm=200, lam_max_nm=340, lamm_min_nm=210, lamm_max_nm=330), data, @@ -426,21 +426,23 @@ delay resampling is needed. ## From the GUI -The welcome menu's **Load simulated trace** entry opens a loader page: choose the -`.h5` file, the trace window, the **thickness**, the corrections, the -**reference spectrum** and the **time-domain truth** source, then preview the trace -live and press the footer's **Load & continue →** button (bottom right, like every -other page) to hand it straight to the **Preprocess** stage. The +The **Simulated scan** tile on Home starts a session on the loader, the first +step of the Data stage: choose the `.h5` file, the trace window, the +**thickness**, the corrections, the **reference spectrum** and the **time-domain +truth** source, then preview the trace live and press **Continue** (the primary +action, at the right of the top bar) to load it and move to the marginal check, +and from there to **Prepare**. The **time-domain truth** dropdown lists what the file stores (the beamlet first, so `It_beamlet` is the default) and is disabled when the file offers only one. For a multi-thickness file the **thickness** dropdown lists every saved depth (largest/exit first, the default); changing it re-previews the trace at that depth. For an older single-thickness file the dropdown -is disabled (only the exit slice exists). Every control has a hover tooltip, and the **? Help** button -summarises the options (including the exponent/reference guidance above). From the -filter stage on, the workflow is exactly as for a measured trace — so you can apply -croak's fringe/DC/threshold filters to the modelled data too. **Back** from -preprocess returns to the loader page to adjust the options. +is disabled (only the exit slice exists). Every control has a hover tooltip, and +**Help for this stage** (⋯ menu or F1) summarises the options (including the +exponent/reference guidance above). From Prepare on, the workflow is exactly as +for a measured trace — so you can apply croak's fringe/DC/threshold filters to the +modelled data too. The **Data** button in the top bar returns to the loader to +adjust the options. The preview is shown in the **file-native representation**: the simulated trace and its spectral marginal are an **angular-frequency density** plotted against @@ -450,12 +452,12 @@ stays in fs. The **load λ band** seeds to the file's full range (see the note above). Tick **Skip filtering & regrid (raw → retrieval)** to load the as-simulated trace -on its native grid **straight into the Retrieve stage**, bypassing the -marginal-check and preprocess stages (see -[above](#raw-direct-to-retrieval)); the footer's forward button relabels to -**Load raw → Retrieve →** to say so. The known-truth overlay is still shown, and -**Back** from Retrieve reaches Preprocess so you can run the regrid path and -compare. +on its native grid **straight into the Retrieve stage** when you press Continue, +bypassing the marginal check and Prepare (see +[above](#raw-direct-to-retrieval)); a scan loaded this way leaves Prepare +disabled, and its tooltip says why. The known-truth overlay is still shown. To +compare with the regrid path, untick the option on the loader and Continue +again. The Rω / phase-only alternatives to the third-order exponent live on the **Retrieve** stage (the *Rω adaptive scaling* and *phase-only* toggles); turn the @@ -463,18 +465,19 @@ loader's third-order correction off and let Rω absorb the response there instea ## Saving and reloading a simulated session -Saving from the Retrieve stage writes the loader's options into the session file +Saving the session writes the loader's options into the session file as a `[simulated]` table, alongside `entry = "simulated"` (see [Saving and loading](saving_and_loading.md)) — the scan file, trace window, thickness slice, loaded band, third-order exponent, delay-sign convention and the spectrum/truth choices. So the retrieval records *which* simulated trace it came from and how that trace was built. -**Load previous retrieval** reads `entry` and reloads through this loader rather +**Open a session…** reads `entry` and reloads through this loader rather than the measured one: it re-reads the scan with the saved options (including the raw *skip filtering & regrid* path, which rebuilds the native-grid trace directly), restores the loader page's controls, replays the saved preprocessing -and rehydrates the sibling `result.h5` without re-running the solver. The same +and rehydrates the sibling `result.h5` without re-running the solver, when it +was fitted to the trace the replay produced. The same file replays headlessly: ```bash @@ -486,6 +489,6 @@ uv run croak replay options.toml another *per-dataset folder*, a convention of measured acquisitions (trace, spectrum and backgrounds sharing a folder with stable filenames). A simulated session is one scansave file with no such siblings, so retargeting it raises — and -the Retrieve stage's **Retarget…** button reports that instead of silently +the Retrieve stage's **Retarget to new dataset…** button reports that instead of silently replaying the same scan. ``` diff --git a/docs/howto/marginal_checks.md b/docs/howto/marginal_checks.md index 8a428b2..3cf4e95 100644 --- a/docs/howto/marginal_checks.md +++ b/docs/howto/marginal_checks.md @@ -97,9 +97,9 @@ collection effects. ## In the GUI -All of the above is interactive on the wizard's **Marginal check** stage, which -sits between the data loaders and preprocessing for the experimental, simulated -and synthetic entry paths. It overlays the predicted marginal on the measured +All of the above is interactive on the GUI's **Check the marginal** step, the +second step of the Data stage, which sits between the data loaders and Prepare +for measured, simulated and synthetic sessions. It overlays the predicted marginal on the measured one, marks the predicted vs measured centroid, reports the width-bracket check, and exposes the trust region, a log/linear toggle and the `(λ/µm)^exp` correction with an `Auto` (centroid-matching) button. The **SHG marginal correction** also diff --git a/docs/howto/postprocessing.md b/docs/howto/postprocessing.md index f64918a..8a3f5b6 100644 --- a/docs/howto/postprocessing.md +++ b/docs/howto/postprocessing.md @@ -198,7 +198,7 @@ direction of time. Reversal is applied by conjugating the spectrum, which is exact: the stored trace, `mu` and `error` remain valid. Note that the **spectral phase changes sign**, so the retrieved GDD and TOD only become comparable with the truth once the direction -is settled. The wizard does this automatically for synthetic and simulated +is settled. The GUI does this automatically for synthetic and simulated sessions, where a truth is available. ### Framing the axes @@ -246,8 +246,8 @@ croak.plotting.draw_spectral(ax, data) # one panel, in your own figure `retrieved_log`, `measured_lin`, `retrieved_lin`, `temporal`, `spectral`, `convergence`, `residual`, `spectral_filter`, `freq_marginal`, `delay_marginal`, `spectrogram` — to their titles and draw functions; -{data}`~croak.plotting.RETRIEVAL_PAGES` holds the three 2×2 pages the wizard shows -as tabs (**Traces**, **Pulse**, **Diagnostics**), and +{data}`~croak.plotting.RETRIEVAL_PAGES` holds the three 2×2 pages the GUI shows +on Analyse (**Traces**, **Pulse**, **Diagnostics**), and {data}`~croak.plotting.RETRIEVAL_OVERVIEW` the 3×4 layout `plot_retrieval` draws. The spectrum panel is drawn against frequency by default; pass `spectral_axis="wavelength"` to `plot_retrieval` or `retrieval_plot_data` (or @@ -256,8 +256,8 @@ unit-peak densities in their own variable, so the wavelength view carries the $1 Jacobian and the frequency view does not. Colourbars shared by a pair of trace panels are drawn whenever both panels are on the page. Every `draw_*` returns the artists it created (for example -{class}`~croak.plotting.TemporalArtists`), which is what a live view updates in -place instead of redrawing. +{class}`~croak.plotting.TemporalArtists`), so you can restyle or annotate them +afterwards. Other composites and single-axis plotters: @@ -277,10 +277,14 @@ Other composites and single-axis plotters: The single-axis plotters take an existing Matplotlib axis, so you can compose your own figures. -### Live views (GUI support) +### Screen views (GUI support) -For custom interactive applications, see the [live-view contracts in the -developer’s guide](../developer_guide/gui_sessions.md#live-views-gui-support). +The GUI does not draw these matplotlib panels on screen: it draws the same data +with interactive views, from the Qt-free {mod}`croak.plotting.screen`, and +{mod}`croak.plotting.export` draws those views back into matplotlib for a +publication figure ({func}`~croak.plotting.export.views_figure`, +{func}`~croak.plotting.export.save_figure`). See +[screen views in the developer’s guide](../developer_guide/gui_sessions.md#screen-views-gui-support). ## Windowing the retrieved field diff --git a/docs/howto/preprocessing.md b/docs/howto/preprocessing.md index 4bebcb5..f000af4 100644 --- a/docs/howto/preprocessing.md +++ b/docs/howto/preprocessing.md @@ -291,7 +291,7 @@ fig = croak.plot_frog_filter(td) ## Settings across a re-load -In the [wizard](gui.md) the preprocess controls are bound one way: a widget +In the [GUI](gui.md) the Prepare controls are bound one way: a widget writes its parameter and triggers a recompute, so what the parameters say is what gets computed. To keep the two in step, **entering the stage re-seeds every widget from the current parameters**. That matters because loading a trace @@ -303,7 +303,7 @@ dataset or window selection, units, loaded band, orientation and interaction, that is, everything that changes the data-seeded defaults. * **Same trace** (reloading after changing a background, a calibration or the - third-order correction, or walking Back and forward again): your windowing + third-order correction, or going back to Data and forward again): your windowing and filter choices are kept; only the slider bounds are refreshed and the values clamped into them. * **Different trace**: the data-seeded defaults are restored, and since the @@ -341,7 +341,7 @@ against a known chirp. An SHG trace is symmetric in delay for any pulse settles nothing — SHG's direction-of-time ambiguity is resolved after retrieval with {func}`~croak.processing.resolve_time_direction`, see [Post-processing](postprocessing.md#shg-settle-the-direction-of-time-first). -The GUI's Load stage exposes both as the **Transpose** +The GUI's measured loader (on Data) exposes both as the **Transpose** and **Reverse delay axis** switches ({class}`~croak.session.params.LoadParams`); `croak.session` applies the flip in {func}`~croak.session.pipeline.assemble_load_data`, before the axis is sorted, so diff --git a/docs/howto/saving_and_loading.md b/docs/howto/saving_and_loading.md index 8c382d2..744fcfa 100644 --- a/docs/howto/saving_and_loading.md +++ b/docs/howto/saving_and_loading.md @@ -78,10 +78,11 @@ from croak.save import load_result, result_from_saved result = result_from_saved(load_result("retrieval.h5")) ``` -The GUI's **Load previous retrieval** uses exactly this: it replays the cheap +The GUI's **Open a session…** uses exactly this: it replays the cheap load + preprocess steps and then rehydrates the saved `result.h5` onto the matching -grid, only re-running the solver if the file is absent or the grid no longer -matches. +grid, only re-running the solver if the file is absent, cannot be read, or was not +fitted to exactly the trace the replay produced +({func}`croak.session.files.result_mismatch`). ## Session options → TOML @@ -118,7 +119,8 @@ entry = "simulated" [uncertainty] ``` -`entry` names the **stage-1 loader the trace came from** — `"experimental"`, +`entry` names the **loader the trace came from** (the first step of the GUI's +Data stage) — `"experimental"`, `"simulated"` or `"synthetic"` — and so which of `[load]` / `[simulated]` is the live one; the other is written at its defaults. Without it a simulated session would be indistinguishable from an experimental one with an empty file path, and diff --git a/docs/howto/uncertainty.md b/docs/howto/uncertainty.md index 825a089..3a3d445 100644 --- a/docs/howto/uncertainty.md +++ b/docs/howto/uncertainty.md @@ -209,7 +209,7 @@ print(u.summary()) # FWHM = … ± … fs at −1 mm SiO2 (…) The same `propagation=H` works for {func}`~croak.covariance.covariance_uncertainty` and the other estimators. The intervening dispersion is assumed exactly known. In the GUI, tick -*"at dispersion-stage point"* to reuse the Dispersion stage's settings as $H$. +*"at dispersion-stage point"* to reuse the Dispersion page's settings as $H$. ## Reading and plotting the result @@ -260,7 +260,8 @@ croak.save_uncertainty({"parametric": stat, "thickness": thickness, "combined": ``` In the GUI's **Uncertainty** stage each run is retained in a list; tick the independent -ones, click **Combine ticked**, then **Save…** to write them all into ``result.h5``. +ones, click **Combine ticked**, then **Save session…** (⋯ menu) to write them all into +``result.h5``, with the figure on screen as ``uncertainty.pdf``. ## Calibrating the interval (is it really 68 %?) @@ -282,10 +283,10 @@ retrievals — keep `n_trials`/`n_resamples` modest while exploring). ## In the GUI -The wizard's **Uncertainty** stage (after Dispersion) runs the bootstrap in the background +The GUI's **Uncertainty** page (under Analyse) runs the bootstrap in the background on the current retrieval, using the same solver settings, and plots the result. Pick the method, the replicate count and (for the parametric method) the noise source, then click -**Estimate**; **Stop** cancels mid-run. For a dispersive retrieval, choose method +**Estimate** in the top bar; **Stop** there cancels mid-run. For a dispersive retrieval, choose method **thickness** and set **Thickness σ (µm)** to your measured substrate-thickness uncertainty (the central value is the retrieval's own thickness) to get the systematic FWHM error bar. diff --git a/docs/introduction/interfaces.md b/docs/introduction/interfaces.md index ba41c6a..f910190 100644 --- a/docs/introduction/interfaces.md +++ b/docs/introduction/interfaces.md @@ -8,8 +8,9 @@ your own analysis. You can move between these approaches using saved sessions. ## Work in the GUI After [installing the GUI](../getting_started/gui.md), launch `croak-gui`. -The wizard guides you through loading data, preparing a trace, retrieving a -pulse, and analysing the result. The [GUI guide](../howto/gui.md) describes the +Its window takes you through loading data, preparing a trace, retrieving a +pulse, and analysing the result, and can show the session's settings as a Python +script. The [GUI guide](../howto/gui.md) describes the controls and stages; [preparing and checking data](../user_guide/data.md) explains the choices behind them. diff --git a/docs/reference/api/plotting.md b/docs/reference/api/plotting.md index 7337563..b1fe435 100644 --- a/docs/reference/api/plotting.md +++ b/docs/reference/api/plotting.md @@ -8,3 +8,17 @@ and 4-panel composites. See [Post-processing and plotting](../../howto/postproce .. automodule:: croak.plotting :members: ``` + +## Figures of the GUI's views + +```{eval-rst} +.. automodule:: croak.plotting.export + :members: +``` + +## The display's spectral axis and range + +```{eval-rst} +.. automodule:: croak.plotting.display + :members: +``` diff --git a/docs/reference/api/workflow.md b/docs/reference/api/workflow.md index e5859b6..4af64e7 100644 --- a/docs/reference/api/workflow.md +++ b/docs/reference/api/workflow.md @@ -110,9 +110,9 @@ different beamline point. The background and caveats are in A whole retrieval — load, preprocess, retrieve, dispersion compensation and uncertainty — captured as serialisable per-stage parameters and replayed without the GUI. {func}`~croak.session.engine.run_session` drives the same stage mappings the -wizard uses; {func}`~croak.session.retarget.retarget` re-points an *experimental* session at +GUI uses; {func}`~croak.session.retarget.retarget` re-points an *experimental* session at a new dataset folder. {class}`~croak.session.options.SessionOptions` also records -which stage-1 loader the trace came from (`entry`, see +which loader the trace came from (`entry`, see {data}`~croak.session.options.ENTRIES`), so a simulated session replays through {func}`~croak.session.pipeline.assemble_simulated_load_data` rather than the measured loader — see [Saving and loading](../../howto/saving_and_loading.md). diff --git a/docs/reference/features.md b/docs/reference/features.md index 29bc2c3..b7d7d42 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -234,18 +234,20 @@ Further reading: [Estimating FWHM uncertainty](../howto/uncertainty.md), ## Interfaces - **Library first** — every stage is callable without the GUI. -- **The wizard** — a six-stage PyQt6 application (Load → Marginal check → - Preprocess → Retrieve → Dispersion → Uncertainty) with live retrieval previews and a numeric - read-out of retrieved, measured, transform-limited and (for synthetic - sessions) true pulse parameters. Laid out for 1366×768 displays and up. - Importing `croak` does not import Qt. +- **The GUI** — a PySide6 window that takes a session from Home through Data + (load and marginal check) → Prepare → Retrieve → Analyse (the retrieval's + panels, Dispersion and Uncertainty), with interactive pyqtgraph plots, live + retrieval previews and a numeric read-out of retrieved, measured, + transform-limited and (for synthetic sessions) true pulse parameters. It shows + a session's settings as `options.toml` or as a Python script on request. Laid + out for 1366×768 displays and up. Importing `croak` does not import Qt. - **Sessions** — the session engine replays a saved TOML session end to end through the same stage operations the GUI uses. - **CLI** — `croak replay` reruns a session headlessly, including batch retargeting across datasets; `croak script` generates a standalone, editable Python script from it. -Further reading: [The wizard GUI](../howto/gui.md), +Further reading: [Using the GUI](../howto/gui.md), [Saving and loading](../howto/saving_and_loading.md). ## Conventions diff --git a/docs/reference/naming.md b/docs/reference/naming.md index be62492..836ba27 100644 --- a/docs/reference/naming.md +++ b/docs/reference/naming.md @@ -35,7 +35,7 @@ through AD. ## The mascot -The mascot was not renamed. The wizard's icon is still a toad, which — given what +The mascot was not renamed. The GUI's icon is still a toad, which — given what toads are known for — remains approximately correct. ```{note} diff --git a/docs/tutorials/05_dispersion_tuning.md b/docs/tutorials/05_dispersion_tuning.md index 4d2f678..04da1e3 100644 --- a/docs/tutorials/05_dispersion_tuning.md +++ b/docs/tutorials/05_dispersion_tuning.md @@ -102,7 +102,8 @@ print(f"peak power = {pr.peak_power/1e6:.1f} MW") print(f"TL peak power = {pr.peak_power_tl/1e6:.1f} MW") # the maximum achievable ``` -In the GUI's Dispersion stage this is shown live as you tune the sliders. See +On the GUI's Dispersion page (under Analyse) this is shown live as you tune the +sliders. See [Post-processing](../howto/postprocessing.md#absolute-power-and-peak-power). ## Other knobs diff --git a/docs/user_guide/workflows.md b/docs/user_guide/workflows.md index 20da24e..dc3e293 100644 --- a/docs/user_guide/workflows.md +++ b/docs/user_guide/workflows.md @@ -3,14 +3,14 @@ A result is reproducible when both the retrieved field and the settings that produced it are kept. croak saves results to HDF5 and every setting of an analysis to a TOML session file, which can be replayed without the GUI or turned -into an editable Python script. This chapter covers the graphical wizard, which -writes those sessions, and the saving and loading functions behind it. +into an editable Python script. This chapter covers the graphical interface, +which writes those sessions, and the saving and loading functions behind it. ## Which page do I need? | I want to… | Read | |---|---| -| use the wizard stage by stage | [The wizard GUI](../howto/gui.md) | +| use the GUI stage by stage | [Using the GUI](../howto/gui.md) | | save a result, or several, and load them back | [Saving and loading](../howto/saving_and_loading.md) | | rerun a saved session headlessly or retarget it at new data | [Saving and loading](../howto/saving_and_loading.md) | | decide between the GUI and a script | [GUI or script interface](../introduction/interfaces.md) | diff --git a/pyproject.toml b/pyproject.toml index 72bbdf9..0a1beb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,9 @@ version = "0.3.1" description = "Complete retrieval of ultrashort laser pulses from FROG traces — amplitude and phase, with dispersive media, geometric smearing and uncertainty" readme = "README.md" requires-python = ">=3.14" -license = "MIT" -license-files = ["LICENSE"] +# The code is MIT; the window's font, IBM Plex Sans, is under the Open Font License. +license = "MIT AND OFL-1.1" +license-files = ["LICENSE", "croak/gui/assets/fonts/LICENSE.txt"] authors = [{ name = "John Travers" }, { name = "Christian Brahms" }] keywords = [ "ultrafast", @@ -47,7 +48,9 @@ dependencies = [ # scripted pipelines install without Qt: `pip install croak` (or # `uv sync --no-dev`). The GUI needs `pip install "croak[gui]"`; launching # `croak-gui` without it raises a message saying exactly that. -gui = ["pyqt6>=6.11.0", "superqt>=0.8.2"] +# PySide6-Essentials is the Qt modules the window uses (about a third of PySide6); +# pyqtgraph draws every on-screen plot, matplotlib only the exported figures. +gui = ["pyside6-essentials>=6.10.1", "pyqtgraph>=0.14", "superqt>=0.8.2"] # Global-search retriever (the 'cma-es' algorithm): CMA-ES / Sep-CMA-ES / DE. # JAX-native evolution strategies; reuses the existing jax install. evo = ["evosax>=0.2.0"] @@ -90,7 +93,8 @@ dev = [ # The GUI extra's packages, spelled out: the dev environment always has Qt # (the suite includes the GUI tests) even though the runtime dependency is # optional. Keep in lockstep with [project.optional-dependencies] gui. - "pyqt6>=6.11.0", + "pyside6-essentials>=6.10.1", + "pyqtgraph>=0.14", "superqt>=0.8.2", # The evo extra, so the 'cma-es' retriever is tested; lockstep with [evo]. "evosax>=0.2.0", @@ -120,6 +124,7 @@ testpaths = ["tests", "croak"] addopts = "-ra --strict-markers --strict-config --doctest-modules" filterwarnings = ["error"] xfail_strict = true +qt_api = "pyside6" [tool.ruff] target-version = "py314" diff --git a/tests/conftest.py b/tests/conftest.py index f02134c..dd00fb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -389,7 +389,7 @@ def isolated_settings(request): """ if "gui" not in request.path.parent.parts[-1:]: return - from PyQt6.QtCore import QSettings + from PySide6.QtCore import QSettings tmp_path = request.getfixturevalue("tmp_path") QSettings.setPath( diff --git a/tests/gui/gui_session.py b/tests/gui/gui_session.py new file mode 100644 index 0000000..1f4af3c --- /dev/null +++ b/tests/gui/gui_session.py @@ -0,0 +1,126 @@ +"""A test driver for the workflow window, in the old stage numbering. + +The GUI tests were written against six numbered stages (1 Load, 2 Marginal +check, 3 Preprocess, 4 Retrieve, 5 Dispersion, 6 Uncertainty). The window +now has five places (Data, Check, Prepare, Retrieve, Analyse with a rail), so +:class:`Driver` maps the numbers onto them and hands back the stage pages, +which keep their own behaviour. Navigation tests use the window itself. +""" + +from __future__ import annotations + +from typing import cast + +from croak.gui.pages import AnalysePage, DataPage +from croak.gui.window import MainWindow +from croak.gui.workflow import Stage + +# stage number -> (place, step or rail page) +PLACES = { + 1: (Stage.DATA, ""), + 2: (Stage.CHECK, ""), + 3: (Stage.PREPARE, ""), + 4: (Stage.RETRIEVE, ""), + 5: (Stage.ANALYSE, "dispersion"), + 6: (Stage.ANALYSE, "uncertainty"), +} + + +class Driver: + """A :class:`MainWindow` and its stage pages, numbered as the tests know them.""" + + def __init__(self, qtbot, entry: str = "") -> None: + self.win = MainWindow() + qtbot.addWidget(self.win) + # Without an entry: a measured session on its Data page, where the old + # wizard's stage tests began. + self.start(entry or "experimental") + + @classmethod + def of(cls, win: MainWindow) -> Driver: + """A driver for a window the test built and started itself.""" + driver = cls.__new__(cls) + driver.win = win + return driver + + @property + def state(self): + return self.win.state + + def start(self, entry: str) -> None: + """Start a new session from ``entry`` (no confirmation asked).""" + self.win.state.reset() + self.win.state.entry = entry + self.win.session_path = "" + self.win._build_pages() + self.win.go(Stage.DATA) + + # -- pages -------------------------------------------------------------- + @property + def data(self) -> DataPage: + return cast(DataPage, self.win.pages[Stage.DATA]) + + @property + def analyse(self) -> AnalysePage: + return cast(AnalysePage, self.win.pages[Stage.ANALYSE]) + + @property + def stages(self) -> list: + """The six stage pages in the old order (the entry's loader first).""" + return [ + self.data.entry_page, + self.win.pages[Stage.CHECK], + self.win.pages[Stage.PREPARE], + self.win.pages[Stage.RETRIEVE], + self.analyse.pages["dispersion"], + self.analyse.pages["uncertainty"], + ] + + @property + def synthetic(self): + return self.data.entry_page + + simulated = synthetic + + # -- where we are --------------------------------------------------------- + def goto(self, number: int) -> None: + """Show stage ``number``, as the old ``state.stage = number`` did. + + The route's rules are bypassed (a test may look at a stage before its + input exists, to check what it says), exactly like the old setter. + """ + place, sub = PLACES[number] + win = self.win + win.stage = place + page = win.pages[place] + win.stack.setCurrentWidget(page) + if place is Stage.ANALYSE: + self.analyse.show_page(sub) + else: + page.on_enter() + win.refresh() + + @property + def stage_number(self) -> int: + """The old number of the stage on screen (0 on Home).""" + place = self.win.stage + for number, (where, sub) in PLACES.items(): + if where is not place: + continue + if place is Stage.ANALYSE and sub != self.analyse.key: + continue + return number + return 0 + + def current_page(self): + """The stage page on screen (inside Data or Analyse's rail).""" + return self.stages[self.stage_number - 1] if self.stage_number else None + + def __getattr__(self, name: str): + """Anything else is the window's (``resize``, ``grab``, ``statusBar``…).""" + return getattr(self.win, name) + + def rebuild(self) -> None: + """Rebuild the pages from the state (after loading options into it).""" + self.win._build_pages() + self.win.go(Stage.DATA) diff --git a/tests/gui/test_about.py b/tests/gui/test_about.py new file mode 100644 index 0000000..ae55609 --- /dev/null +++ b/tests/gui/test_about.py @@ -0,0 +1,40 @@ +"""The About page: version, links, authors, copyright.""" + +from __future__ import annotations + +from importlib import metadata +from pathlib import Path + +import pytest + +pytest.importorskip("PySide6") + +from croak.gui.about import COPYRIGHT, PYPI_URL, About, project_links + +ROOT = Path(__file__).resolve().parents[2] + + +def test_about_shows_the_installed_version_authors_and_links(qtbot): + about = About() + qtbot.addWidget(about) + meta = metadata.metadata("croak") + assert meta["Version"] in about.version.text() + assert about.authors.text() == meta["Author"] + assert about.links["Package on PyPI"] == PYPI_URL + assert set(about.links) == { + "Manual", + "Source on GitHub", + "Package on PyPI", + "Report an issue", + } + + +def test_about_links_leave_out_what_the_metadata_lacks(): + assert project_links([]) == {"Package on PyPI": PYPI_URL} + links = project_links(["Repository, https://example.org/croak"]) + assert links["Source on GitHub"] == "https://example.org/croak" + + +def test_about_copyright_matches_the_licence_file(): + licence = (ROOT / "LICENSE").read_text(encoding="utf-8") + assert COPYRIGHT.replace("©", "(c)") in licence diff --git a/tests/gui/test_base.py b/tests/gui/test_base.py index 13f5dc8..6c39010 100644 --- a/tests/gui/test_base.py +++ b/tests/gui/test_base.py @@ -1,13 +1,15 @@ -"""The Stage base page: help dialog, entry-page hooks and the shared width.""" +"""The Stage base page: help dialog, entry-page hooks and the sheet layout.""" from __future__ import annotations import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") + +from PySide6.QtWidgets import QLabel, QLineEdit -from croak.gui import settings from croak.gui.base import Stage +from croak.gui.controls import group from croak.gui.state import WizardState @@ -35,25 +37,32 @@ def test_stage_help_dialog_is_reused(qtbot): def test_stage_default_hooks(qtbot): stage = Stage(WizardState()) qtbot.addWidget(stage) - assert stage.next_label() == "Next →" assert stage.can_advance() is True assert stage.advance() is None assert stage.on_enter() is None -def test_stage_shows_with_the_shared_controls_width(qtbot): - settings.set_controls_width(300) - stage = Stage(WizardState()) +def test_stage_builds_on_the_sheet_layout(qtbot): + stage = _HelpedStage(WizardState()) + qtbot.addWidget(stage) + # Sections go in the form's column; the ready line is the status. + assert stage.controls is stage.form.content + assert stage.status_label is stage.form.ready + stage.set_status("Loaded.") + assert stage.form.ready.text() == "Loaded." and stage.form.ready.state == "ok" + stage.set_status("Failed: no file", "error") + assert stage.form.ready.property("error") is True + plot, strip = QLabel("plot"), QLabel("strip") + stage.set_plot_area(plot, strip) + assert stage.isAncestorOf(plot) and stage.isAncestorOf(strip) + + +def test_stage_help_reaches_the_section_rows(qtbot): + stage = _HelpedStage(WizardState()) qtbot.addWidget(stage) - stage.resize(1200, 600) - stage.show() - qtbot.waitExposed(stage) - assert stage.splitter.sizes()[0] == 300 - # Showing again with the width already applied changes nothing. - stage.hide() - stage.show() - assert stage.splitter.sizes()[0] == 300 - # A rested divider drag is remembered for every stage. - stage.splitter.setSizes([280, 900]) - stage._save_controls_width() - assert settings.controls_width() == 280 + section, form = group("Section") + field = QLineEdit() + form.addRow("Label", field) + stage.controls.addWidget(section) + assert stage.apply_help() == {("Section", "Label")} + assert field.toolTip() == "Text" diff --git a/tests/gui/test_branding.py b/tests/gui/test_branding.py index eb737b4..6ab0a91 100644 --- a/tests/gui/test_branding.py +++ b/tests/gui/test_branding.py @@ -8,7 +8,7 @@ import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") from croak.gui import branding diff --git a/tests/gui/test_canvas.py b/tests/gui/test_canvas.py deleted file mode 100644 index f504fd7..0000000 --- a/tests/gui/test_canvas.py +++ /dev/null @@ -1,80 +0,0 @@ -"""MplCanvas re-plotting and the small widget factories.""" - -from __future__ import annotations - -import gc -import weakref - -import pytest - -pytest.importorskip("PyQt6") - -from croak.gui.canvas import MplCanvas, check, combo, dspin, spin, with_toolbar - - -def test_canvas_keeps_its_window_alive_during_a_draw(qapp): - """A pending canvas draw must outlive its otherwise unreachable parent.""" - canvas = MplCanvas() - panel = with_toolbar(canvas) - # Model a discarded stage awaiting cyclic collection while its queued draw - # still owns a Python reference to the canvas, but not to the parent widget. - panel.cycle = panel - owner = weakref.ref(panel) - owners = [panel] - del panel - alive_during_draw = [] - - def collect_during_draw(_event): - # Release it only once the draw has started, regardless of when Python - # happens to collect cycles from earlier tests. - owners.clear() - gc.collect() - alive_during_draw.append(owner() is not None) - - canvas.mpl_connect("draw_event", collect_during_draw) - canvas.draw() - assert alive_during_draw == [True] - # Drawing should keep the window alive only for the duration of the draw. - gc.collect() - assert owner() is None - - -def test_canvas_draw_without_a_plot_function_is_an_error(qtbot): - canvas = MplCanvas() - qtbot.addWidget(canvas) - with pytest.raises(RuntimeError, match="render"): - canvas._draw_at(canvas.scale) - - -def test_canvas_replot_only_when_something_changed(qtbot): - canvas = MplCanvas() - qtbot.addWidget(canvas) - calls = [] - # Nothing rendered yet: a debounced resize has nothing to re-plot. - canvas._replot() - canvas.render(lambda fig: calls.append(fig)) - assert len(calls) == 1 - # Same density as drawn: no re-plot. - canvas._replot() - assert len(calls) == 1 - - -def test_widget_factories_without_a_change_handler(qapp): - assert spin(0, 10, 3).value() == 3 - assert dspin(0.0, 1.0, 0.25).value() == pytest.approx(0.25) - box = combo(["a", "b"], value="z") # unknown value: keeps the first item - assert box.currentText() == "a" - assert check("on", True).isChecked() - - -def test_widget_factories_connect_the_change_handler(qapp): - seen = [] - s = spin(0, 10, 3, on_change=seen.append) - s.setValue(4) - d = dspin(0.0, 1.0, 0.25, on_change=seen.append) - d.setValue(0.5) - c = combo(["a", "b"], value="a", on_change=seen.append) - c.setCurrentText("b") - k = check("on", False, on_change=seen.append) - k.setChecked(True) - assert seen == [4, 0.5, "b", True] diff --git a/tests/gui/test_controls.py b/tests/gui/test_controls.py new file mode 100644 index 0000000..b30dc04 --- /dev/null +++ b/tests/gui/test_controls.py @@ -0,0 +1,42 @@ +"""The small form-control factories.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") + +from croak.gui.controls import check, combo, dspin, spin + + +def test_widget_factories_without_a_change_handler(qapp): + assert spin(0, 10, 3).value() == 3 + assert dspin(0.0, 1.0, 0.25).value() == pytest.approx(0.25) + box = combo(["a", "b"], value="z") # unknown value: keeps the first item + assert box.currentText() == "a" + assert check("on", True).isChecked() + + +def test_widget_factories_connect_the_change_handler(qapp): + seen = [] + s = spin(0, 10, 3, on_change=seen.append) + s.setValue(4) + d = dspin(0.0, 1.0, 0.25, on_change=seen.append) + d.setValue(0.5) + c = combo(["a", "b"], value="a", on_change=seen.append) + c.setCurrentText("b") + k = check("on", False, on_change=seen.append) + k.setChecked(True) + assert seen == [4, 0.5, "b", True] + + +def test_controls_collapsible_draws_its_chevrons_from_svg(qtbot): + from PySide6.QtWidgets import QLabel + + from croak.gui.controls import collapsible + + strip = collapsible("Read-out", QLabel("x")) + qtbot.addWidget(strip) + # Real icons, not superqt's text arrows painted into an empty pixmap. + for icon in (strip.expandedIcon(), strip.collapsedIcon()): + assert not icon.pixmap(12, 12).isNull() diff --git a/tests/gui/test_display.py b/tests/gui/test_display.py new file mode 100644 index 0000000..e3e542c --- /dev/null +++ b/tests/gui/test_display.py @@ -0,0 +1,74 @@ +"""The display settings every plot follows (:mod:`croak.gui.display`).""" + +from __future__ import annotations + +import numpy as np +import pytest + +pytest.importorskip("PySide6") + +from croak.gui import display, settings +from croak.gui.display import DisplayBar, current, hub, set_display +from croak.gui.view_panel import ViewPanel +from croak.plotting.display import Display +from croak.plotting.screen import CurveView, LineView + +LAM = np.linspace(600.0, 1000.0, 41) +SPECTRUM = LineView( + curves=(CurveView(LAM, np.exp(-(((LAM - 800.0) / 60.0) ** 2)), "measured"),), + title="Spectrum", + xlabel="Wavelength (nm)", + ylabel="Intensity", +) + + +def test_display_defaults_until_set_and_is_remembered(qtbot): + assert current() == Display() + with qtbot.waitSignal(hub().changed): + set_display(Display("frequency", 45.0)) + assert current() == Display("frequency", 45.0) + + +def test_display_falls_back_to_the_defaults_for_what_it_cannot_use(): + settings.set_text(display.KEY_AXIS, "sideways") + assert current() == Display() + + +def test_display_hub_is_made_once(): + assert hub() is hub() + + +def test_display_bar_changes_and_resets_the_display(qtbot): + bar = DisplayBar() + qtbot.addWidget(bar) + with qtbot.waitSignal(hub().changed): + bar.axis.set_value("frequency", emit=True) + assert current().axis == "frequency" + bar.db_range.setValue(60) + assert current() == Display("frequency", 60.0) + assert bar.display() == current() + bar.reset.click() + assert current() == Display() + assert (bar.axis.value, bar.db_range.value()) == ("native", 30) + # a display set elsewhere shows on the bar + set_display(Display("wavelength", 20.0)) + assert bar.display() == Display("wavelength", 20.0) + # a new bar starts from what is kept + again = DisplayBar() + qtbot.addWidget(again) + assert again.display() == Display("wavelength", 20.0) + + +def test_display_redraws_every_plot_on_its_new_axis(qtbot): + panel = ViewPanel("line") + qtbot.addWidget(panel) + panel.redisplay() # nothing shown yet: nothing to do + assert panel.view is None + panel.show_view(SPECTRUM) + assert panel.view is SPECTRUM + set_display(Display("frequency")) + assert panel.view is not None and panel.view.xlabel == "Frequency (PHz)" + axis = panel.plot.item.getAxis("bottom") + assert axis.labelText == "Frequency (PHz)" + set_display(Display()) + assert panel.view is SPECTRUM diff --git a/tests/gui/test_gui_package.py b/tests/gui/test_gui_package.py index 76472ba..37fd3a5 100644 --- a/tests/gui/test_gui_package.py +++ b/tests/gui/test_gui_package.py @@ -1,4 +1,4 @@ -"""The ``croak.gui`` package entry points: run_wizard, main, lazy exports, -m.""" +"""The ``croak.gui`` package entry points: run_gui, main, lazy exports, -m.""" from __future__ import annotations @@ -7,49 +7,49 @@ import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") import croak.gui as gui -from croak.gui import wizard as wizard_mod +from croak.gui import window as window_mod -def test_run_wizard_shows_maximised_and_runs_the_event_loop(qapp, monkeypatch): +def test_run_gui_shows_maximised_and_runs_the_event_loop(qapp, monkeypatch): shown = [] monkeypatch.setattr( - wizard_mod.Wizard, "showMaximized", lambda self: shown.append(self) + window_mod.MainWindow, "showMaximized", lambda self: shown.append(self) ) monkeypatch.setattr(type(qapp), "exec", lambda self: 42) - assert gui.run_wizard(["croak-gui"]) == 42 + assert gui.run_gui(["croak-gui"]) == 42 assert len(shown) == 1 # apply_identity ran: the application carries croak's name assert qapp.applicationName() == "croak" shown[0].deleteLater() -def test_main_delegates_to_run_wizard(monkeypatch): - monkeypatch.setattr(gui, "run_wizard", lambda argv=None: 7) +def test_main_delegates_to_run_gui(monkeypatch): + monkeypatch.setattr(gui, "run_gui", lambda argv=None: 7) assert gui.main() == 7 -def test_run_wizard_without_qt_says_how_to_install(monkeypatch): +def test_run_gui_without_qt_says_how_to_install(monkeypatch): # A None entry in sys.modules makes the import raise ImportError. - monkeypatch.setitem(sys.modules, "PyQt6.QtWidgets", None) + monkeypatch.setitem(sys.modules, "PySide6.QtWidgets", None) with pytest.raises(ImportError, match=r"croak\[gui\]"): - gui.run_wizard([]) + gui.run_gui([]) def test_lazy_exports_resolve_and_unknown_names_raise(): from croak.gui.state import WizardState - from croak.gui.wizard import Wizard + from croak.gui.window import MainWindow - assert gui.Wizard is Wizard + assert gui.MainWindow is MainWindow assert gui.WizardState is WizardState with pytest.raises(AttributeError, match="no_such_name"): _ = gui.no_such_name -def test_python_dash_m_runs_the_wizard(monkeypatch): - monkeypatch.setattr(gui, "run_wizard", lambda: 3) +def test_python_dash_m_runs_the_gui(monkeypatch): + monkeypatch.setattr(gui, "run_gui", lambda: 3) # runpy warns when re-running a module that is already imported. monkeypatch.delitem(sys.modules, "croak.gui.__main__", raising=False) with pytest.raises(SystemExit) as exit_info: @@ -61,7 +61,7 @@ def test_importing_the_main_module_does_not_launch(monkeypatch): import importlib launched = [] - monkeypatch.setattr(gui, "run_wizard", lambda: launched.append(1)) + monkeypatch.setattr(gui, "run_gui", lambda: launched.append(1)) monkeypatch.delitem(sys.modules, "croak.gui.__main__", raising=False) importlib.import_module("croak.gui.__main__") assert launched == [] diff --git a/tests/gui/test_help.py b/tests/gui/test_help.py index 2d5b122..b5a8a27 100644 --- a/tests/gui/test_help.py +++ b/tests/gui/test_help.py @@ -4,12 +4,11 @@ import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QCheckBox, QFormLayout, - QGroupBox, QHBoxLayout, QLineEdit, QPushButton, @@ -18,6 +17,7 @@ ) from croak.gui.help import HelpDialog, apply_tooltips, build_html, flatten +from croak.gui.sheet import Section def test_build_html_escapes_and_skips_an_empty_intro(): @@ -41,8 +41,8 @@ def test_apply_tooltips_skips_rows_without_a_usable_field_or_label(qtbot): root = QWidget() qtbot.addWidget(root) outer = QVBoxLayout(root) - box = QGroupBox("Group && more") - form = QFormLayout(box) + box = Section("Group && more") + form = box.form labelled = QLineEdit() form.addRow("Plain", labelled) # A layout (not a widget) in the field column: no field widget, skipped. diff --git a/tests/gui/test_mirror_stack.py b/tests/gui/test_mirror_stack.py index 8f9beb9..5bf0c03 100644 --- a/tests/gui/test_mirror_stack.py +++ b/tests/gui/test_mirror_stack.py @@ -5,7 +5,7 @@ import numpy as np import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") from croak import dispersion, mirrors from croak.gui import mirror_stack as ms diff --git a/tests/gui/test_panel_pages.py b/tests/gui/test_panel_pages.py deleted file mode 100644 index 028251b..0000000 --- a/tests/gui/test_panel_pages.py +++ /dev/null @@ -1,85 +0,0 @@ -"""RetrievalPages: pop-out windows and switching between result and convergence.""" - -from __future__ import annotations - -import numpy as np -import pytest - -pytest.importorskip("PyQt6") - -from croak import plotting -from croak.forward import maketrace -from croak.grid import Grid -from croak.gui.panel_pages import RetrievalPages -from croak.maths import wlfreq -from croak.pulses import gaussian_pulse -from croak.retrieve import retrieve - - -@pytest.fixture(scope="module") -def plot_data(): - g = Grid(96, dt=0.5e-15) - ew = gaussian_pulse(g, 7e-15, phases=[20e-30]) - delays = np.linspace(-50e-15, 50e-15, 60) - trace = maketrace(g.omega, delays, ew, "pg") - res = retrieve( - trace, - g.omega, - delays, - "pg", - algorithm="copra", - maxiters=20, - guess=ew, - omega0=wlfreq(800e-9), - progress=False, - ) - return plotting.retrieval_plot_data( - res, measured=trace, lam_min=700e-9, lam_max=950e-9 - ) - - -def test_pop_out_before_any_result_shows_a_blank_page(qtbot): - pages = RetrievalPages() - qtbot.addWidget(pages) - popout = pages.pop_out() - assert popout.page.title == plotting.RETRIEVAL_PAGES[pages.current_page()].title - assert not popout.canvas.rendered - # A second request raises the same window rather than opening another. - assert pages.pop_out() is popout - - -def test_closing_a_pop_out_forgets_it(qtbot, plot_data): - pages = RetrievalPages() - qtbot.addWidget(pages) - pages.set_data(plot_data) - key = pages.page_keys()[1] - popout = pages.pop_out(key) - assert popout.canvas.rendered # drawn with the current result - with qtbot.waitSignal(popout.finished): - popout.close() - assert key not in pages._popouts - # Forgetting a window that is already gone is harmless. - pages._forget_popout(key) - assert pages.pop_out(key) is not popout - - -def test_progress_after_a_result_replaces_the_page_with_the_curve(qtbot, plot_data): - pages = RetrievalPages() - qtbot.addWidget(pages) - pages.set_data(plot_data) - key = pages.current_page() - canvas = pages.canvas(key) - view = pages._views[canvas] - assert view.attached - errors = [0.5, 0.2] - pages.show_progress(errors) - # The page's artists went with the cleared figure; the curve took over. - assert not view.attached - assert canvas in pages._conv - # The curve grows in place from the same (live) list. - artists = pages._conv[canvas] - assert len(artists.line.get_xdata()) == 2 - errors.append(0.1) - pages.show_progress(errors) - assert pages._conv[canvas] is artists - assert len(artists.line.get_xdata()) == 3 diff --git a/tests/gui/test_plots.py b/tests/gui/test_plots.py new file mode 100644 index 0000000..9fb1ef2 --- /dev/null +++ b/tests/gui/test_plots.py @@ -0,0 +1,346 @@ +"""The pyqtgraph views: maps, curves, colour maps, hover readouts and views.""" + +from __future__ import annotations + +import numpy as np +import pytest + +pytest.importorskip("PySide6") + +from numpy.testing import assert_allclose +from PySide6.QtCore import QEvent, QPointF, Qt +from PySide6.QtGui import QMouseEvent + +from croak.gui.plots import ( + NEGATIVE_RGBA, + Curve, + LinePlot, + MapPlot, + Plot, + curve_reader, + is_uniform, + map_reader, + signed_colormap, +) + + +def _shown(qtbot, widget): + qtbot.addWidget(widget) + widget.resize(500, 360) + widget.show() + qtbot.waitExposed(widget) + return widget + + +def _scene_point(plot: Plot, x: float, y: float) -> QPointF: + return plot.box.mapViewToScene(QPointF(x, y)) + + +def _map(qtbot, **kwargs) -> MapPlot: + plot = _shown(qtbot, MapPlot(xlabel="Delay (fs)", ylabel="f (PHz)", **kwargs)) + x = np.linspace(-50.0, 50.0, 11) + y = np.linspace(0.6, 0.9, 7) + data = np.outer(np.arange(7.0), np.ones(11)) + plot.show_map(data, x, y, (0.0, 6.0)) + return plot + + +# -- colour maps -------------------------------------------------------------- +def test_plots_signed_colormap_is_white_at_zero_and_red_below(): + cmap = signed_colormap("viridis", -1.0, 3.0) + # 0 lies a quarter of the way along the levels. + # Qt stores colours in 8 bits, hence the tolerance of one step. + assert_allclose(cmap.map([0.25], mode=cmap.FLOAT)[0], (1, 1, 1, 1), atol=1 / 255) + assert_allclose(cmap.map([0.0], mode=cmap.FLOAT)[0], NEGATIVE_RGBA, atol=1 / 255) + + +def test_plots_signed_colormap_ignores_rounding_noise_below_zero(): + # a bottom level of -2e-12 (rounding noise) must not paint zero red + cmap = signed_colormap("viridis", -2e-12, 1.0) + assert_allclose(cmap.map([0.0], mode=cmap.FLOAT)[0], (1, 1, 1, 1), atol=1 / 255) + + +def test_plots_unsigned_colormap_starts_white(): + cmap = signed_colormap("viridis", 0.0, 1.0) + assert_allclose(cmap.map([0.0], mode=cmap.FLOAT)[0], (1, 1, 1, 1), atol=1e-6) + assert cmap.map([1.0], mode=cmap.FLOAT)[0][3] == pytest.approx(1.0) + + +@pytest.mark.parametrize( + ("name", "low", "high", "match"), + [ + ("viridis", 0.0, 0.0, "high > 0"), + ("viridis", 2.0, 1.0, "low < high"), + ("no-such-map", 0.0, 1.0, "unknown colour map"), + ("bwr", 0.0, 1.0, ""), + ], +) +def test_plots_signed_colormap_rejects_bad_levels_and_names(name, low, high, match): + if not match: # a matplotlib map is found too + assert signed_colormap(name, low, high) is not None + return + with pytest.raises(ValueError, match=match): + signed_colormap(name, low, high) + + +def test_plots_is_uniform_needs_increasing_equal_steps(): + assert is_uniform(np.linspace(0, 1, 5)) + assert not is_uniform(np.array([1.0])) + assert not is_uniform(np.array([2.0, 1.0, 0.0])) + assert not is_uniform(np.array([0.0, 1.0, 1.5])) + + +# -- maps ----------------------------------------------------------------------- +def test_plots_map_shows_data_centred_on_its_samples(qtbot): + plot = _map(qtbot) + rect = plot.image.mapRectToParent(plot.image.boundingRect()) + # 11 samples 10 fs apart, each a pixel centred on its delay. + assert rect.left() == pytest.approx(-55.0) + assert rect.width() == pytest.approx(110.0) + assert plot.bar.levels() == pytest.approx((0.0, 6.0)) + assert plot.value_at(0.0, 0.6) == 0.0 + assert plot.value_at(10.0, 0.9) == 6.0 + assert plot.value_at(500.0, 0.7) is None + + +@pytest.mark.parametrize( + ("data", "x", "match"), + [ + (np.zeros((3, 3)), np.linspace(0, 1, 4), "does not match"), + (np.zeros((7, 3)), np.array([0.0, 1.0, 3.0]), "evenly spaced"), + ], +) +def test_plots_map_rejects_mismatched_or_uneven_axes(qtbot, data, x, match): + plot = _shown(qtbot, MapPlot()) + with pytest.raises(ValueError, match=match): + plot.show_map(data, x, np.linspace(0, 1, 7), (0.0, 1.0)) + + +def test_plots_map_updates_in_place_and_changes_levels_freely(qtbot): + plot = _map(qtbot) + plot.update_map(np.full((7, 11), 2.0)) + assert plot.value_at(0.0, 0.6) == 2.0 + with pytest.raises(ValueError, match="shape already shown"): + plot.update_map(np.zeros((2, 2))) + plot.set_levels((-40.0, 0.0)) + assert plot.bar.levels() == pytest.approx((-40.0, 0.0)) + + +def test_plots_signed_map_recolours_for_negative_levels(qtbot): + plot = _map(qtbot, signed=True) + plot.set_levels((-2.0, 6.0)) + cmap = plot.bar.colorMap() + assert_allclose(cmap.map([0.0], mode=cmap.FLOAT)[0], NEGATIVE_RGBA, atol=1 / 255) + plot.set_levels((0.0, 6.0)) # no negatives: back to white at the floor + cmap = plot.bar.colorMap() + assert_allclose(cmap.map([0.0], mode=cmap.FLOAT)[0], (1, 1, 1, 1), atol=1 / 255) + + +def test_plots_map_colour_bar_drags_and_double_click_restores_it(qtbot): + plot = _map(qtbot, signed=True) + plot.set_levels((-2.0, 6.0)) + # a drag on the bar moves its levels; white stays at zero on a signed map + plot.bar.setLevels(values=(-1.0, 3.0)) + plot.bar.sigLevelsChanged.emit(plot.bar) + cmap = plot.bar.colorMap() + assert_allclose(cmap.map([0.25], mode=cmap.FLOAT)[0], (1, 1, 1, 1), atol=1 / 255) + plot.reset_view() # a double-click + assert plot.bar.levels() == pytest.approx((-2.0, 6.0)) + + +def test_plots_unsigned_map_keeps_its_colours_when_dragged(qtbot): + plot = _map(qtbot) + before = plot.bar.colorMap() + plot.bar.setLevels(values=(0.2, 0.8)) + plot.bar.sigLevelsChanged.emit(plot.bar) + assert plot.bar.colorMap() is before + + +def test_plots_map_starts_white_unless_told_otherwise(qtbot): + white = _map(qtbot) + cmap = white.bar.colorMap() + assert_allclose(cmap.map([0.0], mode=cmap.FLOAT)[0], (1, 1, 1, 1), atol=1 / 255) + plain = _map(qtbot, white=False, colours="bwr") + cmap = plain.bar.colorMap() + assert cmap.map([0.0], mode=cmap.FLOAT)[0][2] > 0.9 # bwr starts blue + + +def test_plots_map_says_a_message_in_place_of_the_image(qtbot): + plot = _map(qtbot) + plot.say("No trace loaded") + assert plot.data is None + assert plot.message.isVisible() + assert plot.value_at(0.0, 0.7) is None + plot.show_map(np.ones((2, 2)), np.arange(2.0), np.arange(2.0), (0.0, 1.0)) + assert not plot.message.isVisible() + + +def test_plots_map_update_needs_an_image_first(qtbot): + plot = _shown(qtbot, MapPlot()) + with pytest.raises(ValueError, match="shape already shown"): + plot.update_map(np.zeros((2, 2))) + + +# -- hover and view --------------------------------------------------------------- +def test_plots_hover_reads_the_map_under_the_pointer(qtbot): + plot = _map(qtbot) + plot.reader = map_reader(plot, "τ", "f") + assert plot.reader(500.0, 0.65) == "" # off the image: nothing to say + plot.hover(_scene_point(plot, -40.0, 0.65)) + assert plot.note.isVisible() and plot.cross_x.isVisible() + assert plot.note.toPlainText().strip() == "τ -40 f 0.65 1" + # Past 62 % of the width the readout flips to the pointer's left. + plot.hover(_scene_point(plot, 40.0, 0.92)) + assert plot.note.anchor.x() == pytest.approx(1.0) + assert plot.note.anchor.y() == pytest.approx(0.0) + + +def test_plots_hover_hides_outside_the_box_without_a_reader_or_text(qtbot): + plot = _map(qtbot) + plot.hover(_scene_point(plot, 0.0, 0.7)) # no reader yet + assert not plot.note.isVisible() + plot.reader = lambda _x, _y: "" + plot.hover(_scene_point(plot, 0.0, 0.7)) + assert not plot.note.isVisible() + plot.reader = map_reader(plot, "τ", "f") + plot.hover(QPointF(-1000.0, -1000.0)) + assert not plot.note.isVisible() + plot.hover(_scene_point(plot, 0.0, 0.7)) + plot.leaveEvent(QEvent(QEvent.Type.Leave)) + assert not plot.cross_y.isVisible() + + +def test_plots_double_click_returns_to_the_home_view(qtbot): + plot = _shown(qtbot, Plot(xlabel="x", ylabel="y")) + event = QMouseEvent( + QEvent.Type.MouseButtonDblClick, + QPointF(10, 10), + QPointF(10, 10), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + plot.mouseDoubleClickEvent(event) # no home yet: fits whatever is there + plot.set_view((0.0, 10.0), (-1.0, 1.0)) + plot.box.setRange(xRange=(4.0, 5.0), padding=0) + plot.mouseDoubleClickEvent(event) + assert plot.box.viewRange()[0] == pytest.approx([0.0, 10.0]) + plot.set_labels("Delay (fs)", None) + assert plot.item.getAxis("bottom").labelText == "Delay (fs)" + plot.set_labels(None, "Power (W)") + assert plot.item.getAxis("left").labelText == "Power (W)" + + +# -- lines -------------------------------------------------------------------------- +def test_plots_lines_take_colours_in_turn_and_a_legend_when_several(qtbot): + plot = _shown(qtbot, LinePlot(xlabel="t (fs)", ylabel="I")) + x = np.linspace(-1, 1, 21) + plot.set_curves([Curve(x, x**2)]) + assert plot.legend is None + plot.set_curves( + [ + Curve(x, x**2, "retrieved"), + Curve(x, x**2 + 1, "truth", colour="#000000", dashed=True), + Curve(x, x**3, "fit"), + ] + ) + assert plot.legend is not None and plot.legend.isVisible() + assert len(plot.legend.items) == 3 + pens = [item.opts["pen"] for item in plot.items] + assert pens[0].color().name() == "#1f6fb4" + assert pens[1].style() == Qt.PenStyle.DashLine + # The explicitly coloured curve does not use up a palette colour. + assert pens[2].color().name() == "#d2670b" + plot.set_curves([Curve(x, x)]) + assert not plot.legend.isVisible() + + +def test_plots_log_axis_drops_non_positive_values(qtbot): + plot = _shown(qtbot, LinePlot()) + x = np.arange(5.0) + plot.set_curves([Curve(x, np.array([0.0, 1.0, 10.0, -1.0, 100.0]))], log_y=True) + assert_allclose(plot.items[0].yData, [1.0, 10.0, 100.0]) + plot.update_curve(0, x, np.array([1.0, 0.0, 1.0, 1.0, 1.0])) + assert plot.items[0].yData.size == 4 + plot.fit() + # A log axis spans whole decades (at least one), so it always has ticks. + assert plot.box.viewRange()[1] == pytest.approx([0.0, 1.0]) + # ...labelled at each decade only, which a short plot has room for + left = plot.item.getAxis("left") + assert left.tickSpacing(0.0, 2.0, 100.0) == [(1.0, 0), (1.0, 0)] + plot.set_curves([Curve(x, x)]) # a linear axis spaces its ticks itself + assert left.tickSpacing(0.0, 2.0, 100.0) != [(1.0, 0), (1.0, 0)] + + +def test_plots_right_axis_carries_its_own_curves(qtbot): + plot = _shown(qtbot, LinePlot(ylabel="I", right_label="Phase (rad)")) + x = np.linspace(0, 1, 11) + plot.set_curves([Curve(x, x, "I"), Curve(x, -x, "φ", right=True)], log_y=True) + assert plot.right_box is not None + assert plot.items[1] in plot.right_box.addedItems + # Negative phase survives the log intensity axis. + assert plot.items[1].yData.min() == pytest.approx(-1.0) + plot.set_right_range(-3.0, 3.0) + assert plot.right_box.viewRange()[1] == pytest.approx([-3.0, 3.0]) + plot.set_curves([Curve(x, x)]) # removes the right-hand curve again + assert plot.items[0] not in plot.right_box.addedItems + plot.box.resize(300, 200) # the right view box follows the main one + + +def test_plots_right_range_is_ignored_without_a_right_axis(qtbot): + plot = _shown(qtbot, LinePlot()) + plot.set_right_range(0.0, 1.0) + assert plot.right_box is None + + +def test_plots_right_label_needs_a_right_axis(qtbot): + with_right = _shown(qtbot, LinePlot(right_label="Phase")) + with_right.set_right_label("Phase (rad)") + assert with_right.item.getAxis("right").labelText == "Phase (rad)" + without = _shown(qtbot, LinePlot()) + without.set_right_label("ignored") # no right axis: nothing to label + + +def test_plots_curves_can_be_thin_references_drawn_beneath(qtbot): + plot = _shown(qtbot, LinePlot()) + x = np.arange(3.0) + plot.set_curves([Curve(x, x), Curve(x, x, width=0.9, under=True)]) + assert plot.items[1].zValue() < plot.items[0].zValue() + assert plot.items[1].opts["pen"].widthF() == pytest.approx(0.9) + + +def test_plots_marks_replace_the_previous_set(qtbot): + plot = _shown(qtbot, LinePlot()) + plot.set_marks([(1.0, ""), (2.0, "")]) + plot.set_marks([(3.0, "#ff0000")], dashed=False) + assert [m.value() for m in plot.marks] == [3.0] + + +def test_plots_fit_frames_the_left_curves(qtbot): + plot = _shown(qtbot, LinePlot()) + plot.fit() # nothing to fit + x = np.linspace(0.0, 2.0, 3) + plot.set_curves([Curve(x, np.array([0.0, 10.0, 0.0])), Curve(x, x, right=True)]) + plot.fit() + assert plot.box.viewRange()[0] == pytest.approx([0.0, 2.0]) + assert plot.box.viewRange()[1] == pytest.approx([-0.5, 10.5]) + plot.set_curves([Curve(x, np.full(3, np.nan))]) + plot.fit() # no finite value: a default window + assert plot.box.viewRange()[1] == pytest.approx([-0.05, 1.05]) + + +def test_plots_curve_reader_reads_each_named_left_curve(qtbot): + plot = _shown(qtbot, LinePlot()) + x = np.linspace(0.0, 1.0, 11) + plot.set_curves( + [ + Curve(x, 2 * x, "R"), + Curve(x, x), + Curve(x, x, "φ", right=True), + Curve(x + 5, x, "far"), + Curve(np.zeros(0), np.zeros(0), "empty"), + ] + ) + read = curve_reader(plot, "t") + assert read(0.5, 0.0) == "t 0.5 R 1 0.5" + assert read(-3.0, 0.0) == "" diff --git a/tests/gui/test_retrieval_views.py b/tests/gui/test_retrieval_views.py new file mode 100644 index 0000000..c748e52 --- /dev/null +++ b/tests/gui/test_retrieval_views.py @@ -0,0 +1,219 @@ +"""The retrieval's panels on screen: the live overview and Analyse's result pages.""" + +from __future__ import annotations + +from dataclasses import replace + +import numpy as np +import pytest + +pytest.importorskip("PySide6") + +from PySide6.QtWidgets import QFileDialog + +from croak import plotting +from croak.forward import maketrace +from croak.grid import Grid +from croak.gui.retrieval_views import ( + OVERVIEW, + PANELS, + ResultPage, + RetrievalPanels, +) +from croak.gui.state import WizardState +from croak.maths import wlfreq +from croak.processing import TruthPulse +from croak.pulses import gaussian_pulse +from croak.retrieve import retrieve + + +@pytest.fixture(scope="module") +def data(): + """A short PG retrieval with a measured spectrum and a known truth.""" + g = Grid(96, dt=0.5e-15) + omega0 = float(wlfreq(800e-9)) + ew = gaussian_pulse(g, 7e-15, phases=[20e-30]) + delays = np.linspace(-50e-15, 50e-15, 60) + trace = maketrace(g.omega, delays, ew, "pg") + res = retrieve( + trace, + g.omega, + delays, + "pg", + algorithm="copra", + maxiters=20, + guess=ew, + omega0=omega0, + ) + return plotting.retrieval_plot_data( + res, + measured=trace, + Iomega_meas=np.abs(ew) ** 2, + lam_min=700e-9, + lam_max=950e-9, + truth=TruthPulse.from_spectrum(g, ew, omega0), + ) + + +def test_retrieval_views_cover_every_figure_panel(): + assert set(PANELS) == set(plotting.RETRIEVAL_PANELS) + + +def test_retrieval_panels_move_from_empty_to_results(qtbot, data): + panels = RetrievalPanels() + qtbot.addWidget(panels) + assert panels.mode == "empty" + panels.set_data(data) + assert panels.mode == "panels" and panels.data is data + assert set(panels.boxes) == {k for row in OVERVIEW for k in row} + temporal = panels.boxes["temporal"] + assert temporal.title.text() == "Retrieved pulse" + assert temporal.plot.right_box is not None # the phase axis + spectral = panels.boxes["spectral"] + assert spectral.notes.text().startswith("GDD:") + assert spectral.notes.property("warn") is False + panels.clear() + assert panels.mode == "empty" and panels.data is None + + +def test_retrieval_panels_draw_every_page(qtbot, data): + for spec in plotting.RETRIEVAL_PAGES.values(): + panels = RetrievalPanels(spec.mosaic) + qtbot.addWidget(panels) + panels.set_data(data) + for key, box in panels.boxes.items(): + assert box.title.text(), key + traces = RetrievalPanels(plotting.RETRIEVAL_PAGES["traces"].mosaic) + qtbot.addWidget(traces) + traces.resize(900, 700) + traces.show() + qtbot.waitExposed(traces) + traces.set_data(data) + note = traces.boxes["retrieved_lin"].notes.text() + assert note.startswith("R ") and "×" in note + # The trace maps are linked: zooming one zooms the others. + first = traces.boxes["measured_log"].plot + first.box.setXRange(-10, 10, padding=0) + other = traces.boxes["retrieved_lin"].plot + assert other.box.viewRange()[0] == pytest.approx([-10, 10]) + + +def test_retrieval_panels_say_when_there_is_no_residual(qtbot, data): + panels = RetrievalPanels((("residual",),)) + qtbot.addWidget(panels) + panels.set_data(replace(data, processed=replace(data.processed, residual=None))) + box = panels.boxes["residual"] + assert box.title.text() == "Residuals" + assert box.plot.data is None and box.plot.message.isVisible() + + +def test_retrieval_panels_warn_about_edge_energy(qtbot, data): + panels = RetrievalPanels((("spectral",),)) + qtbot.addWidget(panels) + leaky = replace(data, processed=replace(data.processed, edge_energy=0.5)) + panels.set_data(leaky) + assert panels.boxes["spectral"].notes.property("warn") is True + + +def test_result_page_follows_the_result_and_pops_out(qtbot, data): + state = WizardState() + current = {"data": None} + page = ResultPage(state, "pulse", lambda: current["data"]) + qtbot.addWidget(page) + page.show() + page.on_enter() + assert page.panels.mode == "empty" + current["data"] = data + state.result_changed.emit() # visible: redrawn at once + assert page.panels.mode == "panels" + dialog = page.pop_out() + follower = dialog.findChild(RetrievalPanels) + assert follower is not None and follower.mode == "panels" + current["data"] = replace(data) + state.result_changed.emit() + assert follower.data is current["data"] + dialog.close() + assert page.popouts == [] + page.hide() + state.result_changed.emit() # hidden: drawn when next shown + empty = ResultPage(state, "overview", lambda: None) + qtbot.addWidget(empty) + assert empty.pop_out().findChild(RetrievalPanels).mode == "empty" + + +def test_result_page_exports_its_figure(qtbot, data, tmp_path, monkeypatch): + state = WizardState() + messages = [] + page = ResultPage(state, "overview", lambda: data) + page.message.connect(messages.append) + qtbot.addWidget(page) + monkeypatch.setattr(QFileDialog, "getSaveFileName", lambda *a, **k: ("", "")) + assert page.export_figure() == "" # cancelled + target = tmp_path / "overview.png" + monkeypatch.setattr( + QFileDialog, "getSaveFileName", lambda *a, **k: (str(target), "") + ) + assert page.export_figure() == str(target) + assert target.stat().st_size > 0 + assert messages == ["Saved overview.png"] + diagnostics = ResultPage(state, "diagnostics", lambda: data) + qtbot.addWidget(diagnostics) + target = tmp_path / "diagnostics.svg" + monkeypatch.setattr( + QFileDialog, "getSaveFileName", lambda *a, **k: (str(target), "") + ) + assert diagnostics.export_figure() == str(target) + nothing = ResultPage(state, "traces", lambda: None) + nothing.message.connect(messages.append) + qtbot.addWidget(nothing) + assert nothing.export_figure() == "" + assert messages[-1] == "There is nothing to save as a figure yet." + + +def test_result_page_on_wavelength_exports_its_plots_as_shown(qtbot, data): + from croak.gui.display import set_display + from croak.plotting.display import Display + + page = ResultPage(WizardState(), "overview", lambda: data) + qtbot.addWidget(page) + page.on_enter() + page.resize(1200, 800) + page.show() + qtbot.waitExposed(page) + set_display(Display(axis="wavelength")) + figure = page.figure() + assert figure is not None + # croak's page figure draws its traces against frequency; this is the + # screen's own layout, on wavelength + labels = {ax.get_ylabel() for ax in figure.axes} | { + ax.get_xlabel() for ax in figure.axes + } + assert "Wavelength (nm)" in labels + assert "Frequency (PHz)" not in labels + + +def test_view_panel_draws_bars_bands_spans_points_and_levels(qtbot): + from test_plotting_screen import _estimate + + from croak.gui.view_panel import ViewPanel + from croak.plotting.screen import uncertainty_views + + panels = [ViewPanel("line") for _ in range(3)] + for panel in panels: + qtbot.addWidget(panel) + hist, band = uncertainty_views(_estimate()) + panels[0].show_view(hist) + panels[1].show_view(band) + plot = panels[0].plot + kinds = {type(item).__name__ for item in plot.extras} + assert kinds == {"BarGraphItem", "LinearRegionItem"} + assert plot.box.viewRange()[0] == pytest.approx( + [hist.bars.edges[0], hist.bars.edges[-1]] + ) + assert any(type(i).__name__ == "FillBetweenItem" for i in panels[1].plot.extras) + fwhm, error, _ = uncertainty_views(_estimate("thickness", thickness=True)) + panels[0].show_view(fwhm) # redrawn: the histogram's extras go + kinds = {type(item).__name__ for item in plot.extras} + assert "BarGraphItem" not in kinds and "InfiniteLine" in kinds + assert plot.items[0].opts["symbol"] == "o" + panels[2].show_view(error) diff --git a/tests/gui/test_settings.py b/tests/gui/test_settings.py index d4a0cda..08d4e1c 100644 --- a/tests/gui/test_settings.py +++ b/tests/gui/test_settings.py @@ -4,9 +4,9 @@ import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") -from PyQt6.QtWidgets import QWidget +from PySide6.QtWidgets import QWidget from croak.gui import settings diff --git a/tests/gui/test_sheet.py b/tests/gui/test_sheet.py new file mode 100644 index 0000000..19d9a14 --- /dev/null +++ b/tests/gui/test_sheet.py @@ -0,0 +1,268 @@ +"""Sheet building blocks: sections, help, find, reflow and status widgets.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") + +from PySide6.QtWidgets import ( + QCheckBox, + QLineEdit, + QScrollArea, + QSpinBox, + QWidget, +) + +from croak.gui import settings +from croak.gui.help import apply_tooltips +from croak.gui.sheet import ( + NARROW_WIDTH, + AdviceBox, + DerivedGrid, + Form, + ReadyLine, + Section, + Segments, + Sheet, + row_text, +) + +HELP = { + ("Grid", "Points"): "How many samples.", + ("Grid", "Resample t"): "Resample in time.", + ("Filtering", "Threshold"): "Clip below this.", +} + + +def _form(qtbot, **kwargs) -> Form: + form = Form(**kwargs) + qtbot.addWidget(form) + return form + + +def _grid(form: Form): + grid = form.add_section("Grid") + points = QSpinBox() + resample = QCheckBox("Resample t") + grid.form.addRow("Points", points) + grid.form.addRow(resample) + return grid, points, resample + + +def test_sheet_section_rows_take_help_by_section_and_label(qtbot): + form = _form(qtbot) + _, points, resample = _grid(form) + loose = QLineEdit() + form.add_section("Other").form.addRow("Undocumented", loose) + matched = apply_tooltips(form, HELP) + assert matched == {("Grid", "Points"), ("Grid", "Resample t")} + assert points.toolTip() == "How many samples." + assert resample.toolTip() == "Resample in time." + assert loose.toolTip() == "" + + +def test_sheet_section_title_reads_an_escaped_ampersand(qtbot): + section = Section("Units && interaction") + qtbot.addWidget(section) + assert section.section_title == "Units & interaction" + assert section.header.text() == "Units && interaction" + + +def test_sheet_row_text_reads_a_label_or_a_check_box(qtbot): + form = _form(qtbot) + grid, _, _ = _grid(form) + grid.form.addRow(QWidget()) # neither: no text + assert [row_text(grid.form, r) for r in range(3)] == ["Points", "Resample t", ""] + + +def test_sheet_focused_control_shows_its_help_line(qtbot): + form = _form(qtbot) + form.show() + qtbot.waitExposed(form) + _, points, _ = _grid(form) + apply_tooltips(form, HELP) + points.setFocus() + qtbot.waitUntil(lambda: form.help.text() == "How many samples.") + plain = QLineEdit() # no tooltip anywhere up to the form: help unchanged + form.add_widget(plain) + plain.setFocus() + qtbot.waitUntil(plain.hasFocus) + assert form.help.text() == "How many samples." + + +def test_sheet_help_ignores_controls_of_another_form(qtbot): + form, other = _form(qtbot), _form(qtbot) + elsewhere = QSpinBox() + elsewhere.setToolTip("Not mine.") + other.add_widget(elsewhere) + form._focus_moved(None, elsewhere) + assert form.help.text() == "" + form._focus_moved(None, None) + + +def test_sheet_sections_fold_and_remember_it(qtbot): + form = _form(qtbot) + section = form.add_section("Grid", folded=True, settings_key="test/grid_open") + assert section.folded and section.body.isHidden() + section.set_folded(False) + assert not section.folded + assert settings.value("test/grid_open", False) is True + second = _form(qtbot) # held, so Python does not delete the widget + again = second.add_section("Grid", folded=True, settings_key="test/grid_open") + assert not again.folded # the remembered state wins over the default + plain = form.add_section("Filtering") + plain.set_folded(True) # no key: nothing remembered + assert plain.folded + assert form.sections == [section, plain] + + +def test_sheet_find_shows_only_matching_rows(qtbot): + form = _form(qtbot, find=True) + form.show() + grid, points, resample = _grid(form) + filtering = form.add_section("Filtering", folded=True) + threshold = QSpinBox() + filtering.form.addRow("Threshold", threshold) + apply_tooltips(form, HELP) + assert form.find is not None + form.find.setText("clip") # matches the threshold's help text + assert threshold.isVisible() and filtering.body.isVisible() + assert not grid.isVisible() + form.find.setText("samples") + assert points.isVisible() and not resample.isVisible() + form.find.setText("grid") # the section's own name shows all of it + assert points.isVisible() and resample.isVisible() + form.find.setText("") + assert grid.isVisible() and resample.isVisible() + assert filtering.folded and not filtering.body.isVisible() + + +def test_sheet_form_without_find_has_no_box(qtbot): + form = Form() + qtbot.addWidget(form) + assert form.find is None + assert form.sections == [] + + +def test_sheet_plots_move_below_the_form_on_a_narrow_window(qtbot): + form, plots = _form(qtbot), QWidget() + sheet = Sheet(form, plots) + qtbot.addWidget(sheet) + sheet.resize(NARROW_WIDTH + 200, 700) + sheet.show() + qtbot.waitExposed(sheet) + assert not sheet.narrow + sheet.resize(NARROW_WIDTH - 200, 700) + qtbot.waitUntil(lambda: plots.geometry().top() >= form.geometry().bottom()) + assert sheet.narrow + sheet.resize(NARROW_WIDTH - 100, 700) # still narrow: no change + qtbot.wait(10) + assert sheet.narrow + + +@pytest.mark.parametrize( + ("state", "warn", "error"), + [("ok", False, False), ("warn", True, False), ("error", False, True)], +) +def test_sheet_ready_line_colours_its_state(qtbot, state, warn, error): + line = ReadyLine() + qtbot.addWidget(line) + line.say("Ready", state) + assert line.text() == "Ready" and line.state == state + assert bool(line.property("warn")) is warn + assert bool(line.property("error")) is error + + +def test_sheet_ready_line_rejects_an_unknown_state(qtbot): + line = ReadyLine() + qtbot.addWidget(line) + with pytest.raises(ValueError, match="ok, warn or error"): + line.say("?", "maybe") + + +def test_sheet_advice_box_appears_when_it_has_something_to_say(qtbot): + box = AdviceBox() + qtbot.addWidget(box) + assert box.isHidden() + box.say("Retrieval failed", "Try a smaller grid.") + assert not box.isHidden() + assert box.title.text() == "Retrieval failed" + box.clear() + assert box.isHidden() and box.title.text() == "" + + +def test_sheet_derived_numbers_show_values_and_states(qtbot): + grid = DerivedGrid(columns=2) + qtbot.addWidget(grid) + grid.add("FWHM") + grid.add("Edge energy", tooltip="Energy at the grid's edge.") + grid.add("Grid") + assert grid.grid.itemAtPosition(1, 0) is not None # wrapped onto row 2 + grid.set("Edge energy", "2 %", "warn") + assert grid.values["Edge energy"].property("warn") is True + grid.clear() + assert grid.values["FWHM"].text() == "–" + assert grid.values["Edge energy"].property("warn") is False + grid.note("FWHM", "fit") + assert grid.names["FWHM"].text() == "FWHM · fit" + grid.note("FWHM", "") + assert grid.names["FWHM"].text() == "FWHM" + + +def test_sheet_derived_numbers_start_a_group_on_a_new_row(qtbot): + grid = DerivedGrid(columns=3) + qtbot.addWidget(grid) + grid.add("A") + grid.end_row() + grid.end_row() # already at a row's start: no blank row + grid.add("B") + assert grid.grid.itemAtPosition(1, 0) is not None + assert grid.grid.itemAtPosition(0, 1) is None + + +def test_sheet_segments_choose_one_value(qtbot): + seg = Segments( + [("frequency", "Frequency"), ("wavelength", "Wavelength")], "frequency" + ) + qtbot.addWidget(seg) + with qtbot.waitSignal(seg.changed) as blocker: + seg.buttons["wavelength"].click() + assert blocker.args == ["wavelength"] + assert seg.buttons["wavelength"].property("current") is True + assert seg.buttons["frequency"].property("current") is False + with qtbot.assertNotEmitted(seg.changed): + seg.buttons["wavelength"].click() # no change: no signal + seg.set_value("frequency") # programmatic: no signal + with pytest.raises(ValueError, match="not one of"): + seg.set_value("time") + with pytest.raises(ValueError, match="not one of"): + Segments([("a", "A")], "b") + + +def test_sheet_combo_boxes_shrink_to_the_column(qtbot): + from PySide6.QtWidgets import QComboBox + + from croak.gui.sheet import FORM_WIDTH + + form, plots = _form(qtbot), QWidget() + long = QComboBox() + long.addItems(["Automatic (centroid phase) from a very long description"]) + wide = long.minimumSizeHint().width() # computed now, and cached by Qt + form.add_section("Mode").form.addRow("Initial guess", long) + sheet = Sheet(form, plots) + qtbot.addWidget(sheet) + sheet.resize(NARROW_WIDTH + 400, 700) + sheet.show() + qtbot.waitExposed(sheet) + assert long.sizeAdjustPolicy() == ( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + assert form.width() == FORM_WIDTH + # The long item does not widen what the column scrolls, cached hint or not. + assert long.minimumSizeHint().width() == wide + assert long.minimumWidth() < wide + content = form.findChild(QScrollArea).widget() + assert content.minimumSizeHint().width() < FORM_WIDTH + sheet.resize(NARROW_WIDTH - 200, 700) # stacked: the form spans the sheet + qtbot.waitUntil(lambda: form.width() > FORM_WIDTH) diff --git a/tests/gui/test_stage_dispersion.py b/tests/gui/test_stage_dispersion.py index 5c2cbd6..ee0a409 100644 --- a/tests/gui/test_stage_dispersion.py +++ b/tests/gui/test_stage_dispersion.py @@ -2,10 +2,12 @@ from __future__ import annotations +from pathlib import Path + import numpy as np import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") from croak import materials from croak.forward import maketrace @@ -85,12 +87,12 @@ def test_without_a_result_everything_is_inert(qtbot): for action in ( stage._auto_taylor, stage._auto_gas, - stage._save, lambda: stage._auto_material("SiO2"), lambda: stage._auto_row(stage.mirror_stack.add_row()), ): action() - assert not stage.canvas.rendered + assert stage.views["spectrogram"].plot.data is None # nothing drawn + assert stage.save_extras(Path("unused")) == [] # nothing to save with pytest.raises(RuntimeError, match="no retrieval result"): stage._modified() with pytest.raises(RuntimeError, match="no retrieval result"): @@ -113,7 +115,11 @@ def test_base_without_a_result_is_inert_for_the_tuners(qtbot, chirped_result): def test_entering_shows_the_pulse_and_power_labels(stage): - assert stage.canvas.rendered + titles = [view.title.text() for view in stage.views.values()] + assert titles == ["Spectrum", "Retrieved pulse", "Applied phase", "Spectrogram"] + assert stage.views["spectral"].plot.item.getAxis("bottom").labelText == ( + "Wavelength (nm)" + ) assert stage.fwhm_label.text().endswith(" fs") assert stage.peak_label.text() == "–" stage.energy.setValue(1e-6) @@ -232,23 +238,44 @@ def test_saved_settings_are_restored_on_entry(qtbot, chirped_result): # -- save ------------------------------------------------------------------------ -def test_save_cancelled_writes_nothing(stage, monkeypatch, tmp_path): - monkeypatch.setattr( - sd.QFileDialog, "getExistingDirectory", staticmethod(lambda *a: "") - ) - stage._save() +def test_save_extras_writes_the_dispersed_pulse_only_when_applied(stage, tmp_path): + assert stage.save_extras(tmp_path) == [] # no dispersion set: nothing added assert list(tmp_path.iterdir()) == [] + stage.gdd.set_value(-20.0) + written = stage.save_extras(tmp_path) + assert written == ["result_dispersion.h5", "dispersion.pdf"] + for name in written: + assert (tmp_path / name).stat().st_size > 0 -def test_save_writes_result_options_and_figure(stage, monkeypatch, tmp_path): +def test_save_extras_disperses_the_result_of_a_later_retrieval(stage, tmp_path): + """Regression: a retrieval run after the page was last shown is the one saved. + + The page copies the spectrum when shown, and Save session can be reached + from any page; the stale copy was paired with the new result's grid. + """ + from croak import dispersion, save + stage.gdd.set_value(-20.0) - monkeypatch.setattr( - sd.QFileDialog, - "getExistingDirectory", - staticmethod(lambda *a: str(tmp_path)), + g = Grid(64, dt=0.5e-15) # a different grid size from the page's copy + ew = gaussian_pulse(g, 6e-15) + delays = np.linspace(-40e-15, 40e-15, 24) + later = retrieve( + maketrace(g.omega, delays, ew, "pg"), + g.omega, + delays, + "pg", + algorithm="copra", + maxiters=3, + guess=ew, + omega0=wlfreq(800e-9), + progress=False, + ) + stage.state.set_result(later) # Dispersion not shown again + assert stage.save_extras(tmp_path)[0] == "result_dispersion.h5" + saved = save.load_result(str(tmp_path / "result_dispersion.h5")) + expected = dispersion.apply_dispersion( + later.grid.omega, later.omega0, later.spectrum, gdd=-20e-30 ) - stage._save() - for name in ("result_dispersion.h5", "options.toml", "dispersion.pdf"): - assert (tmp_path / name).exists() - assert "gdd_fs2 = -20.0" in (tmp_path / "options.toml").read_text() - assert stage.status_label.text() == f"Saved to {tmp_path}" + # the same arrays written and read back: float precision only + np.testing.assert_allclose(saved["Ew_retr"], expected, rtol=1e-12, atol=0) diff --git a/tests/gui/test_stage_load.py b/tests/gui/test_stage_load.py index e94ca6c..1514ab7 100644 --- a/tests/gui/test_stage_load.py +++ b/tests/gui/test_stage_load.py @@ -10,9 +10,9 @@ import numpy as np import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") -from PyQt6.QtWidgets import QFileDialog, QPushButton +from PySide6.QtWidgets import QFileDialog, QPushButton from croak.gui import stage_load from croak.gui.stage_load import StageLoad @@ -386,11 +386,11 @@ def test_stage_load_draw_handles_position_scans_and_out_of_band_data(stage): "trace": np.zeros((6, 5)), } stage._draw(data) - ax = stage.trace_canvas.figure.axes[0] - assert ax.get_title() == "Measured trace" - # τ = 2z/c: ±3 µm of stage travel is ±20 fs of delay (the mesh adds half a - # 10 fs cell either side), not the ±3e-6 a raw position axis would give - lo, hi = ax.get_xlim() + view = stage.trace_view + assert view.title.text() == "Measured trace" + # τ = 2z/c: ±3 µm of stage travel is ±20 fs of delay (each sample is a + # 10 fs pixel centred on its delay), not the ±3e-6 a raw position axis gives + lo, hi = view.plot.box.viewRange()[0] assert lo == pytest.approx(-25.0, abs=0.1) and hi == pytest.approx(25.0, abs=0.1) @@ -401,14 +401,9 @@ def test_stage_load_draw_handles_an_empty_trace(stage): "input_unit": "delay", "trace": np.zeros((0, 3)), } - called = [] - monkey = stage.trace_canvas.render - stage.trace_canvas.render = lambda fn: called.append(fn) - try: - stage._draw(data) - finally: - stage.trace_canvas.render = monkey - assert len(called) == 1 + stage._draw(data) + assert stage.trace_view.plot.data is None # said, not drawn + assert stage.trace_view.plot.message.isVisible() def test_stage_load_signature_ignores_reverse_trace(stage): diff --git a/tests/gui/test_stage_marginal_check.py b/tests/gui/test_stage_marginal_check.py index b9dd64d..06174ef 100644 --- a/tests/gui/test_stage_marginal_check.py +++ b/tests/gui/test_stage_marginal_check.py @@ -11,11 +11,13 @@ import numpy as np import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") from croak.gui import stage_marginal_check +from croak.gui.display import set_display from croak.gui.stage_marginal_check import StageMarginalCheck, _peak_norm from croak.gui.state import WizardState +from croak.plotting.display import Display from croak.session.pipeline import assemble_load_data LAM = np.linspace(700e-9, 900e-9, 64) @@ -41,7 +43,7 @@ def _data(interaction="pg", *, spectrum=True, trace=None, input_unit="delay"): @pytest.fixture def stage(qtbot): - s = StageMarginalCheck(WizardState(), None) + s = StageMarginalCheck(WizardState()) qtbot.addWidget(s) return s @@ -52,9 +54,9 @@ def _enter(stage, data, entry="experimental"): stage.on_enter() -def _legend(canvas): - legend = canvas.figure.axes[0].get_legend() - return [t.get_text() for t in legend.get_texts()] +def _legend(view): + """The names of the curves the marginal view shows.""" + return [c.name for c in view.plot.curves] @pytest.fixture @@ -79,7 +81,7 @@ def test_stage_marginal_check_without_data_asks_for_a_trace(stage): stage.on_enter() assert stage.status_label.text() == "Load a trace first." stage._draw() # nothing to draw - assert stage.trace_canvas.figure.axes == [] + assert stage.trace_view.plot.data is None def test_stage_marginal_check_measured_trace_defaults_and_controls(stage): @@ -92,7 +94,7 @@ def test_stage_marginal_check_measured_trace_defaults_and_controls(stage): assert stage.exp_spin.value() == pytest.approx(3.5) assert stage.auto_btn.isEnabled() assert stage.status_label.text().startswith("PG marginal check") - assert "trace marginal" in _legend(stage.marg_canvas) + assert "trace marginal" in _legend(stage.marg_view) def test_stage_marginal_check_trust_is_kept_for_the_same_trace(stage): @@ -136,27 +138,28 @@ def test_stage_marginal_check_shg_correction_needs_a_spectrum(stage): assert stage.marginal_correct_check.isEnabled() stage.marginal_correct_check.setChecked(True) assert stage.state.preproc.marginal_correct is True - assert "trace marginal (corrected)" in _legend(stage.marg_canvas) + assert "trace marginal (corrected)" in _legend(stage.marg_view) assert "(exact)" in stage.status_label.text() # SHG predicts the width exactly # -- display ------------------------------------------------------------------ def test_stage_marginal_check_wavelength_axis_and_log_scale(stage): _enter(stage, _data()) - stage.axis_combo.setCurrentText("wavelength") + set_display(Display(axis="wavelength")) stage.scale_combo.setCurrentText("log") - ax = stage.marg_canvas.figure.axes[0] - assert ax.get_xlabel() == "Wavelength (nm)" - assert ax.get_yscale() == "log" - assert ax.get_xlim() == pytest.approx(stage._trust_nm) + plot = stage.marg_view.plot + assert plot.item.getAxis("bottom").labelText == "Wavelength (nm)" + assert plot.log_y + assert plot.box.viewRange()[0] == pytest.approx(stage._trust_nm) + # the log window is 1e-3 … 1.5, in decades + assert plot.box.viewRange()[1] == pytest.approx([-3.0, np.log10(1.5)]) def test_stage_marginal_check_position_scan_and_blank_trace(stage): # A blank, position-scanned trace: the image has no in-band signal to # zoom to and the marginal has too few positive samples for its moments. _enter(stage, _data(trace=np.zeros((LAM.size, 21)), input_unit="position")) - ax = stage.trace_canvas.figure.axes[0] - assert ax.get_title() == "Measured trace" + assert stage.trace_view.title.text() == "Measured trace" assert stage.status_label.text() == ( "No independent spectrum: showing the trace marginal only." ) @@ -165,8 +168,8 @@ def test_stage_marginal_check_position_scan_and_blank_trace(stage): def test_stage_marginal_check_trace_outside_trust_scales_to_all_of_it(stage): _enter(stage, _data(), entry="synthetic") stage._on_trust(100.0, 200.0) # no sample in band - ax = stage.trace_canvas.figure.axes[0] - assert ax.get_title() == "Measured trace" + assert stage.trace_view.title.text() == "Measured trace" + assert stage.trace_view.plot.data is not None # -- prediction edge cases ------------------------------------------------------ @@ -206,7 +209,7 @@ def test_stage_marginal_check_width_bracket_flags(stage, sigma_meas, flag): "width_is_hard": False, "rms_width": 2.0, } - stage._annotate_checks(None, 801.0, sigma_meas, pred, "pg") + stage._annotate_checks(801.0, sigma_meas, pred, "pg") text = stage.status_label.text() assert text.startswith("PG marginal check") assert "(Δ +1.0 nm, hard)" in text @@ -224,7 +227,7 @@ def test_stage_marginal_check_sd_reports_only_the_lower_bound(stage, sigma_meas, "width_is_hard": False, "rms_width": 2.0, } - stage._annotate_checks(None, 800.0, sigma_meas, pred, "sd") + stage._annotate_checks(800.0, sigma_meas, pred, "sd") text = stage.status_label.text() assert "TL/soft" in text assert text.endswith(f"(σ_S) {mark}") diff --git a/tests/gui/test_stage_preprocess.py b/tests/gui/test_stage_preprocess.py index c499a0c..e72d153 100644 --- a/tests/gui/test_stage_preprocess.py +++ b/tests/gui/test_stage_preprocess.py @@ -8,9 +8,9 @@ import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") -from PyQt6 import sip +import shiboken6 from croak.gui import stage_preprocess from croak.gui.stage_load import seed_preproc_defaults @@ -135,12 +135,14 @@ def test_stage_preprocess_on_enter_computes_once(loaded): assert loaded.status_label.text().startswith("Regridded to") loaded.on_enter() # a trace exists: re-seeds the widgets, no recompute assert loaded.state.tracedata is td - # a recompute with the view already built updates the artists in place - assert loaded._view.attached - figure_axes = list(loaded.canvas.figure.axes) + # a recompute redraws the same six maps + plots = [view.plot for view in loaded.views.values()] + assert all(plot.data is not None for plot in plots) loaded.apply(block=True) assert loaded.state.tracedata is not td - assert loaded.canvas.figure.axes == figure_axes + assert [view.plot for view in loaded.views.values()] == plots + titles = [view.title.text() for view in loaded.views.values()] + assert titles[0] == "Measured (dB)" and titles[-1] == "Regridded (lin)" def test_stage_preprocess_reset_restores_defaults(loaded): @@ -169,6 +171,7 @@ def test_stage_preprocess_ignores_superseded_and_reports_failed_results(loaded): assert loaded.status_label.text() == "" loaded._on_preprocess_failed("new error", 5) assert loaded.status_label.text() == "Preprocess failed: new error" + assert loaded.numbers.values["Grid"].text() == "–" # no grid to describe def test_stage_preprocess_fast_result_without_pending_does_not_refine(loaded): @@ -179,18 +182,12 @@ def test_stage_preprocess_fast_result_without_pending_does_not_refine(loaded): assert loaded._workers == [] -def test_stage_preprocess_replot_without_a_trace_only_attaches(loaded): - loaded.canvas.render(loaded._replot_view) - assert loaded._view.attached - assert loaded.state.tracedata is None - - def test_stage_preprocess_discard_worker_tolerates_destroyed_workers(loaded): worker = stage_preprocess.PreprocessWorker( loaded.state.preproc, loaded.state.load_data, 1, parent=loaded ) loaded._workers.append(worker) - sip.delete(worker) # destroyed with its stage before the queued slot ran + shiboken6.delete(worker) # destroyed with its stage before the queued slot ran loaded._discard_worker(worker) assert loaded._workers == [] # a worker no longer tracked (and still alive) is just scheduled for deletion @@ -198,7 +195,7 @@ def test_stage_preprocess_discard_worker_tolerates_destroyed_workers(loaded): loaded.state.preproc, loaded.state.load_data, 2, parent=loaded ) loaded._discard_worker(other) - assert not sip.isdeleted(other) + assert shiboken6.isValid(other) def test_stage_preprocess_baseline_toggle_gates_its_controls(stage): @@ -214,3 +211,41 @@ def test_stage_preprocess_baseline_toggle_gates_its_controls(stage): stage.baseline_check.setChecked(checked) assert stage.state.preproc.baseline is checked assert all(w.isEnabled() is checked for w in dependents) + + +def test_stage_preprocess_advises_when_the_grid_clips_the_trace(loaded): + from dataclasses import replace + + loaded.on_enter() + td = loaded.state.tracedata + assert loaded.advice.isHidden() # the seeded grid leaves room to spare + # Just over the library's own warning level: the page says so too. + clipped = replace(td, taper_loss=stage_preprocess.TAPER_ADVICE_LEVEL + 0.6) + loaded._show_tracedata(clipped) + assert loaded.status_label.state == "warn" + assert not loaded.advice.isHidden() + assert loaded.advice.title.text() == "The λ grid removes 61% of the trace" + assert "λ range (nm)" in loaded.advice.text.text() + loaded._show_tracedata(td) # widened again: the advice goes + assert loaded.advice.isHidden() + assert loaded.status_label.text().endswith("Proceed to Retrieve.") + + +def test_stage_preprocess_numbers_describe_the_grid(loaded): + from dataclasses import replace + + loaded.on_enter() + td = loaded.state.tracedata + numbers = loaded.numbers.values + assert numbers["Grid"].text() == f"{td.grid.n} × {td.delays.size}" + assert numbers["Δt"].text() == f"{td.grid.dt / 1e-15:.3g} fs" + window = float(numbers["Time window"].text().removesuffix(" fs")) + # .4g formatting: equal to four significant figures + assert window == pytest.approx(td.grid.n * td.grid.dt / 1e-15, rel=1e-3) + assert numbers["λ span"].text().endswith(" nm") + assert numbers["Taper loss"].property("warn") is not True + loaded._show_tracedata(replace(td, taper_loss=0.25)) + assert numbers["Taper loss"].text() == "25.0%" + assert numbers["Taper loss"].property("warn") is True + loaded._show_tracedata(replace(td, taper_loss=0.0)) + assert numbers["Taper loss"].text() == "< 0.1%" diff --git a/tests/gui/test_stage_retrieve.py b/tests/gui/test_stage_retrieve.py index 96bbaf6..819ff7c 100644 --- a/tests/gui/test_stage_retrieve.py +++ b/tests/gui/test_stage_retrieve.py @@ -15,15 +15,14 @@ import numpy as np import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") -from PyQt6.QtWidgets import QFileDialog +from PySide6.QtWidgets import QFileDialog from croak.gui import stage_retrieve from croak.gui.stage_load import seed_preproc_defaults from croak.gui.stage_retrieve import StageRetrieve from croak.gui.state import WizardState -from croak.processing import TruthPulse from croak.session.params import RetrieveParams from croak.session.pipeline import assemble_load_data, build_tracedata, run_retrieval @@ -55,6 +54,9 @@ def run(tmp_path_factory): def stage(qtbot): s = StageRetrieve(WizardState()) qtbot.addWidget(s) + # What the page says goes out as notices; keep them to read back. + s.said = [] + s.message.connect(s.said.append) return s @@ -226,13 +228,14 @@ def test_stage_retrieve_geometry_without_mask_says_so(stage): # -- running ------------------------------------------------------------------- def test_stage_retrieve_run_without_a_trace(stage): stage._run() - assert stage.status_label.text() == "No trace to retrieve." + assert stage.said == ["No trace to retrieve."] + assert stage.status_label.isHidden() # Retrieve has no ready line def test_stage_retrieve_truth_init_needs_a_complex_truth(with_trace): with_trace.state.retrieve.truth_init = True with_trace._run() - assert with_trace.status_label.text().startswith( + assert with_trace.said[-1].startswith( "Truth initialisation needs a known complex field" ) assert with_trace._worker is None @@ -264,16 +267,16 @@ def test_stage_retrieve_reuse_without_a_compatible_result(with_trace, monkeypatc monkeypatch.setattr(stage_retrieve, "RetrievalWorker", _Worker) with_trace.state.retrieve.reuse_result = True with_trace._run() - assert with_trace.status_label.text() == ( + assert with_trace.said[-1] == ( "No compatible previous result to reuse; using the default guess." ) assert with_trace._worker.kwargs["guess_override"] is None # a result on the same grid but with nothing extra to seed says nothing with_trace.state.result = run[3] - with_trace.set_status("") + with_trace.said.clear() with_trace._run() assert with_trace._worker.kwargs["extras_seed"] is run[3] - assert with_trace.status_label.text() == "" + assert with_trace.said == [] with_trace._stop() assert with_trace._worker.stop_requested @@ -296,14 +299,13 @@ def test_stage_retrieve_seeded_extras_names_thickness_and_smearing(stage, run): # -- completion, preview and read-out ------------------------------------------ def test_stage_retrieve_preview_without_trace_is_ignored(stage, run): stage._on_preview(run[3]) - assert not stage._live_full + assert stage.pages.mode == "empty" def test_stage_retrieve_preview_without_worker_still_shows(with_trace, run): with_trace._worker = None with_trace._on_preview(run[3]) - assert with_trace._live_full - assert with_trace.status_label.text().endswith("(live preview)") + assert with_trace.pages.mode == "panels" def test_stage_retrieve_preview_reports_its_cost_to_the_worker(with_trace, run): @@ -314,8 +316,11 @@ def test_stage_retrieve_preview_reports_its_cost_to_the_worker(with_trace, run): def test_stage_retrieve_convergence_waits_for_errors(stage, monkeypatch): shown = [] - monkeypatch.setattr(stage.pages, "show_progress", shown.append) - stage._live_full = False + monkeypatch.setattr( + stage.convergence, + "show_view", + lambda view: shown.append(list(view.curves[0].y)), + ) stage._errors = [] stage._draw_convergence() # nothing to plot yet assert shown == [] @@ -327,21 +332,8 @@ def test_stage_retrieve_convergence_waits_for_errors(stage, monkeypatch): def test_stage_retrieve_stopped_run_is_kept_and_labelled(with_trace, run): with_trace._on_stopped(run[3], 1.25) assert with_trace.state.result is not None - assert with_trace.save_btn.isEnabled() - status = with_trace.status_label.text() - assert status.startswith("Stopped. R = ") - assert status.endswith("1.25 s") - - -def test_stage_retrieve_status_lists_fitted_extras(with_trace, run): - p = with_trace.state.retrieve - p.smearing = True - fitted = replace(run[3], thickness=12e-6, tau0=2e-15, smear_scale=1.1) - with_trace._on_finished(fitted, 0.5) - status = with_trace.status_label.text() - assert "thickness 12.00 µm" in status - assert "τ₀ 2.00 fs" in status - assert "smearing σ" in status and "(×1.100)" in status + assert not with_trace.busy() + assert with_trace.said[-1] == "Stopped: the run's latest result is kept." def test_stage_retrieve_update_view_needs_result_and_trace(stage, run): @@ -353,6 +345,12 @@ def test_stage_retrieve_update_view_needs_result_and_trace(stage, run): stage._plot_data(run[3], None) +def test_stage_retrieve_smear_width_is_none_without_a_kernel(stage): + stage.state.retrieve.smearing = False # switched off after the run + td = SimpleNamespace(interaction="pg", omega0_pulse=2.35e15) + assert stage._smear_width_fs(1.1, td) is None + + def test_stage_retrieve_smear_width_is_none_for_shg(stage): stage.state.retrieve.smearing = True td = SimpleNamespace(interaction="shg", omega0_pulse=2.35e15) @@ -372,92 +370,89 @@ def test_stage_retrieve_elapsed_clock_ticks(stage, monkeypatch): def test_stage_retrieve_formatters_show_a_dash_when_undefined(value): assert StageRetrieve._nm(value) == DASH assert StageRetrieve._fs(value) == DASH - assert StageRetrieve._power(value, 1.0) == DASH -def test_stage_retrieve_curve_readout_without_truth_spectrum( +def test_stage_retrieve_pulse_readout_dashes_what_is_undefined( with_trace, run, monkeypatch ): with_trace._on_finished(run[3], 0.5) - pr = with_trace.state.processed - t = np.linspace(-50e-15, 50e-15, 101) - # intensity only: no spectrum on the truth - truth = TruthPulse(t=t, It=np.exp(-((t / 5e-15) ** 2)), fwhm=8.3e-15) - cells = {} - monkeypatch.setattr( - with_trace.readout, "set_cells", lambda row, values: cells.update({row: values}) - ) - with_trace._update_curve_readout(pr, truth) - # without a known spectrum the truth row carries temporal cells only - assert set(cells["truth"]) == {"fwhm", "peak_power"} + pr = replace(with_trace.state.processed, fwhm_retr=float("nan")) + monkeypatch.setattr(stage_retrieve, "peak_wavelength", lambda *a: None) + with_trace._update_pulse_readout(pr) + assert with_trace.readout.text("lam_peak") == DASH + assert with_trace.readout.text("fwhm") == DASH def test_stage_retrieve_extras_readout_marks_fitted_values(with_trace, run): p = with_trace.state.retrieve p.smearing = True - seen = {} - with_trace.readout.set_scalars = lambda values: seen.update(values) + readout = with_trace.readout + + def shown(key): + return readout.text(key), readout.name(key) + fitted = replace(run[3], thickness=12e-6, tau0=2e-15, smear_scale=1.1) with_trace._update_extras_readout(fitted) - assert seen["thickness"] == "12.00 µm (fit)" - assert seen["tau0"] == "2.00 fs (fit)" - assert seen["smearing"].endswith("(×1.100, fit)") + assert shown("thickness") == ("12.00 µm", "Thickness · fit") + assert shown("tau0") == ("2.00 fs", "τ₀ · fit") + assert readout.text("smearing").endswith(" fs") + assert readout.name("smearing") == "Smearing · ×1.100 fit" # a kernel the trace cannot carry (SHG) falls back to the bare factor with_trace.state.tracedata = replace(run[2], interaction="shg") with_trace._update_extras_readout(fitted) - assert seen["smearing"] == "×1.100 (fit)" + assert shown("smearing") == ("×1.100", "Smearing · fit") # nothing fitted, no slab: dashes and the nominal fixed values p.dispersive = False p.smearing = False with_trace._update_extras_readout(None) - assert seen["thickness"] == DASH - assert seen["tau0"] == "0.00 fs (fixed)" - assert seen["smearing"] == DASH + assert shown("thickness") == (DASH, "Thickness") + assert shown("tau0") == ("0.00 fs", "τ₀ · fixed") + assert shown("smearing") == (DASH, "Smearing") # -- retarget and save --------------------------------------------------------- def test_stage_retrieve_retarget_emits_the_chosen_folder(stage, monkeypatch, qtbot): monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: "") stage._retarget() - assert stage.status_label.text() == "" + assert stage.said == [] monkeypatch.setattr( QFileDialog, "getExistingDirectory", lambda *a, **k: "/data/next" ) with qtbot.waitSignal(stage.state.retarget_requested) as blocker: stage._retarget() assert blocker.args == ["/data/next"] - assert stage.status_label.text() == "Retargeting to /data/next …" - - -def test_stage_retrieve_save_needs_a_result_and_a_folder( - with_trace, monkeypatch, run, tmp_path -): - calls = [] - monkeypatch.setattr( - QFileDialog, - "getExistingDirectory", - lambda *a, **k: calls.append(1) or "", - ) - with_trace._save() # no result: the dialog never opens - assert calls == [] - with_trace._on_finished(run[3], 0.5) - with_trace._save() # cancelled - assert calls == [1] - assert list(tmp_path.iterdir()) == [] - - -def test_stage_retrieve_smear_summary_after_the_kernel_is_switched_off(stage): - stage.state.retrieve.smearing = False - td = SimpleNamespace(interaction="pg", omega0_pulse=2.35e15) - assert stage._smear_summary(1.1, td) == "smearing ×1.100" + assert stage.said == ["Retargeting to /data/next …"] @pytest.mark.parametrize( - ("message", "status"), [("stopped", "Stopped."), ("diverged", "Failed: diverged")] + ("message", "status", "advised"), + [("stopped", "Stopped.", False), ("diverged", "Failed: diverged", True)], ) -def test_stage_retrieve_failure_reenables_run(stage, message, status): - stage.run_btn.setEnabled(False) - stage.stop_btn.setEnabled(True) - stage._on_failed(message) - assert stage.run_btn.isEnabled() and not stage.stop_btn.isEnabled() - assert stage.status_label.text() == status +def test_stage_retrieve_failure_ends_the_run( + with_trace, monkeypatch, message, status, advised +): + with_trace.set_running(True) + with_trace._on_failed(message) + assert not with_trace.busy() + assert with_trace.said[-1] == status + # a failure stays over the plots until the next run starts + assert with_trace.advice.isHidden() is not advised + monkeypatch.setattr(stage_retrieve, "RetrievalWorker", _Worker) + with_trace._run() + assert with_trace.advice.isHidden() + + +def test_retrieve_form_finds_a_setting_by_name_or_help(qtbot): + stage = StageRetrieve(WizardState()) + qtbot.addWidget(stage) + stage.show() + assert stage.form.find is not None + stage.form.find.setText("regularisation") + shown = [s.section_title for s in stage.form.sections if not s.isHidden()] + assert "Regularisation" in shown and "Solver" not in shown + regularisation = next( + s for s in stage.form.sections if s.section_title == "Regularisation" + ) + assert not regularisation.body.isHidden() # unfolded to show the matches + stage.form.find.setText("") + assert regularisation.folded and regularisation.body.isHidden() diff --git a/tests/gui/test_stage_simulated.py b/tests/gui/test_stage_simulated.py index 2434ddd..120c65e 100644 --- a/tests/gui/test_stage_simulated.py +++ b/tests/gui/test_stage_simulated.py @@ -1,7 +1,8 @@ """Tests for the simulated-scan entry page (:mod:`croak.gui.stage_simulated`). -The page lives inside a :class:`~croak.gui.wizard.Wizard` (it calls back into -it to relabel and advance), so each test drives ``w.simulated``. +The page lives on the window's Data page (Retrieve adopts the scan's geometry +and the Data page follows where it goes), so each test drives ``w.simulated`` +through :class:`gui_session.Driver`. """ from __future__ import annotations @@ -10,22 +11,19 @@ import numpy as np import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") -from PyQt6.QtWidgets import QFileDialog +from gui_session import Driver +from PySide6.QtWidgets import QFileDialog from croak.gui import stage_simulated -from croak.gui.wizard import Wizard from croak.io import SIMULATED_WINDOW_KEYS from croak.session.params import SimulatedLoadParams @pytest.fixture def wizard(qtbot): - w = Wizard() - qtbot.addWidget(w) - w.load_simulated() - return w + return Driver(qtbot, "simulated") @pytest.fixture @@ -52,9 +50,9 @@ def test_stage_simulated_on_enter_previews_the_chosen_file(wizard, simulated_sca assert sim.can_advance() # a legacy file has no thickness slices: the generic prompt assert sim.status_label.text() == ( - "Review the trace, then Load & continue to filter it." + "Review the trace, then Continue to check and filter it." ) - titles = [ax.get_title() for ax in sim.canvas.figure.axes] + titles = [view.title.text() for view in sim.views.values()] assert "Simulated FROG trace (lin)" in titles @@ -77,11 +75,11 @@ def test_stage_simulated_unreadable_file_falls_back_and_reports(wizard, junk_h5) def test_stage_simulated_advance_reports_load_failure(wizard, junk_h5): sim = wizard.simulated sim.path_edit.setText(junk_h5) - stage_before = wizard.state.stage + stage_before = wizard.stage_number sim.advance() assert sim.status_label.text().startswith("Load failed:") assert wizard.state.load_data is None - assert wizard.state.stage == stage_before + assert wizard.stage_number == stage_before def test_stage_simulated_raw_load_failure_stays_on_the_page( @@ -102,7 +100,7 @@ def broken(params): sim.advance() assert sim.status_label.text() == "Raw load failed: no native grid" assert wizard.state.tracedata is None - assert wizard.stack.currentWidget() is sim + assert wizard.current_page() is sim def test_stage_simulated_advance_without_geometry_note( @@ -116,7 +114,7 @@ def test_stage_simulated_advance_without_geometry_note( sim._populate_file_options(simulated_scan_h5) sim.set_status("before") sim.advance() - assert wizard.stack.currentWidget() is wizard.stages[1] + assert wizard.current_page() is wizard.stages[1] assert sim.status_label.text() == "before" @@ -221,10 +219,9 @@ def test_stage_simulated_draw_handles_a_blank_trace_and_spectrum(wizard): lam = np.linspace(700e-9, 900e-9, 6) sim = wizard.simulated sim._draw(_sim_data(lam, np.zeros((6, 4)), lam, np.zeros(6))) - axd = {ax.get_title(): ax for ax in sim.canvas.figure.axes} - assert "Simulated FROG trace (lin)" in axd - legend = sim.canvas.figure.axes[3].get_legend() - assert [t.get_text() for t in legend.get_texts()] == ["trace marginal", "input"] + marginal = sim.views["spectral"].plot + assert sim.views["lin"].title.text() == "Simulated FROG trace (lin)" + assert [c.name for c in marginal.curves] == ["trace marginal", "input"] def test_stage_simulated_draw_skips_a_spectrum_without_intensity(wizard): @@ -232,4 +229,4 @@ def test_stage_simulated_draw_skips_a_spectrum_without_intensity(wizard): trace = np.outer(np.hanning(6), np.hanning(4)) sim = wizard.simulated sim._draw(_sim_data(lam, trace, lam_spec=lam, Ilam_spec=None)) - assert sim.canvas.figure.axes[3].get_legend() is None + assert [c.name for c in sim.views["spectral"].plot.curves] == ["trace marginal"] diff --git a/tests/gui/test_stage_synthetic.py b/tests/gui/test_stage_synthetic.py index 7d46024..9c5283f 100644 --- a/tests/gui/test_stage_synthetic.py +++ b/tests/gui/test_stage_synthetic.py @@ -5,29 +5,20 @@ import numpy as np import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") -from matplotlib.figure import Figure -from croak.grid import Grid from croak.gui.stage_synthetic import StageSynthetic from croak.gui.state import WizardState -class _Wizard: - """The one wizard hook the generator calls on success.""" - - def __init__(self): - self.entered = 0 - - def enter_marginal_from_synthetic(self): - self.entered += 1 - - @pytest.fixture def stage(qtbot): - widget = StageSynthetic(WizardState(), _Wizard()) + widget = StageSynthetic(WizardState()) qtbot.addWidget(widget) + # Count where the generator says the session goes on success. + widget.went = [] + widget.advanced.connect(widget.went.append) # Small, fast settings: a coarse trace is plenty to exercise the logic. widget.ndelay.setValue(32) widget.nwl.setValue(64) @@ -132,7 +123,7 @@ def fail(): monkeypatch.setattr(stage, "_pulse", fail) stage.on_enter() assert stage.status_label.text() == "Generation failed: bad grid" - assert not stage.canvas.rendered + assert stage.views["trace"].plot.data is None # nothing drawn def test_generate_failure_stays_on_the_page(stage, monkeypatch): @@ -142,7 +133,7 @@ def fail(): monkeypatch.setattr(stage, "_pulse", fail) stage.advance() assert stage.status_label.text() == "Generation failed: bad grid" - assert stage.wizard.entered == 0 + assert stage.went == [] def test_generate_with_an_empty_window_asks_to_widen_it(stage): @@ -150,14 +141,13 @@ def test_generate_with_an_empty_window_asks_to_widen_it(stage): stage._wl = (100.0, 150.0) # below every wavelength the grid represents stage.advance() assert "widen the range" in stage.status_label.text() - assert stage.wizard.entered == 0 + assert stage.went == [] assert stage.state.load_data is None def test_generate_hands_the_trace_and_truth_on(stage): - assert stage.next_label() == "Generate →" stage.advance() - assert stage.wizard.entered == 1 + assert stage.went == ["check"] data = stage.state.load_data assert data["trace"].shape == (64, 32) assert data["trace"].max() == pytest.approx(1.0) @@ -165,25 +155,6 @@ def test_generate_hands_the_trace_and_truth_on(stage): assert stage.state.load.interaction == stage.interaction.currentText() -def test_plot_helpers_tolerate_an_empty_field(stage): - grid = Grid(64, dt=1e-15) - zeros = np.zeros(grid.n, dtype=complex) - fig = Figure() - ax = fig.add_subplot() - stage._plot_temporal(ax, grid, zeros) - stage._plot_spectral(fig.add_subplot(), grid, zeros, 2.35e15) - # Nothing above threshold: no zoom, the axis spans the whole window. - lo, hi = ax.get_xlim() - assert lo <= grid.t.min() / 1e-15 and hi >= grid.t.max() / 1e-15 - spec_ax = fig.add_subplot() - stage._plot_spectrogram(spec_ax, grid, zeros, 2.35e15, n_t=8) - assert spec_ax.get_title() == "Spectrogram" - trace = np.zeros((10, 5)) - frog_ax = fig.add_subplot() - stage._plot_frog(frog_ax, np.linspace(7e-7, 9e-7, 10), np.arange(5.0), trace) - assert frog_ax.get_title() == "Synthetic FROG trace" - - def test_additive_noise_alone(stage): trace = np.linspace(0.0, 1.0, 50).reshape(5, 10) stage.noise_add.set_value(0.05) @@ -191,10 +162,30 @@ def test_additive_noise_alone(stage): assert noisy.min() >= 0.0 and noisy.max() == pytest.approx(1.0) +def test_preview_draws_the_trace_continue_hands_on(stage): + stage.on_enter() + shown = stage.views["trace"].plot + stage.advance() + lam_nm = stage.state.load_data["lam"] / 1e-9 + # The λ window's own samples, not the whole simulation grid, in which the + # signal band was one or two rows of a featureless stripe. + assert shown.data.shape == stage.state.load_data["trace"].shape + assert shown.y_axis[0] == pytest.approx(lam_nm[0]) + assert shown.y_axis[-1] == pytest.approx(lam_nm[-1]) + + +def test_preview_with_an_empty_window_asks_to_widen_it(stage): + stage.interaction.setCurrentText("pg") + stage._wl = (100.0, 150.0) # below every wavelength the grid represents + stage.on_enter() + assert "widen the range" in stage.status_label.text() + assert stage.status_label.state == "warn" + + def test_preview_draws_the_four_panels(stage): stage.on_enter() - assert stage.canvas.rendered - titles = {ax.get_title() for ax in stage.canvas.figure.axes} - assert {"Temporal", "Spectral", "Spectrogram", "Synthetic FROG trace"} <= titles - assert stage.status_label.text().startswith("Adjust parameters") + titles = {view.title.text() for view in stage.views.values()} + assert titles == {"Temporal", "Spectral", "Spectrogram", "Synthetic FROG trace"} + assert stage.views["trace"].plot.data is not None + assert stage.status_label.text().startswith("Adjust the pulse") assert stage._last is not None diff --git a/tests/gui/test_stage_uncertainty.py b/tests/gui/test_stage_uncertainty.py index c23d396..aeddfe2 100644 --- a/tests/gui/test_stage_uncertainty.py +++ b/tests/gui/test_stage_uncertainty.py @@ -2,13 +2,12 @@ from __future__ import annotations -import h5py import numpy as np import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") -from PyQt6.QtCore import QObject, Qt, pyqtSignal +from PySide6.QtCore import QObject, Qt, Signal from croak.forward import maketrace from croak.grid import Grid @@ -98,21 +97,20 @@ def test_controls_write_the_parameters_and_gate_each_other(stage): assert u.interval == "percentile" -def test_on_enter_without_a_result_disables_estimate(stage): +def test_on_enter_without_a_result_asks_for_one(stage): stage.on_enter() - assert not stage.run_btn.isEnabled() assert stage.status_label.text() == "Retrieve a pulse first." def test_on_enter_lists_and_draws_the_current_estimate(stage, retrieval): stage.state.set_result(retrieval) stage.on_enter() - assert stage.run_btn.isEnabled() assert "Choose options" in stage.status_label.text() stage.state.set_uncertainty_result(_uresult("parametric", 0.05e-15)) stage.on_enter() assert stage.est_list.count() == 1 - assert stage.canvas.rendered + shown = [view.title.text() for view in stage.views if not view.isHidden()] + assert shown[1] == "Temporal intensity" and len(shown) == 2 assert stage.status_label.text() == stage.state.uncertainty_result.summary() @@ -175,48 +173,35 @@ def fail(_results): assert stage.status_label.text() == "Combine failed: incompatible bands" -def test_save_needs_an_estimate(stage): - stage._save() - assert stage.status_label.text() == "Run an estimate first." - - -def test_save_cancelled_writes_nothing(stage, retrieval, monkeypatch, tmp_path): +@pytest.mark.parametrize("drawn", [False, True]) +def test_save_extras_writes_the_figure_on_screen(stage, retrieval, tmp_path, drawn): stage.state.set_result(retrieval) stage.state.set_uncertainty_result(_uresult("parametric", 0.05e-15)) - monkeypatch.setattr( - su.QFileDialog, "getExistingDirectory", staticmethod(lambda *a: "") - ) - stage._save() - assert list(tmp_path.iterdir()) == [] + if drawn: + stage._draw(stage.state.uncertainty_result) + expected = ["uncertainty.pdf"] if drawn else [] + assert stage.save_extras(tmp_path) == expected + assert (tmp_path / "uncertainty.pdf").exists() == drawn -@pytest.mark.parametrize("drawn", [False, True]) -def test_save_writes_result_options_and_the_figure_if_drawn( - stage, retrieval, monkeypatch, tmp_path, drawn +def test_save_extras_skips_an_estimate_a_new_retrieval_cleared( + stage, retrieval, tmp_path ): + """Regression: the figure drawn before a new retrieval is not saved with it.""" stage.state.set_result(retrieval) stage.state.set_uncertainty_result(_uresult("parametric", 0.05e-15)) - if drawn: - stage._draw(stage.state.uncertainty_result) - monkeypatch.setattr( - su.QFileDialog, - "getExistingDirectory", - staticmethod(lambda *a: str(tmp_path)), - ) - stage._save() - assert (tmp_path / "options.toml").exists() - with h5py.File(tmp_path / "result.h5", "r") as f: - assert len(f.keys()) > 0 - assert (tmp_path / "uncertainty.pdf").exists() == drawn - assert stage.status_label.text().startswith("Saved result.h5") + stage._draw(stage.state.uncertainty_result) + stage.state.set_result(retrieval) # a new retrieval clears the estimates + assert stage.save_extras(tmp_path) == [] + assert not (tmp_path / "uncertainty.pdf").exists() class _FakeWorker(QObject): """Stands in for UncertaintyWorker: emits what the test scripts on start().""" - progress = pyqtSignal(int, int, float) - finished_ok = pyqtSignal(object) - failed = pyqtSignal(str) + progress = Signal(int, int, float) + finished_ok = Signal(object) + failed = Signal(str) script: list = [] instances: list = [] @@ -260,7 +245,7 @@ def test_run_reports_progress_and_shows_the_estimate(stage, retrieval, fake_work assert w.kwargs["dispersion"] is stage.state.dispersion assert "replicate 1/4: R = 1.2300%" in statuses assert stage.state.uncertainty_results["parametric"] is est - assert stage.run_btn.isEnabled() and not stage.stop_btn.isEnabled() + assert not stage.busy() assert stage.status_label.text() == est.summary() # Stop forwards to the worker. stage._stop() @@ -280,7 +265,7 @@ def test_run_failure_and_stop_messages(stage, retrieval, fake_worker, message, s fake_worker.script = [("failed", message)] stage._run() assert stage.status_label.text() == status - assert stage.run_btn.isEnabled() and not stage.stop_btn.isEnabled() + assert not stage.busy() def test_stop_without_a_run_is_harmless(stage): diff --git a/tests/gui/test_state.py b/tests/gui/test_state.py index a8d8b93..9e54d81 100644 --- a/tests/gui/test_state.py +++ b/tests/gui/test_state.py @@ -1,10 +1,10 @@ -"""WizardState properties and stage navigation.""" +"""WizardState properties.""" from __future__ import annotations import pytest -pytest.importorskip("PyQt6") +pytest.importorskip("PySide6") from croak.gui.state import WizardState @@ -14,14 +14,3 @@ def test_geometry_and_truth_come_from_the_load(): assert state.geometry is None and state.truth is None state.load_data = {"geometry": "g", "truth": "t"} assert state.geometry == "g" and state.truth == "t" - - -def test_stage_setter_clamps_and_emits_only_on_change(qtbot): - state = WizardState() - seen = [] - state.stage_changed.connect(seen.append) - state.stage = 1 # unchanged: no signal - state.stage = 99 # clamped to the last stage - state.stage = 6 # unchanged again - assert seen == [6] - assert state.stage == 6 diff --git a/tests/gui/test_theme.py b/tests/gui/test_theme.py new file mode 100644 index 0000000..b8f88d3 --- /dev/null +++ b/tests/gui/test_theme.py @@ -0,0 +1,86 @@ +"""The window's look: palette, style sheet, fonts and styled widgets.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") + +from PySide6.QtGui import QFontDatabase +from PySide6.QtWidgets import QApplication, QWidget + +from croak.gui import theme + + +def test_theme_stylesheet_is_built_from_the_palette(): + sheet = theme.stylesheet() + assert theme.PALETTE.accent in sheet + assert "QPushButton#primary" in sheet + # Every token reaches the sheet only through the palette: a new palette + # recolours every rule that used the old value. + recoloured = theme.stylesheet(theme.Palette(accent="#123456")) + assert "#123456" in recoloured + assert theme.PALETTE.accent not in recoloured + # The combo chevron points at a file that ships with the package. + assert (theme.ASSETS / "chevron.svg").is_file() + + +def test_theme_fonts_load_and_become_the_application_font(qapp): + assert theme.load_fonts() + assert QApplication.font().family() == theme.FONT_FAMILY + assert theme.FONT_FAMILY in QFontDatabase.families() + assert (theme.ASSETS / "fonts" / "LICENSE.txt").is_file() + + +@pytest.mark.parametrize("found", [[], [0, -1]]) +def test_theme_missing_fonts_fall_back_to_the_system_font( + qapp, monkeypatch, tmp_path, caplog, found +): + fonts = tmp_path / "fonts" + fonts.mkdir() + for index in range(len(found)): + (fonts / f"f{index}.ttf").write_bytes(b"") + ids = iter(found) + monkeypatch.setattr(theme, "ASSETS", tmp_path) + monkeypatch.setattr( + theme.QFontDatabase, "addApplicationFont", lambda _path: next(ids) + ) + before = QApplication.font() + with caplog.at_level("WARNING", logger="croak.gui.theme"): + assert not theme.load_fonts() + assert "system font" in caplog.text + assert QApplication.font() == before + + +def test_theme_set_flag_restyles_only_on_change(qtbot, monkeypatch): + widget = QWidget() + qtbot.addWidget(widget) + calls = [] + monkeypatch.setattr(theme, "repolish", calls.append) + theme.set_flag(widget, "current", True) + theme.set_flag(widget, "current", True) + assert widget.property("current") is True + assert calls == [widget] + + +def test_theme_repolish_reapplies_the_style(qtbot): + widget = QWidget() + qtbot.addWidget(widget) + widget.setStyleSheet(theme.stylesheet()) + theme.repolish(widget) # must not raise on a styled widget + + +def test_theme_builders_set_the_role(qtbot): + heading = theme.label("Hello", "h1") + plain = theme.label("text", wrap=True) + primary = theme.button("Go", "primary") + plain_button = theme.button("Open") + box = theme.frame("form") + for widget in (heading, plain, primary, plain_button, box): + qtbot.addWidget(widget) + assert heading.objectName() == "h1" + assert plain.objectName() == "" + assert plain.wordWrap() + assert primary.objectName() == "primary" + assert plain_button.objectName() == "plain" + assert box.objectName() == "form" diff --git a/tests/gui/test_view_panel.py b/tests/gui/test_view_panel.py new file mode 100644 index 0000000..21bd6d4 --- /dev/null +++ b/tests/gui/test_view_panel.py @@ -0,0 +1,145 @@ +"""A plot's way out of the window: figures and images (:mod:`croak.gui.view_panel`).""" + +from __future__ import annotations + +import numpy as np +import pytest + +pytest.importorskip("PySide6") + +from PySide6.QtCore import QPoint +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QGridLayout, + QMainWindow, + QWidget, +) + +from croak.gui.view_panel import ViewPanel, export_figure, page_figure +from croak.plotting.screen import CurveView, LineView, MapView + +X = np.linspace(0.0, 1.0, 8) +LINE = LineView( + curves=(CurveView(X, X, "retrieved"),), + title="Temporal", + xlabel="Time (fs)", + ylabel="Intensity", +) +MAP = MapView( + values=np.outer(X, X), + x=X, + y=X, + levels=(0.0, 1.0), + title="Measured trace", + xlabel="Delay (fs)", + ylabel="Wavelength (nm)", +) + + +def _save_to(monkeypatch, path): + monkeypatch.setattr(QFileDialog, "getSaveFileName", lambda *a, **k: (path, "")) + + +@pytest.fixture +def window(qtbot): + """A main window, so a panel's notices have a status bar to go to.""" + window = QMainWindow() + qtbot.addWidget(window) + window.resize(800, 600) + return window + + +def test_view_panel_menu_offers_its_figure_once_it_shows_a_view(qtbot): + panel = ViewPanel("map", colours="magma", white=False) + qtbot.addWidget(panel) + assert panel.figure() is None + menu = panel.show_menu(QPoint(5, 5)) + actions = {a.text(): a for a in menu.actions() if a.text()} + assert not actions["Export this plot as a figure…"].isEnabled() + assert set(actions) == { + "Export this plot as a figure…", + "Save as an image…", + "Copy as an image", + } + panel.show_view(MAP) + figure = panel.figure() + assert figure is not None + mesh = figure.axes[0].collections[0] + assert mesh.cmap(0.0)[:3] != (1.0, 1.0, 1.0) # the panel's own map, not white + panel.say("Residuals", "No residual for this result.") + assert panel.figure() is None + + +def test_view_panel_right_click_opens_its_menu(qtbot, monkeypatch): + panel = ViewPanel("line") + qtbot.addWidget(panel) + opened = [] + monkeypatch.setattr(panel, "show_menu", opened.append) + panel.plot.customContextMenuRequested.emit(QPoint(3, 4)) + assert opened == [QPoint(3, 4)] + + +def test_view_panel_exports_its_figure_and_says_so(window, monkeypatch, tmp_path): + panel = ViewPanel("line") + window.setCentralWidget(panel) + panel.show_view(LINE) + _save_to(monkeypatch, "") + assert panel.export_figure() == "" # cancelled + target = tmp_path / "temporal.svg" + _save_to(monkeypatch, str(target)) + assert panel.export_figure() == str(target) + assert "