diff --git a/.gitea/workflows/debian-build.yml b/.gitea/workflows/debian-build.yml index 65bf425..c39f6a8 100644 --- a/.gitea/workflows/debian-build.yml +++ b/.gitea/workflows/debian-build.yml @@ -2,8 +2,7 @@ name: Build Debian Packages on: push: - branches: [ "main" ] - pull_request: + tags: [ "v*" ] # releases are tag-driven now workflow_dispatch: jobs: @@ -16,14 +15,14 @@ jobs: codename: bookworm - target: debian13 codename: trixie - + runs-on: ${{ matrix.target }} steps: - name: Install Node.js and git run: | apt-get update - apt-get -y install nodejs git lsb-release + apt-get -y install nodejs git lsb-release jq - name: Checkout repository uses: actions/checkout@v3 @@ -31,20 +30,61 @@ jobs: github-server-url: https://gitea.pkm.physik.tu-darmstadt.de - name: Install build dependencies - run: | - bash requirements.debian + run: bash requirements.debian - name: Build Package - run: | - dpkg-buildpackage -us -uc -b + run: dpkg-buildpackage -us -uc -b - name: Collect Artifacts run: | mkdir -p artifacts mv -v ../*.deb artifacts/ - - name: Upload Artifacts - uses: actions/upload-artifact@v3 - with: - name: python3-damaris-${{ matrix.codename }}.gz - path: artifacts/*.deb + - name: Publish release with assets + env: + GITEA_TOKEN: ${{ secrets.DEB_UPLOAD_TOKEN }} + SLUG: ${{ gitea.repository }} # owner/repo + TAG: ${{ gitea.ref_name }} # e.g. v1.2.3 + CODENAME: ${{ matrix.codename }} + run: | + set -euo pipefail + API="https://gitea.pkm.physik.tu-darmstadt.de/api/v1" + AUTH="Authorization: token ${GITEA_TOKEN}" + + # 1. Find an existing release for this tag, if any + release_id=$(curl -s -H "$AUTH" \ + "$API/repos/$SLUG/releases/tags/$TAG" | jq -r '.id // empty') + + # 2. Create the release if it does not exist yet + if [ -z "$release_id" ]; then + echo "Creating release $TAG" + release_id=$(curl -s -X POST -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg tag "$TAG" \ + '{tag_name:$tag, name:$tag, draft:false, prerelease:false}')" \ + "$API/repos/$SLUG/releases" | jq -r '.id') + else + echo "Reusing existing release $TAG (id=$release_id)" + fi + echo "release_id=$release_id" + + # 3. Upload each .deb as an asset + for deb in artifacts/*.deb; do + asset="${CODENAME}_$(basename "$deb")" # avoid bookworm/trixie name clash + echo "Uploading $asset" + + # delete any existing asset with the same name (idempotent re-runs) + existing=$(curl -s -H "$AUTH" \ + "$API/repos/$SLUG/releases/$release_id/assets" \ + | jq -r --arg n "$asset" '.[] | select(.name==$n) | .id') + if [ -n "$existing" ]; then + curl -s -X DELETE -H "$AUTH" \ + "$API/repos/$SLUG/releases/$release_id/assets/$existing" + fi + + curl --fail --show-error --silent \ + -X POST -H "$AUTH" \ + -F "attachment=@$deb;filename=$asset" \ + "$API/repos/$SLUG/releases/$release_id/assets" + done + diff --git a/.gitignore b/.gitignore index 9be833f..70043b9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ debian/.debhelper # Sphinx documentation doc/_build/ +doc/_autosummary/ +doc/damaris/ # Debian build artifacts debian/*.debhelper.log diff --git a/AGENTS.md b/AGENTS.md index 2c7f68f..68548b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,16 +4,17 @@ This is the front end part of GUI for controlling NMR spectrometers. On "Start", the experiment script executes and creates XML job files. A backend, not in this code, executes these jobs sequentially adn writes the results as XML files. The front end consumes these result files using the result script. ## Guidelines -1. Use the .venv python3 environment -5. Old code was ported from gtk2 and python2.7. This code has to work on python 3.11 and above, as well as GTK3 -2. Be aware the targeted Debian versions are bookworm, trixie, and forky. -3. Only use packages and libraries available in Debian bookworm, trixie, and forky. Make sure that the code is compatible with all of these versions. -3. Do not commit changes (with git) or write commit messages without asking the user. -4. Try to minimize code changes, do not use elaborate checks for defensive programming. Ask the user if something should be tested with unit tests. -6. Use the python unittest module for unit tests, not pytest -7. Functions for the experiment script belong to the src/damaris/experiments/Experiment.py file -8. Functions for the result script belong in the files in the src/damaris/data/ folder -9. The gitea workflow can be found at .gitea/workflows/debian-build.yml -10. to not edit anything in "build", it is regenerated -11. Use sphinx for documentation, autodoc and autosummary is used for references of all classes, functions and modules. -12. If a new function/method is created document a short usage the doc/api_reference.rst and a longer explanation in doc/manual.rst +1. Prefer the system python and notify the user if libraries and packages are missing. Use uv for the python3 environment only if system packages are missing and the user does not want to install the needed packages. +2. Old code was ported from gtk2 and python2.7. This code has to work on python 3.11 and above, as well as GTK3 +3. Be aware the targeted Debian versions are bookworm, trixie, and forky. +4. Only use packages and libraries available in Debian bookworm, trixie, and forky. Make sure that the code is compatible with all of these versions. +5. Do not commit changes (with git) or write commit messages without asking the user. +6. Try to minimize code changes, do not use elaborate checks for defensive programming. Ask the user if something should be tested with unit tests. +7. Use the python unittest module for unit tests, not pytest +8. Functions for the experiment script belong to the src/damaris/experiments/Experiment.py file +9. Functions for the result script belong in the files in the src/damaris/data/ folder +10. The gitea workflow can be found at .gitea/workflows/debian-build.yml +11. Do not edit anything in "build", it is regenerated +12. Use sphinx for documentation, autodoc and autosummary is used for references of all classes, functions and modules. +13. If a new function/method is created document a short usage in doc/api_reference.rst and a longer explanation in doc/manual.rst +14. Do not use the Makefile in ./doc/, Doxygen is not used anymore. diff --git a/DEVELOPER.md b/DEVELOPER.md index f8eab4a..85c08b9 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -3,3 +3,43 @@ ## Version bump Version is changed in src/damaris/__init__.py + +### Process + +1. Update the version in `src/damaris/__init__.py` +2. Add a new changelog entry in `debian/changelog` using the `dch` (debchange) tool + +### Using dch tool + +The `dch` tool (from the `devscripts` package) is the standard way to maintain debian changelog files. Example workflow: + +```bash +# Update version in Python package +sed -i 's/__version__ = "0.24"/__version__ = "0.25"/' src/damaris/__init__.py + +# Add changelog entry with dch +dch --newversion 0.25 + +# This opens your editor to add the changes for this version +# Or use batch mode with message: +dch -v 0.25 "New features and bug fixes" + +# Verify the changelog +tail -20 debian/changelog +``` + +The `dch` command will: +- Create a new entry with the specified version +- Set the distribution to "stable" (or "unstable" for development versions) +- Use the maintainer name and email from the previous entry or environment variables +- Open your editor to add the change descriptions + +For development versions, use: +```bash +dch -v 0.25-1 --distribution unstable "Development version" +``` + +Remember to install devscripts if needed: +```bash +sudo apt-get install devscripts +``` diff --git a/debian/changelog b/debian/changelog index 78d83fc..e51ce4e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,32 @@ +python3-damaris (0.25) stable; urgency=medium + + * fixed non working stop button + * added type hints to data classes, fixed get_ydata override conflict + * fixed y_square latent bug: kept always a list of per-channel arrays + * fixed time_period latent crashes: missing time information treated as None + * replaced remaining gtk2/PyGTK and Python 2 idioms in DamarisGUI + * fixed startup crash: register GtkSourceView GType for GtkBuilder + * fixed bug with counter updates + * FitResult fit overlays, leaked HDF5 files closed after experiment stop + * HTML documentation is built at package build time and opened from the Help menu + * crosshair cursor is fast on HiDPI screens (dirty-strip blitting) + + -- Markus Rosenstihl Sun, 18 Oct 2026 19:13:20 +0200 + +python3-damaris (0.24) stable; urgency=medium + + * fixed log dump race condition, no more crash at experiment end + * data pool HDF5 file is now always closed properly + * Signalpath.phase() no longer modifies data in place, returns object for phase=0 (fixes #43) + * fixed inconsistencies in returned objects: missing channels, is_clipped + * fixed accumulation lock leaks and deprecated API usage + * user plugin directory ~/.config/damaris/plugin added to Python path + * all output switched to logging module, experiment and result script prints show up in the GUI + * command line interface fixed and properly added to the deb package + * performance: incremental writing of HDF5 data pool, reduced polling in synchronize() + + -- Markus Rosenstihl Sun, 18 Oct 2026 11:39:54 +0200 + python3-damaris (0.22) stable; urgency=medium * fixed autocomplete in the editor tabs diff --git a/debian/control b/debian/control index dfd09e4..8d04745 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: python3-damaris Section: science Priority: optional Maintainer: Markus Rosenstihl -Build-Depends: python3, debhelper, python3-dev, dh-sequence-python3, dpkg-dev, pybuild-plugin-pyproject, sphinx-common, sphinx-rtd-theme-common, python3-sphinxcontrib.bibtex, python3-sphinx-rtd-theme +Build-Depends: python3, debhelper, python3-dev, dh-sequence-python3, dpkg-dev, pybuild-plugin-pyproject, sphinx-common, python3-sphinx, sphinx-rtd-theme-common, python3-sphinxcontrib.bibtex, python3-sphinx-rtd-theme Standards-Version: 3.7.3 X-Python3-Version: >= 3.5 Rules-Requires-Root: no @@ -10,7 +10,7 @@ Rules-Requires-Root: no Package: python3-damaris Architecture: all Provides: damaris-frontend-py3 -Depends: ${python3:Depends}, python3-numpy, python3-scipy, python3-gi, python3-matplotlib, python3-tables, lzop, python3-xdg, libgtksourceview-3.0-1, libgirepository1.0-dev | libgirepository-2.0-dev, breeze-icon-theme +Depends: ${python3:Depends}, ${sphinxdoc:Depends}, python3-numpy, python3-scipy, python3-gi, python3-matplotlib, python3-tables, lzop, python3-xdg, libgtksourceview-3.0-1, libgirepository1.0-dev | libgirepository-2.0-dev, breeze-icon-theme Recommends: damaris-backends, python3-numpy-ext Suggests: python3-doc, python3-tables-doc, python3-numpy-doc Description: python frontend for DAMARIS project diff --git a/debian/install b/debian/install index 0213823..74eef99 100644 --- a/debian/install +++ b/debian/install @@ -1,4 +1,4 @@ -doc/ usr/share/doc/python3-damaris/ +build/html usr/share/doc/python3-damaris/ debian/damaris3.desktop usr/share/applications/ src/damaris/gui/DAMARIS3.png usr/share/pixmaps/ src/damaris/gui/DAMARIS3.ico usr/share/pixmaps/ diff --git a/debian/rules b/debian/rules index 9f25e53..0d881ec 100755 --- a/debian/rules +++ b/debian/rules @@ -17,7 +17,7 @@ DH_ALWAYS_EXCLUDE=CVS:.git:venv:.venv:*.h5 override_dh_auto_build: dh_auto_build - sphinx-build -N -bhtml doc/ build/doc + sphinx-build -N -bhtml doc/ build/html override_dh_auto_test: diff --git a/doc/api_reference.rst b/doc/api_reference.rst index fae80bb..169b8cc 100644 --- a/doc/api_reference.rst +++ b/doc/api_reference.rst @@ -26,11 +26,13 @@ The data module contains result types, data handling, and signal processing clas damaris.data.ADC_Result damaris.data.Accumulation + damaris.data.Config_Result damaris.data.DamarisFFT damaris.data.DataPool damaris.data.Drawable damaris.data.Errorable damaris.data.Error_Result + damaris.data.FitResult damaris.data.MeasurementResult damaris.data.Persistance damaris.data.Resultable @@ -71,6 +73,20 @@ The GUI module contains the graphical interface, backend driver, and experiment/ damaris.gui.TemperatureMonitor damaris.gui.logging_utils +A short usage example for the font helper of the GUI module (the non-deprecated +replacement for the removed ``Widget.modify_font()``): + +.. code-block:: python + + from damaris.gui.DamarisGUI import apply_font_css + + apply_font_css(textview, "Monospace 10") # pango style font description + apply_font_css(label, "sans bold 16") + +The font string is converted into a CSS rule (``font-family``/``font-size``/ +``font-weight``) which is applied to the widget's style context with a +``Gtk.CssProvider``. + Tools Module ============ diff --git a/doc/devel.rst b/doc/devel.rst index 389628e..b4944bb 100644 --- a/doc/devel.rst +++ b/doc/devel.rst @@ -184,3 +184,57 @@ Stability of the DAC has been tested by measuring the shifts of resonance freque The offset of the PFG amplifier was adjusted via the offset potentiometer on the DAC board. There is also an offset adjustment possible at the PFG amplifier directly. For adjustment, the signal of water in a sample tube with a 0.5 mm teflon stripe perpendicular to the magnetic field B0 was recorded. First, the PFG power supply has been switched off and the sample was shimmed with the room temperature shims until the longest decay time T*2 of the FID has been achieved. After this, the PFG was switched on and the offset has been adjusted to cause least distortion in the spectrum. +******************* +Publish new version +******************* + +.. warning:: + + ``__version__`` in ``src/damaris/__init__.py`` and the top entry of + ``debian/changelog`` must carry the same version number at every + release. If they drift apart, the Debian package version no longer + matches the application version. + +1. Commit all code changes intended for the release. + +2. Bump ``__version__`` in ``src/damaris/__init__.py``. This constant + is the single source of truth for the application version and + follows the simple ``X.Y`` scheme used so far (a minor release + increments the second number, e.g. 0.22 -> 0.23). + +3. Add a new entry at the top of ``debian/changelog`` with the same + version number and a bullet list of the user visible changes. The + Debian package version does not follow ``__version__`` + automatically; it is defined solely by this file. Either edit + ``debian/changelog`` directly or run ``dch`` (from the + ``devscripts`` package), which prepends the entry for you. Match + the entry format already used in this file: + + .. code-block:: text + + python3-damaris (X.Y) stable; urgency=medium + + * bullet list of user visible changes + + -- Full Name Day, DD Mon YYYY HH:MM:SS +0200 + + The maintainer line at the end is indented by two spaces and its + date must be in RFC-2822 format, which ``date -R`` produces. + +4. Commit the version bump and the changelog entry together in one + commit. The repository currently carries no version tags, so no + tagging step is defined; confirm with ``git tag -l`` before + introducing one. + +5. Everything else picks up the new version automatically. The Sphinx + documentation reads ``damaris.__version__`` in ``doc/conf.py``, and + ``pyproject.toml`` declares the package version dynamically via + ``[tool.setuptools.dynamic]`` with ``attr = "damaris.__version__"``, + so neither needs a manual edit. On a push to ``main`` the Debian CI + pipeline ``.gitea/workflows/debian-build.yml`` builds the package + with ``dpkg-buildpackage`` for bookworm and trixie; the version of + the resulting ``.deb`` is taken from ``debian/changelog``. + +``DEVELOPER.md`` at the repository root keeps a short note about the +version bump in ``src/damaris/__init__.py``; this section expands it. + diff --git a/doc/manual.rst b/doc/manual.rst index 73ce857..a9d0ed7 100644 --- a/doc/manual.rst +++ b/doc/manual.rst @@ -101,6 +101,12 @@ There are now two possibilities to export the figure: 2. Saving the data of the plot as CSV file (comma separated values) by pressing the button on the right side of the toolbar under the plot +Help Menu +========= + +The **Help** button in the toolbar opens this documentation in the system's web browser. The arrow next to it offers additional local documentations, such as the Python documentation, if installed. When the Debian package is used, the HTML documentation is found in ``/usr/share/doc/python3-damaris/html/``. + + Accumulation ============ @@ -285,6 +291,35 @@ A typical method to already extract data like amplitudes etc. is possible by usi Essentially, a dictionary "measurement" is created with *tau* as the key. For each new key the current magnetization is added. This dictionary is then added to the data pool. The advantage over the previous listing is that the magnetization vs. τ curve can be directly displayed in order to gain an overview of the running experiment, because the "measurement" dictionary is added to the data pool. In this example, the value also stores statistics from the accumulation. The data dictionary is stored automatically as described before, including the result and experiment scripts, information about the used back end and a timeline of the experiment. +**Displaying fits** + +A fit to a measurement can be shown as an overlay on the measurement curve using the FitResult class. In contrast to the measurement itself, a fit has its own x-values, independent of the measured data points. Therefore the fit can be sampled on a much denser x-grid, which makes the plotted curve smooth. The fit is stored in the data pool under its own key and is drawn by the GUI on top of the measurement it belongs to. Fit parameters are shown in the plot and are stored together with the fit. + +The fit is associated with its measurement in two ways: by the ``source`` argument (the data pool key of the measurement) and, optionally, by storing it under a nested key such as ``"Magnetization(tau)/fit"``, so that it is stored next to the measurement in the HDF5 data pool file. The GUI shows the fit only as an overlay on its measurement; fits do not get their own entry in the display tree. + +.. code-block:: python + :linenos: + + import numpy + from damaris import FitResult + + def result(results): + # ... fill "measurement" as in the listing above ... + # fit an exponential decay to the measured points + from scipy.optimize import curve_fit + [k, v, e] = measurement.get_errorplotdata() + popt, pcov = curve_fit(lambda t, m0, t1: m0 * numpy.exp(-t / t1), + k, v, p0=(v[0], 1.0), sigma=e) + # sample the fit on a dense x-grid for a smooth curve + xfit = numpy.linspace(k.min(), k.max(), 200) + fit = FitResult(xfit, popt[0] * numpy.exp(-xfit / popt[1]), + title="Magnetization(tau) fit", + source="Magnetization(tau)", + params={"M0": popt[0], "T1": popt[1]}) + data["Magnetization(tau)/fit"] = fit + +The fit is written to the HDF5 data pool file as its own table (columns *x* and *y*, plus *y_err* if given), together with the fit parameters as attributes, and is restored when the data pool is read back. + Note that we can also create a HDF5 file from scratch! In this case experiment and result script are not saved in the resulting file. The advantage is that one can create arbitrary complex structured files at the cost of having a more complicated result script. .. code-block:: python @@ -313,6 +348,10 @@ The change is *in-place*, which means if the timesignal is needed later on, a co - **clip(start=**\ *None* **, stop=**\ *None* **)** Only the data between start and stop is returned. The start and stop parameters can be either in time or frequency domain. + Calling **clip()** without any argument is a placeholder that leaves the + object unchanged (useful in scripted processing chains). If only one bound + is given, the missing one defaults to the first resp. last data point and + reversed bounds (start > stop) are swapped automatically. - **baseline(lastpart=**\ *0.1* **)** This method should be applied first as it corrects the baseline of the signal, taking the last part of the signal for determining the correction. @@ -374,6 +413,12 @@ To use this methods one has to apply consecutively the methods to an *timesignal This applies baseline correction using the last 40% of the timesignal for determination of the signal. Then, an exponential window is applied. From this timesignal an FFT is calculated which is further clipped to view to the data between -1 kHz with 1 kHz width. +Operators and Copies of Data Objects +==================================== + +The arithmetic operators (``+``, ``-``, ``*``, ``/``, ``//``, ``**``, unary ``-``) of **ADC_Result** and **Accumulation** objects return *new* objects: the operands are never modified and the data arrays of the result are independent copies, so later in-place operations (``+=``, ``*=``, ...) on the result cannot corrupt the operands. The channel metadata is carried over to the result: an **ADC_Result** result keeps the same ``get_nChannels()`` and ``is_clipped`` flag as its operand, and an **Accumulation** result keeps (and merges) the ``job_ids`` provenance of both operands. Adding an unsupported type or negating an empty accumulation raises a ``ValueError`` instead of silently returning *None*. In the same spirit, ``Config_Result(config=..., desc=...)`` stores the given dictionaries in the constructor, and ``measurementresult + 0`` returns an independent deep copy of the result object. + + Good Practices ============== diff --git a/pyproject-bookworm.toml b/pyproject-bookworm.toml index 1a57d51..ec3a033 100644 --- a/pyproject-bookworm.toml +++ b/pyproject-bookworm.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python3-damaris" -version = "0.22" +version = "0.25" requires-python = ">= 3.11" description = "Python 3 frontend for DAMARIS (DArmstadt MAgnetic Resonance Instrument Software)" license = {text = "GPL-2.0"} diff --git a/pyproject.toml b/pyproject.toml index 55fcbcb..01f6231 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,9 @@ maintainers = [ [project.scripts] DAMARIS3 = "damaris.__main__:main" +[dependency-groups] +dev = ["pyrefly"] + [tool.setuptools.packages.find] where = ["src"] @@ -50,6 +53,18 @@ version = {attr = "damaris.__version__"} [tool.setuptools.data-files] "share/python3-damaris/images" = ["src/damaris/gui/DAMARIS3.png", "src/damaris/gui/DAMARIS3.ico"] + +[tool.pyrefly] +project-includes = [ + "src/damaris/experiments/Experiment.py", + "src/damaris/experiments/dac.py", + "src/damaris/data/ADC_Result.py", + "src/damaris/data/MeasurementResult.py", + "src/damaris/data/Accumulation.py", +] +search-path = ["src"] +python-version = "3.11" +python-platform = "linux" #"share/python3-damaris/doc" = ["doc/index.html"] #"share/python3-damaris/doc/reference-html" = ["doc/reference-html/*"] #"share/python3-damaris/doc/tutorial-html" = ["doc/tutorial-html/*"] diff --git a/requirements.debian b/requirements.debian index 9cbbe4b..35763e4 100644 --- a/requirements.debian +++ b/requirements.debian @@ -1,7 +1,7 @@ #!/bin/bash apt-get -y install libcairo2-dev gir1.2-gtksource-3.0 hdf5-tools apt-get -y install python3-dev python3-scipy python3-lmfit python3-matplotlib python3-tables python3-xdg python3-setuptools python3-sphinx-rtd-theme -apt-get -y install debhelper dh-sequence-python3 lsb-release sphinx-common pybuild-plugin-pyproject python3-sphinxcontrib.bibtex +apt-get -y install debhelper dh-sequence-python3 lsb-release sphinx-common python3-sphinx pybuild-plugin-pyproject python3-sphinxcontrib.bibtex DEBRELEASE=$(lsb_release -cs) case $DEBRELEASE in "trixie") diff --git a/src/damaris/__init__.py b/src/damaris/__init__.py index 869b9c5..0e79c1b 100644 --- a/src/damaris/__init__.py +++ b/src/damaris/__init__.py @@ -15,7 +15,7 @@ ## module contents # -__version__ = "0.23" +__version__ = "0.25" __all__=["experiments", "data", "gui"] import sys diff --git a/src/damaris/data/ADC_Result.py b/src/damaris/data/ADC_Result.py index a709729..5f3e178 100644 --- a/src/damaris/data/ADC_Result.py +++ b/src/damaris/data/ADC_Result.py @@ -1,5 +1,7 @@ # -*- coding: iso-8859-1 -*- +from __future__ import annotations + from .Resultable import Resultable from .Drawable import Drawable from .Signalpath import Signalpath @@ -9,7 +11,13 @@ import numpy import sys import datetime import tables +from typing import IO from scipy.signal import filtfilt, remez + +_XArray = numpy.ndarray | list +_YChannels = list[numpy.ndarray] +_IndexList = list[tuple[int, int]] +_Description = dict[str, str] ############################################################################# # # # Name: Class ADC_Result # @@ -39,7 +47,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): sampling_rate: Sampling frequency of the ADC data. job_id: identifier for the job that generated this result. """ - def __init__(self, x = None, y:list = None, index = None, sampl_freq = None, desc = None, job_id = None, job_date = None): + def __init__(self, x: _XArray | None = None, y: _YChannels | None = None, index: _IndexList | None = None, sampl_freq: float | None = None, desc: _Description | None = None, job_id: int | None = None, job_date: datetime.datetime | None = None): Resultable.__init__(self) Drawable.__init__(self) @@ -59,7 +67,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): self.cont_data = False self.sampling_rate = 0 self.index = [] - self.x = [] + self.x = numpy.array([], dtype="float32") self.y = [] # using all arguments for initialization elif (x is not None) and (y is not None) and (index is not None) and (sampl_freq is not None) and (desc is not None) and (job_id is not None) and (job_date is not None): @@ -67,8 +75,8 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): #for ch in y: # if not numpy.issubdtype(ch.dtype, numpy.integer): # raise TypeError("TypeError: ADC_Result y data must be a list with integer type channels") - self.x = x - self.y = y + self.x = numpy.asarray(x) + self.y = [numpy.asarray(ch) for ch in y] self.index = index self.sampling_rate = sampl_freq self.cont_data = True @@ -87,7 +95,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): raise ValueError("Wrong usage of __init__!") - def create_data_space(self, channels, samples): + def create_data_space(self, channels: int, samples: int) -> None: "Initialises the internal data-structures" if self.contains_data(): @@ -104,12 +112,12 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): self.cont_data = True - def contains_data(self): + def contains_data(self) -> bool: """Returns true if ADC_Result contains data. (-> create_data_space() was called)""" return self.cont_data - def check_clipping(self, bit_depth=None): + def check_clipping(self, bit_depth: int | None = None) -> bool: """ Check if any data in y is clipped. For a 14-bit ADC, clipping occurs at 2**13 (8192) or 2**13-1 (8191). @@ -147,7 +155,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): return self.is_clipped - def lowpass(self, cutoff, numtaps=None): + def lowpass(self, cutoff: float, numtaps: int | None = None) -> ADC_Result: """ Apply a zero-phase lowpass FIR filter to all channels. @@ -232,15 +240,14 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): job_id=self.job_id, job_date=self.job_date, ) - r.nChannels = self.nChannels - r.is_clipped = self.is_clipped + self._copy_metadata(r) finally: self.lock.release() return r - def add_sample_space(self, samples): + def add_sample_space(self, samples: int) -> None: "Adds space for n samples, where n can also be negative (deletes space). New space is filled up with \"0\"" self.lock.acquire() @@ -260,7 +267,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): self.lock.release() - def get_result_by_index(self, index): + def get_result_by_index(self, index: int) -> ADC_Result: self.lock.acquire() try: @@ -277,39 +284,38 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): tmp_y.append(self.y[i][start:end+1].copy()) r = ADC_Result(x = tmp_x, y = tmp_y, index = [(0,len(tmp_y[0])-1)], sampl_freq = self.sampling_rate, desc = self.description.copy() if self.description is not None else None, job_id = self.job_id, job_date = self.job_date) - r.nChannels = self.nChannels - r.is_clipped = self.is_clipped + self._copy_metadata(r) self.lock.release() return r - def get_sampling_rate(self): + def get_sampling_rate(self) -> float: """Returns the samplingfrequency""" return self.sampling_rate + 0 - def set_sampling_rate(self, hz): + def set_sampling_rate(self, hz: float) -> None: """Sets the samplingfrequency in hz""" self.sampling_rate = float(hz) - def get_nChannels(self): + def get_nChannels(self) -> int: """Gets the number of channels""" return self.nChannels + 0 - def set_nChannels(self, channels): + def set_nChannels(self, channels: int) -> None: """Sets the number of channels""" self.nChannels = int(channels) - def get_index_bounds(self, index): + def get_index_bounds(self, index: int) -> tuple[int, int]: "Returns a tuple with (start, end) of the wanted result" return self.index[index] - def uses_statistics(self): + def uses_statistics(self) -> bool: return False - def write_to_csv(self, destination=sys.stdout, delimiter=" "): + def write_to_csv(self, destination: str | IO[str] = sys.stdout, delimiter: str = " ") -> None: """ writes the data to a file or to sys.stdout destination can be a file or a filename @@ -321,7 +327,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): else: self._write_to_csv_dest(destination, delimiter) - def _write_to_csv_dest(self, dest, delimiter): + def _write_to_csv_dest(self, dest: IO[str], delimiter: str) -> None: dest.write("# adc_result\n") dest.write("# t y0 y1 ...\n") self.lock.acquire() @@ -337,7 +343,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): finally: self.lock.release() - def write_to_simpson(self, destination=sys.stdout, delimiter=" "): + def write_to_simpson(self, destination: str | IO[str] = sys.stdout, delimiter: str = " ") -> None: """ writes the data to a text file or sys.stdout in Simpson format, for further processing with the NMRnotebook software; @@ -349,7 +355,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): else: self._write_to_simpson_dest(destination, delimiter) - def _write_to_simpson_dest(self, dest, delimiter): + def _write_to_simpson_dest(self, dest: IO[str], delimiter: str) -> None: self.lock.acquire() try: xdata=self.get_xdata() @@ -368,7 +374,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): finally: self.lock.release() - def write_to_hdf(self, hdffile, where, name, title, complib=None, complevel=None): + def write_to_hdf(self, hdffile: tables.File, where: str, name: str, title: str, complib: str | None = None, complevel: int | None = None) -> None: accu_group=hdffile.create_group(where=where,name=name,title=title) accu_group._v_attrs.damaris_type="ADC_Result" if self.contains_data(): @@ -465,13 +471,13 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): # Ueberladen von Operatoren und Built-Ins ------------------------------------------------------- - def __len__(self): + def __len__(self) -> int: "Redefining len(ADC_Result obj), returns the number of samples in one channel and 0 without data" if len(self.y)>0: return len(self.y[0]) return 0 - def __repr__(self): + def __repr__(self) -> str: """ writes job meta data and data to string returned """ @@ -488,7 +494,12 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): return tmp_string - def __add__(self, other): + def _copy_metadata(self, r: ADC_Result) -> None: + "Copy channel metadata (nChannels, is_clipped) from self to the new result r" + r.nChannels = self.nChannels + r.is_clipped = self.is_clipped + + def __add__(self, other: int | float) -> ADC_Result: "Redefining self + other (scalar)" if isinstance(other, int) or isinstance(other, float): self.lock.acquire() @@ -498,6 +509,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): tmp_y.append(numpy.array(self.y[i], dtype="float32") + other) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r else: @@ -505,12 +517,12 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): - def __radd__(self, other): + def __radd__(self, other: int | float) -> ADC_Result: "Redefining other (scalar) + self" return self.__add__(other) - def __sub__(self, other): + def __sub__(self, other: int | float) -> ADC_Result: "Redefining self - other (scalar)" if isinstance(other, int) or isinstance(other, float): self.lock.acquire() @@ -520,6 +532,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): tmp_y.append(numpy.array(self.y[i], dtype="float32") - other) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r else: @@ -527,7 +540,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): - def __rsub__(self, other): + def __rsub__(self, other: int | float) -> ADC_Result: "Redefining other (scalar) - self" if isinstance(other, int) or isinstance(other, float): self.lock.acquire() @@ -537,6 +550,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): tmp_y.append(other - numpy.array(self.y[i], dtype="float32")) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r else: @@ -544,7 +558,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): - def __mul__(self, other): + def __mul__(self, other: int | float) -> ADC_Result: "Redefining self * other (scalar)" if isinstance(other, int) or isinstance(other, float): self.lock.acquire() @@ -554,18 +568,19 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): tmp_y.append(numpy.array(self.y[i], dtype="float32") * other) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r else: raise ValueError(f"ValueError: Cannot multiply \"{other.__class__}\" to ADC-Result!") - def __rmul__(self, other): + def __rmul__(self, other: int | float) -> ADC_Result: "Redefining other (scalar) * self" return self.__mul__(other) - def __pow__(self, other): + def __pow__(self, other: int | float) -> ADC_Result: "Redefining self ** other (scalar)" if isinstance(other, int) or isinstance(other, float): self.lock.acquire() @@ -575,12 +590,13 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): tmp_y.append(numpy.array(self.y[i], dtype="float32") ** other) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r else: raise ValueError(f"ValueError: Cannot power raise \"{other.__class__}\" to ADC-Result!") - def __truediv__(self, other): + def __truediv__(self, other: int | float) -> ADC_Result: "Redefining self / other (scalar)" if isinstance(other, int) or isinstance(other, float): self.lock.acquire() @@ -589,12 +605,13 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): tmp_y.append(numpy.array(self.y[i], dtype="float32") / other) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r else: raise ValueError(f"ValueError: Cannot divide \"{other.__class__}\" to ADC-Result!") - def __rtruediv__(self, other): + def __rtruediv__(self, other: int | float) -> ADC_Result: "Redefining other (scalar) / self" if isinstance(other, int) or isinstance(other, float): self.lock.acquire() @@ -602,45 +619,48 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): for i in range(self.get_number_of_channels()): tmp_y.append(other / numpy.array(self.y[i], dtype="float32")) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r else: raise ValueError(f"ValueError: Cannot divide \"{other.__class__}\" to ADC-Result!") - def __floordiv__(self, other): + def __floordiv__(self, other: int) -> ADC_Result: "Redefining self // other (scalar)" if isinstance(other, float): raise ValueError("ValueError: Cannot use floor division (//) on floats! Use \"/\" instead of \"//\"! ") if isinstance(other, int): self.lock.acquire() - tmp_y = [] + tmp_y: list[numpy.ndarray] = [] for i in range(self.get_number_of_channels()): tmp_y.append(numpy.array(self.y[i], dtype="float32") // other) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r else: raise ValueError(f"ValueError: Cannot divide \"{other.__class__}\" to ADC-Result!") - def __rfloordiv__(self, other): + def __rfloordiv__(self, other: int) -> ADC_Result: "Redefining other (scalar) // self" if isinstance(other, float): raise ValueError("ValueError: Cannot use floor division (//) on floats! Use \"/\" instead of \"//\"! ") if isinstance(other, int): self.lock.acquire() - tmp_y = [] + tmp_y: list[numpy.ndarray] = [] for i in range(self.get_number_of_channels()): tmp_y.append(other // numpy.array(self.y[i], dtype="float32")) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r else: raise ValueError(f"ValueError: Cannot divide \"{other.__class__}\" to ADC-Result!") - def __neg__(self): + def __neg__(self) -> ADC_Result: "Redefining -self" self.lock.acquire() tmp_y = [] @@ -649,11 +669,12 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): tmp_y.append(numpy.array(-self.y[i])) r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date) + self._copy_metadata(r) self.lock.release() return r -def read_from_hdf(hdf_node): +def read_from_hdf(hdf_node: object) -> ADC_Result | None: """ read accumulation data from HDF node and return it. """ @@ -703,6 +724,7 @@ def read_from_hdf(hdf_node): adc.cont_data=True # now do the real data adc_data=hdf_node.adc_data.read() + adc.set_nChannels(adc_data.shape[1]) adc.x=numpy.arange(adc_data.shape[0], dtype="float32")/adc.sampling_rate diff --git a/src/damaris/data/Accumulation.py b/src/damaris/data/Accumulation.py index f9ee5f0..dcf6f32 100644 --- a/src/damaris/data/Accumulation.py +++ b/src/damaris/data/Accumulation.py @@ -25,6 +25,12 @@ import datetime # added by Oleg Petrov import ctypes # added by Oleg Petrov import struct # added by Oleg Petrov import os # added by Oleg Petrov +from typing import IO + +_XArray = numpy.ndarray | list +_YChannels = list[numpy.ndarray] +_IndexList = list[tuple[int, int]] +_Description = dict[str, str] class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): @@ -50,7 +56,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): x (list): The x-axis data points. y (list): The y-axis data points. """ - def __init__(self, x = None, y = None, y_2 = None, n = None, index = None, sampl_freq = None, error = False): + def __init__(self, x: _XArray | None = None, y: _YChannels | None = None, y_2: _YChannels | numpy.ndarray | None = None, n: int | None = None, index: _IndexList | None = None, sampl_freq: float | None = None, error: bool = False) -> None: Errorable.__init__(self) Drawable.__init__(self) @@ -64,14 +70,15 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): self.is_clipped = False self.common_descriptions=None - self.time_period=[] + self.time_period: list[datetime.datetime] | None = [] self.job_id = None # this does not make sense for an object accumulated from multiple job_ids self.job_ids = {} self.use_error = error if self.uses_statistics(): if (y_2 is not None): - self.y_square = y_2 + # y_square is always kept as a list of per-channel arrays + self.y_square: _YChannels = [numpy.asarray(ch) for ch in y_2] elif (y_2 is None) : self.y_square = [] else: @@ -86,12 +93,12 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): self.cont_data = False self.index = [] - self.x = [] + self.x = numpy.array([], dtype="float32") self.y = [] elif (x is not None) and (y is not None) and (index is not None) and (sampl_freq is not None) and (n is not None): - self.x = x - self.y = y + self.x = numpy.asarray(x) + self.y = [numpy.asarray(ch) for ch in y] self.sampling_rate = sampl_freq self.n = n @@ -102,7 +109,15 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: raise ValueError("Wrong usage of __init__!") - def get_accu_by_index(self, index: int): + def _copy_provenance(self, r: "Accumulation") -> None: + r.is_clipped = self.is_clipped + r.job_id = self.job_id + r.job_ids = self.job_ids.copy() + r.time_period = self.time_period[:] if self.time_period is not None else None + if self.common_descriptions is not None: + r.common_descriptions = self.common_descriptions.copy() + + def get_accu_by_index(self, index: int) -> "Accumulation": self.lock.acquire() try: start = self.index[index][0] @@ -124,19 +139,19 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): r.y_square.append(self.y_square[i][start:end+1].copy()) if self.common_descriptions is not None: r.common_descriptions = self.common_descriptions.copy() - r.time_period = self.time_period[:] + r.time_period = self.time_period[:] if self.time_period is not None else None r.job_ids = self.job_ids.copy() r.is_clipped = self.is_clipped self.lock.release() return r - def get_result_by_index(self, index): + def get_result_by_index(self, index: int) -> "Accumulation": return self.get_accu_by_index(index) - def get_Result_by_index(self, index): + def get_Result_by_index(self, index: int) -> "Accumulation": return self.get_accu_by_index(index) - def get_ysquare(self, channel): + def get_ysquare(self, channel: int) -> numpy.ndarray | None: if self.uses_statistics(): try: return self.y_square[channel] @@ -164,15 +179,15 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): # external interface -------------------------------------------------------------------- - def get_yerr(self, channel): + def get_yerr(self, channel: int) -> numpy.ndarray: """ return error (std.dev/sqrt(n)) of mean """ + if not self.contains_data(): + return numpy.array([], dtype="float32") if not self.uses_statistics(): return numpy.zeros((len(self.y[0]),),dtype="float32") - if not self.contains_data(): - return [] self.lock.acquire() if self.n < 2: @@ -188,13 +203,13 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): # sample standard deviation / sqrt(n) return numpy.nan_to_num(numpy.sqrt(variance_over_n)) - def get_ydata(self, channel): + def get_ydata(self, channel: int = 0) -> numpy.ndarray: """ return mean data """ if not self.contains_data(): - return [] + return numpy.array([], dtype="float32") self.lock.acquire() try: tmp_y = self.y[channel] / self.n @@ -206,7 +221,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): return tmp_y - def get_ymin(self): + def get_ymin(self) -> float|int: if not self.contains_data(): return 0 @@ -241,10 +256,10 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): self.lock.release() return max(tmp_max) - def get_job_id(self) -> int: + def get_job_id(self) -> int | None: return self.job_id # modified by Oleg Petrov - def write_to_csv(self, destination=sys.stdout, delimiter=" "): + def write_to_csv(self, destination: str | IO[str] = sys.stdout, delimiter: str = " ") -> None: """ writes the data to a file. destination can be a filehandle or a filename, default sys.stdout @@ -255,7 +270,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: self._write_to_csv_dest(destination, delimiter) - def _write_to_csv_dest(self, dest, delimiter): + def _write_to_csv_dest(self, dest: IO[str], delimiter: str) -> None: dest.write("# accumulation %d\n"%self.n) self.lock.acquire() try: @@ -288,7 +303,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): self.lock.release() # ------------- added by Oleg Petrov, 14 Feb 2012 ---------------------- - def write_to_simpson(self, destination=sys.stdout, delimiter=" ", frequency=100e6): + def write_to_simpson(self, destination: str | IO[str] = sys.stdout, delimiter: str = " ", frequency: float = 100e6) -> None: """ writes the data to a text file or sys.stdout in Simpson format, for further processing with the NMRnotebook software; @@ -300,7 +315,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: self._write_to_simpson_dest(destination, delimiter, frequency) - def _write_to_simpson_dest(self, dest, delimiter, frequency): + def _write_to_simpson_dest(self, dest: IO[str], delimiter: str, frequency: float) -> None: self.lock.acquire() try: xdata=self.get_xdata() @@ -321,11 +336,11 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): self.lock.release() # ------------- added by Oleg Petrov, 10 Sep 2013 ----------------------- - def write_to_tecmag(self, destination=sys.stdout, nrecords=1,\ - frequency=100.,\ - last_delay = 1.,\ - receiver_phase=0.,\ - nucleus='1H'): + def write_to_tecmag(self, destination = sys.stdout, nrecords: int = 1,\ + frequency: float = 100.,\ + last_delay: float = 1.,\ + receiver_phase: float = 0.,\ + nucleus: str = '1H') -> None: """ writes the data to a binary file in TecMag format; destination can be a file object or a filename; @@ -354,6 +369,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): base_freq = [frequency, frequency, 0., 0.] offset_freq = [0., 0., 0., 0.] ob_freq = [sum(x) for x in zip(base_freq, offset_freq)] + if self.time_period is None or len(self.time_period)==0: + raise ValueError("write_to_tecmag: no time information available") date = self.time_period[0].strftime("%Y/%m/%d %H:%M:%S") # data handling: @@ -448,7 +465,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): # ----------------------------------------------------------------------- - def write_to_hdf(self, hdffile, where, name, title, complib=None, complevel=None): + def write_to_hdf(self, hdffile: tables.File, where: str, name: str, title: str, complib: str | None = None, complevel: int | None = None) -> None: """ Write the accumulation data to an HDF5 file. @@ -609,7 +626,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): return tmp_string - def __add__(self, other): + def __add__(self, other: "int | float | ADC_Result | Accumulation | None") -> "Accumulation": """ Redefining self + other We define this for different types of other: @@ -637,12 +654,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, y_2 = tmp_ysquare, n = self.n, index = self.index, sampl_freq = self.sampling_rate, error = self.use_error) else: r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, n = self.n, index = self.index, sampl_freq = self.sampling_rate, error = self.use_error) - r.is_clipped = self.is_clipped - r.job_id = self.job_id - r.job_ids = self.job_ids.copy() - r.time_period = self.time_period[:] if self.time_period is not None else None - if self.common_descriptions is not None: - r.common_descriptions = self.common_descriptions.copy() + self._copy_provenance(r) self.lock.release() return r @@ -669,7 +681,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): r = Accumulation(x = numpy.array(other.x, dtype="float32"), y = tmp_y, index = other.index, sampl_freq = other.sampling_rate, n = 1, error = False) if hasattr(other, "is_clipped"): r.is_clipped = other.is_clipped - r.time_period=[other.job_date,other.job_date] + r.time_period=[other.job_date,other.job_date] if other.job_date is not None else None r.job_id = other.job_id r.common_descriptions=other.description.copy() r.job_ids[other.job_id] = other.get_job_date() @@ -678,8 +690,6 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): # Other and self not empty (self + other ADC_Result) else: - self.lock.acquire() - if self.sampling_rate != other.get_sampling_rate(): raise ValueError("Accumulation: You cant add ADC_Results with different sampling-rates") if len(self.y[0]) != len(other): @@ -690,6 +700,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): if self.index[i] != other.get_index_bounds(i): raise ValueError("Accumulation: You cant add ADC_Results with different indexing") + self.lock.acquire() + tmp_y = [] tmp_ysquare = [] @@ -703,10 +715,13 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, n = self.n + 1, index = self.index, sampl_freq = self.sampling_rate, error = False) r.is_clipped = self.is_clipped or (hasattr(other, "is_clipped") and other.is_clipped) - r.time_period=[min(self.time_period[0],other.job_date), - max(self.time_period[1],other.job_date)] + if self.time_period and other.job_date is not None: + r.time_period=[min(self.time_period[0],other.job_date), + max(self.time_period[1],other.job_date)] + else: + r.time_period=None r.job_id = other.job_id - r.job_ids = self.job_ids + r.job_ids = self.job_ids.copy() r.job_ids[other.job_id] = other.get_job_date() if self.common_descriptions is not None: @@ -725,33 +740,29 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): # Self empty (copy) if not self.contains_data(): + if self.uses_statistics() and not other.uses_statistics(): + raise ValueError("Accumulation: You cant add non-error accumulations to accumulations with error") + + self.lock.acquire() tmp_y = [] tmp_ysquare = [] - self.lock.acquire() + for i in range(other.get_number_of_channels()): + tmp_y.append(numpy.array(other.y[i], dtype="float32")) + if other.uses_statistics(): + tmp_ysquare.append(numpy.array(other.y_square[i], dtype="float32")) if self.uses_statistics(): r = Accumulation(x = numpy.array(other.x, dtype="float32"), y = tmp_y, y_2 = tmp_ysquare, n = other.n, index = other.index, sampl_freq = other.sampling_rate, error = True) else: r = Accumulation(x = numpy.array(other.x, dtype="float32"), y = tmp_y, n = other.n, index = other.index, sampl_freq = other.sampling_rate, error = False) - for i in range(other.get_number_of_channels()): - tmp_y.append(other.y[i]) - tmp_ysquare.append(other.y_square[i]) - r.time_period=other.time_period[:] - r.job_id = other.job_id - r.job_ids = other.job_ids - if other.common_descriptions is not None: - r.common_descriptions=other.common_descriptions.copy() - else: - r.common_descriptions=None + other._copy_provenance(r) self.lock.release() return r # Other and self not empty (self + other) else: - self.lock.acquire() - if self.sampling_rate != other.get_sampling_rate(): raise ValueError("Accumulation: You cant add accumulations with different sampling-rates") if len(self.y[0]) != len(other): @@ -764,6 +775,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): if self.uses_statistics() and not other.uses_statistics(): raise ValueError("Accumulation: You cant add non-error accumulations to accumulations with error") + self.lock.acquire() + tmp_y = [] tmp_ysquare = [] @@ -777,9 +790,13 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, n = other.n + self.n, index = self.index, sampl_freq = self.sampling_rate, error = False) r.is_clipped = self.is_clipped or (hasattr(other, "is_clipped") and other.is_clipped) - r.time_period=[min(self.time_period[0],other.time_period[0]), - max(self.time_period[1],other.time_period[1])] + if self.time_period and other.time_period: + r.time_period=[min(self.time_period[0],other.time_period[0]), + max(self.time_period[1],other.time_period[1])] + else: + r.time_period=None r.job_id = other.job_id + r.job_ids = self.job_ids.copy() for i in other.job_ids: r.job_ids[i] = other.job_ids[i] r.common_descriptions={} @@ -793,22 +810,28 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): return r - def __radd__(self, other): + elif other is None: + # Convenience: ignore add of None + return self + 0 + else: + raise ValueError("can not add "+repr(type(other))+" to Accumulation") + + def __radd__(self, other: "int | float | ADC_Result | Accumulation | None") -> "Accumulation": """Redefining other + self""" return self.__add__(other) - def __sub__(self, other): + def __sub__(self, other: "int | float | ADC_Result | Accumulation") -> "Accumulation": """Redefining self - other""" return self.__add__(-other) - def __rsub__(self, other): + def __rsub__(self, other: "int | float | ADC_Result | Accumulation") -> "Accumulation": """Redefining other - self""" return (-self) + other - def __mul__(self, other): + def __mul__(self, other: int | float) -> "Accumulation": """Redefining self * other (scalar)""" if isinstance(other, (int, float)): if not self.contains_data(): @@ -835,11 +858,11 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: raise ValueError(f"ValueError: Cannot multiply \"{other.__class__}\" to Accumulation!") - def __rmul__(self, other): + def __rmul__(self, other: int | float) -> "Accumulation": """Redefining other (scalar) * self""" return self.__mul__(other) - def __imul__(self, other): + def __imul__(self, other: int | float) -> "Accumulation": """Redefining self *= other (scalar)""" if isinstance(other, (int, float)): if not self.contains_data(): @@ -854,7 +877,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: raise ValueError(f"ValueError: Cannot multiply \"{other.__class__}\" to Accumulation!") - def __truediv__(self, other): + def __truediv__(self, other: int | float) -> "Accumulation": """Redefining self / other (scalar)""" if isinstance(other, (int, float)): if not self.contains_data(): @@ -881,7 +904,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: raise ValueError(f"ValueError: Cannot divide \"{other.__class__}\" to Accumulation!") - def __itruediv__(self, other): + def __itruediv__(self, other: int | float) -> "Accumulation": """Redefining self /= other (scalar)""" if isinstance(other, (int, float)): if not self.contains_data(): @@ -896,7 +919,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: raise ValueError(f"ValueError: Cannot divide \"{other.__class__}\" to Accumulation!") - def __floordiv__(self, other): + def __floordiv__(self, other: int) -> "Accumulation": """Redefining self // other (scalar)""" if isinstance(other, float): raise ValueError("ValueError: Cannot use floor division (//) on floats! Use \"/\" instead of \"//\"! ") @@ -905,7 +928,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): if not self.contains_data(): raise ValueError("Accumulation: You cant divide an empty accumulation") self.lock.acquire() - tmp_y = [] + tmp_y: list[numpy.ndarray] = [] for i in range(self.get_number_of_channels()): tmp_y.append(self.y[i] // other) @@ -921,7 +944,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): else: raise ValueError(f"ValueError: Cannot divide \"{other.__class__}\" to Accumulation!") - def __ifloordiv__(self, other): + def __ifloordiv__(self, other: int) -> "Accumulation": """Redefining self //= other (scalar)""" if isinstance(other, float): raise ValueError("ValueError: Cannot use floor division (//) on floats! Use \"/\" instead of \"//\"! ") @@ -942,7 +965,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): raise ValueError(f"ValueError: Cannot divide \"{other.__class__}\" to Accumulation!") - def __iadd__(self, other): + def __iadd__(self, other: "int | float | ADC_Result | Accumulation | None") -> "Accumulation": """Redefining self += other""" # Float or int if isinstance(other, int) or isinstance(other, float): @@ -982,7 +1005,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): if hasattr(other, "is_clipped"): self.is_clipped = other.is_clipped - self.time_period=[other.job_date,other.job_date] + self.time_period=[other.job_date,other.job_date] if other.job_date is not None else None self.job_id = other.job_id # added by Oleg Petrov self.job_ids[other.job_id] = other.get_job_date() self.common_descriptions=other.description.copy() @@ -992,8 +1015,6 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): # Other and self not empty (self + other/ADC_Result) else: - self.lock.acquire() - if self.sampling_rate != other.get_sampling_rate(): raise ValueError("Accumulation: You can't add ADC-Results with different sampling-rates") if len(self.y[0]) != len(other): @@ -1004,6 +1025,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): if self.index[i] != other.get_index_bounds(i): raise ValueError("Accumulation: You can't add ADC-Results with different indexing") + self.lock.acquire() + for i in range(self.get_number_of_channels()): self.y[i] += other.y[i] if self.uses_statistics(): @@ -1012,8 +1035,11 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): self.n += 1 if hasattr(other, "is_clipped"): self.is_clipped = self.is_clipped or other.is_clipped - self.time_period=[min(self.time_period[0],other.job_date), - max(self.time_period[1],other.job_date)] + if self.time_period and other.job_date is not None: + self.time_period=[min(self.time_period[0],other.job_date), + max(self.time_period[1],other.job_date)] + else: + self.time_period=None self.job_id = other.job_id @@ -1053,7 +1079,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): if hasattr(other, "is_clipped"): self.is_clipped = other.is_clipped self.common_descriptions=other.common_descriptions.copy() - self.time_period=other.time_period[:] + self.time_period=other.time_period[:] if other.time_period is not None else None self.job_id = other.job_id # added by Oleg Petrov self.job_ids.update(other.job_ids) self.lock.release() @@ -1062,7 +1088,6 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): # Other and self not empty (self + other) else: - self.lock.acquire() if self.sampling_rate != other.get_sampling_rate(): raise ValueError("Accumulation: You cant add accumulations with different sampling-rates") if len(self.y[0]) != len(other): @@ -1074,6 +1099,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): raise ValueError("Accumulation: You cant add accumulations with different indexing") if self.uses_statistics() and not other.uses_statistics(): raise ValueError("Accumulation: You cant add non-error accumulations to accumulations with error") + + self.lock.acquire() for i in range(self.get_number_of_channels()): self.y[i] += other.y[i] @@ -1083,8 +1110,11 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): self.n += other.n if hasattr(other, "is_clipped"): self.is_clipped = self.is_clipped or other.is_clipped - self.time_period=[min(self.time_period[0],other.time_period[0]), - max(self.time_period[1],other.time_period[1])] + if self.time_period and other.time_period: + self.time_period=[min(self.time_period[0],other.time_period[0]), + max(self.time_period[1],other.time_period[1])] + else: + self.time_period=None self.job_id = other.job_id self.job_ids.update(other.job_ids) # Removes mismatched common description keys @@ -1114,16 +1144,16 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): raise ValueError("can not add "+repr(type(other))+" to Accumulation") - def __isub__(self, other): + def __isub__(self, other: "int | float | ADC_Result | Accumulation") -> "Accumulation": """Redefining self -= other""" return self.__iadd__(-other) - def __neg__(self): + def __neg__(self) -> "Accumulation": """Redefining -self""" if not self.contains_data(): - return + raise ValueError("Accumulation: You cant negate an empty accumulation") tmp_y = [] @@ -1132,14 +1162,16 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): tmp_y.append(numpy.array(-self.y[i], dtype="float32")) if self.uses_statistics(): - r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, y_2 = numpy.array(self.y_square), n = self.n, index = self.index, sampl_freq = self.sampling_rate, error = True) + tmp_y_square = [numpy.array(sq) for sq in self.y_square] + r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, y_2 = tmp_y_square, n = self.n, index = self.index, sampl_freq = self.sampling_rate, error = True) else: r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, n = self.n, index = self.index, sampl_freq = self.sampling_rate, error = False) + self._copy_provenance(r) self.lock.release() return r -def read_from_hdf(hdf_node): +def read_from_hdf(hdf_node: object) -> "Accumulation | None": """ read accumulation data from HDF node and return it. """ diff --git a/src/damaris/data/Config_Result.py b/src/damaris/data/Config_Result.py index 627058a..a6c858b 100644 --- a/src/damaris/data/Config_Result.py +++ b/src/damaris/data/Config_Result.py @@ -17,8 +17,12 @@ class Config_Result(Resultable): if config is None: self.config = { } + else: + self.config = config if desc is None: self.description = { } + else: + self.description = desc self.job_id = job_id self.job_date = job_date diff --git a/src/damaris/data/DamarisFFT.py b/src/damaris/data/DamarisFFT.py index a4b43e2..71b0baa 100644 --- a/src/damaris/data/DamarisFFT.py +++ b/src/damaris/data/DamarisFFT.py @@ -19,10 +19,6 @@ class DamarisFFT: :param float stop: end of clipping in s """ - # check if start/stop order is properly - if start > stop: - start, stop = stop, start - # do nothing if one uses clip as a "placeholder" if start is None and stop is None: return self @@ -32,6 +28,10 @@ class DamarisFFT: if stop is None: stop = self.x[ -1 ] + # check if start/stop order is properly + if start > stop: + start, stop = stop, start + # check if data is fft which changes the start/stop units if self.xlabel == "Frequency / Hz": start = self.x.size * (0.5 + start / self.sampling_rate) diff --git a/src/damaris/data/DataPool.py b/src/damaris/data/DataPool.py index c4e9b87..70c978c 100644 --- a/src/damaris/data/DataPool.py +++ b/src/damaris/data/DataPool.py @@ -394,6 +394,12 @@ class DataPool(collections.abc.MutableMapping): if obj is not None: self[full_path] = obj + elif damaris_type == "FitResult": + from damaris.data.FitResult import read_from_hdf + obj = read_from_hdf(node) + if obj is not None: + self[full_path] = obj + elif damaris_type == "TemperatureResult": from damaris.data.Temperature import read_from_hdf obj = read_from_hdf(node) diff --git a/src/damaris/data/Drawable.py b/src/damaris/data/Drawable.py index b1990f5..be45ee5 100644 --- a/src/damaris/data/Drawable.py +++ b/src/damaris/data/Drawable.py @@ -1,5 +1,7 @@ # -*- coding: iso-8859-1 -*- +from __future__ import annotations + ############################################################################# # # # Name: Class Drawable # @@ -8,23 +10,29 @@ # # ############################################################################# +import numpy + + class Drawable: def __init__(self): # Will be set correctly in one of the subclasses - self.x = [] - self.y = [] + # x is always a numpy.ndarray, y is always a list of numpy.ndarray + # (one array per channel); empty data is represented by zero-length + # arrays of the same types + self.x: numpy.ndarray = numpy.array([], dtype="float64") + self.y: list[numpy.ndarray] = [] - self.styles = { } + self.styles: dict = { } - self.xlabel = None - self.ylabel = None + self.xlabel: str | None = None + self.ylabel: str | None = None - self.title = None + self.title: str | None = None - self.legend = { } + self.legend: dict = { } - self.text = {} + self.text: dict = {} self.xmin = 0 self.xmax = 0 @@ -32,12 +40,12 @@ class Drawable: self.ymax = 0 - def get_xdata(self): + def get_xdata(self) -> numpy.ndarray: "Returns a reference to the x-Plotdata (array)" return self.x - def set_xdata(self, pos, value): + def set_xdata(self, pos: int, value: float) -> None: "Sets a point in x" try: self.x[pos] = value @@ -45,7 +53,7 @@ class Drawable: raise - def get_ydata(self, channel): + def get_ydata(self, channel: int = 0) -> numpy.ndarray: "Returns the y-Plotdata of channel n (array)" try: return self.y[channel] @@ -53,51 +61,52 @@ class Drawable: raise - def set_ydata(self, channel, pos, value): + def set_ydata(self, channel: int, pos: int, value: float) -> None: "Sets a point in y" try: self.y[channel][pos] = value except: raise - def get_number_of_channels(self): + + def get_number_of_channels(self) -> int: "Returns the number of channels in y" return len(self.y) - def get_style(self): + def get_style(self) -> dict: "Returns a reference to plot-styles (dictionary)" return self.styles - def set_style(self, channel, value): + def set_style(self, channel: int | str, value: object) -> None: "Sets a channel to a certain plot-style" if channel in self.styles: print("Drawable Warning: Style key \"%s\" will be overwritten with \"%s\"" % (str(channel), str(value))) self.styles[channel] = str(value) - def get_xlabel(self): + def get_xlabel(self) -> str | None: "Returns the label for the x-axis" return self.xlabel - def set_xlabel(self, label): + def set_xlabel(self, label: object) -> None: "Sets the label for the x-axis" self.xlabel = str(label) - def get_ylabel(self): + def get_ylabel(self) -> str | None: "Gets the label for the y-axis" return self.ylabel - def set_ylabel(self, label): + def set_ylabel(self, label: object) -> None: "Sets the label for the y-axis" self.ylabel = str(label) - def get_text(self, index): + def get_text(self, index: int | str) -> str | None: "Returns labels to be plotted (List)" if index in self.text: return self.text[index] @@ -105,84 +114,96 @@ class Drawable: return None - def set_text(self, index, text): + def set_text(self, index: int | str, text: object) -> None: "Sets labels to be plotted " self.text[index] = str(text) - def get_title(self): + def get_title(self) -> str | None: "Returns the title of the plot" return self.title - def set_title(self, title): + def set_title(self, title: object) -> None: "Sets the title of the plot" self.title = str(title) - def get_legend(self): + def get_legend(self) -> dict: "Returns the legend of the plot (Dictionary)" return self.legend - def set_legend(self, channel, value): + def set_legend(self, channel: int | str, value: object) -> None: "Sets the legend of the plot" if channel in self.legend: print("Drawable Warning: Legend key \"%s\" will be overwritten with \"%s\"" % (str(channel), str(value))) self.legend[channel] = str(value) - def get_xmin(self): + + def get_xmin(self) -> float: "Returns minimun of x" + if self.x.size == 0: + return 0 return self.x.min() - def get_xminpos(self): + + def get_xminpos(self) -> float: "Returns smallest positive value of x" mask = self.x > 0 + if not numpy.any(mask): + return 0 return self.x[mask].min() - def set_xmin(self, xmin): + + def set_xmin(self, xmin: float) -> None: "Sets minimum of x" self.xmin = xmin - def get_xmax(self): + + def get_xmax(self) -> float: "Returns maximum of x" + if self.x.size == 0: + return 0 return self.x.max() - def set_xmax(self, xmax): + + def set_xmax(self, xmax: float) -> None: "Sets maximum of x" self.xmax = xmax - def get_ymin(self): + + def get_ymin(self) -> float | int: "Returns minimum of y" - if isinstance(self.y, list): - return min([yarr.min() for yarr in self.y]) - else: - return self.y.min() + if len(self.y) == 0: + return 0 + return min([yarr.min() for yarr in self.y]) - def get_yminpos(self): + + def get_yminpos(self) -> float | int: "Returns smallest positive value of y" - if isinstance(self.y, list): - ymins = [] - for ys in self.y: - mask = ys > 0 + ymins = [] + for ys in self.y: + mask = ys > 0 + if numpy.any(mask): ymins.append(ys[mask].min()) - ymin = min(ymins) - else: - mask = self.y > 0 - ymin = self.y[mask].min() - return ymin + if len(ymins) == 0: + return 0 + return min(ymins) - def set_ymin(self, ymin): + + def set_ymin(self, ymin: float) -> None: "Sets minimum of y" self.ymin = ymin - def get_ymax(self): - "Returns maximimum of y" - if isinstance(self.y, list): - return max([yarr.max() for yarr in self.y]) - else: - return self.y.max() - def set_ymax(self, ymax): + def get_ymax(self) -> float | int: + "Returns maximimum of y" + if len(self.y) == 0: + return 0 + return max([yarr.max() for yarr in self.y]) + + + def set_ymax(self, ymax: float) -> None: "Sets maximum of y" self.ymax = ymax diff --git a/src/damaris/data/Error_Result.py b/src/damaris/data/Error_Result.py index c576265..1bc0e3f 100644 --- a/src/damaris/data/Error_Result.py +++ b/src/damaris/data/Error_Result.py @@ -2,6 +2,7 @@ from .Resultable import Resultable from .Drawable import Drawable +import numpy ############################################################################# # # @@ -46,12 +47,12 @@ class Error_Result(Resultable, Drawable): return False # Nothing to plot - def get_ydata(self): - return [0.0] + def get_ydata(self, channel=0): + return numpy.array([0.0]) # Nothing to plot def get_xdata(self): - return [0.0] + return numpy.array([0.0]) #overload of operators und built-ins ------------------------------------------------------- diff --git a/src/damaris/data/FitResult.py b/src/damaris/data/FitResult.py new file mode 100644 index 0000000..783151a --- /dev/null +++ b/src/damaris/data/FitResult.py @@ -0,0 +1,127 @@ +# fit curve with x-values independent of the measurement data points + +import numpy +import tables +from . import Drawable + + +class FitResult(Drawable.Drawable): + """ + plottable fit curve belonging to a MeasurementResult + + In contrast to MeasurementResult, the x-values are a plain array + and are independent of the measured data points, so a fit can be + sampled on a much denser x-grid for a smooth plot. The fit is + stored in the data pool under its own key, e.g. as a child of the + measurement ("Magnetization(tau)/fit"), and is drawn by the GUI + as an overlay on the measurement it belongs to. + """ + + def __init__(self, x, y, title=None, source=None, y_err=None, params=None): + """ + x: array-like of x-values (independent of the measurement) + y: array-like of y-values, same length as x + title: plot title of the fit + source: data pool key of the MeasurementResult this fit belongs to + y_err: optional array-like of y errors, same length as x + params: optional dictionary of fit parameters, shown in the plot + """ + Drawable.Drawable.__init__(self) + self.x = numpy.array(x, dtype="float64").reshape(-1) + self.y = [numpy.array(y, dtype="float64").reshape(-1)] + if y_err is not None: + self.y_err = numpy.array(y_err, dtype="float64").reshape(-1) + else: + self.y_err = None + self.title = title + self.source = source + self.params = dict(params) if params is not None else {} + + def get_lineplotdata(self): + """ + x and y data for plotting the fit curve + """ + return [self.x, self.y[0]] + + def uses_statistics(self): + """ + drawable interface method returns False + """ + return False + + def write_to_hdf(self, hdffile, where, name, title, complib=None, complevel=None): + h5_table_format = { + "x": tables.Float64Col(), + "y": tables.Float64Col() + } + if self.y_err is not None: + h5_table_format["y_err"] = tables.Float64Col() + filter = None + if complib is not None: + if complevel is None: + complevel = 9 + filter = tables.Filters(complevel=complevel, complib=complib, shuffle=1) + + fit_table = hdffile.create_table(where=where, name=name, + description=h5_table_format, + title=title, + filters=filter, + expectedrows=self.x.shape[0]) + fit_table.flavor = "numpy" + fit_table.attrs.damaris_type = "FitResult" + fit_table.attrs.quantity_name = self.get_title() + if self.xlabel is not None: + fit_table.attrs.xlabel = self.xlabel + if self.ylabel is not None: + fit_table.attrs.ylabel = self.ylabel + if self.source is not None: + fit_table.attrs.source = self.source + for (key, value) in self.params.items(): + if isinstance(value, (int, float)) and not isinstance(value, bool): + fit_table.attrs.__setattr__("param_" + str(key), float(value)) + else: + fit_table.attrs.__setattr__("param_" + str(key), str(value)) + + row = fit_table.row + has_y_err = self.y_err is not None + for i in range(self.x.shape[0]): + row["x"] = self.x[i] + row["y"] = self.y[0][i] + if has_y_err: + row["y_err"] = self.y_err[i] + row.append() + fit_table.flush() + + +def read_from_hdf(hdf_node): + """ + reads a FitResult object from the hdf_node + or None if the node is not suitable + """ + if not isinstance(hdf_node, tables.Table): + return None + + if hdf_node._v_attrs.damaris_type != "FitResult": + return None + + xdata = hdf_node.col("x") + ydata = hdf_node.col("y") + if "y_err" in hdf_node.colnames: + y_err = hdf_node.col("y_err") + else: + y_err = None + + fr = FitResult(xdata, ydata, + title=getattr(hdf_node._v_attrs, "quantity_name", None), + source=getattr(hdf_node._v_attrs, "source", None), + y_err=y_err) + fr.xlabel = getattr(hdf_node._v_attrs, "xlabel", None) + fr.ylabel = getattr(hdf_node._v_attrs, "ylabel", None) + + params = {} + for attr_name in hdf_node._v_attrs._f_list("all"): + if attr_name.startswith("param_"): + params[attr_name[6:]] = getattr(hdf_node._v_attrs, attr_name) + fr.params = params + + return fr diff --git a/src/damaris/data/MeasurementResult.py b/src/damaris/data/MeasurementResult.py index d81c1c9..8f3853a 100644 --- a/src/damaris/data/MeasurementResult.py +++ b/src/damaris/data/MeasurementResult.py @@ -4,6 +4,7 @@ import sys import tables import numpy import collections +from typing import IO from . import Drawable ## provide gaussian statistics for a series of measured data points @@ -12,7 +13,7 @@ from . import Drawable # internally it keeps the sum, the sum of squares and the number of data points class AccumulatedValue: - def __init__(self, mean=None, mean_err=None, n=None): + def __init__(self, mean: float | None = None, mean_err: float | None = None, n: int | None = None): """ one value with std. deviation can be initialized by: @@ -42,7 +43,7 @@ class AccumulatedValue: self.y=float(mean)*self.n self.y2=float(mean_err)**2*n*(n-1.0)+float(mean)**2*n - def __add__(self,y): + def __add__(self, y: "AccumulatedValue | float") -> "AccumulatedValue": new_one=AccumulatedValue() if isinstance(y, AccumulatedValue): new_one.y=self.y+y.y @@ -54,7 +55,7 @@ class AccumulatedValue: new_one.n=self.n+1 return new_one - def __iadd__(self,y): + def __iadd__(self, y: "AccumulatedValue | float") -> "AccumulatedValue": if isinstance(y, AccumulatedValue): self.y+=y.y self.y2+=y.y2 @@ -65,14 +66,14 @@ class AccumulatedValue: self.n+=1 return self - def copy(self): + def copy(self) -> "AccumulatedValue": a=AccumulatedValue() a.y=self.y a.y2=self.y2 a.n=self.n return a - def mean(self): + def mean(self) -> float | None: """ returns the mean of all added/accumulated values """ @@ -81,7 +82,7 @@ class AccumulatedValue: else: return self.y/self.n - def sigma(self): + def sigma(self) -> float | None: """ returns the standard deviation added/accumulated values """ @@ -97,7 +98,7 @@ class AccumulatedValue: else: return None - def mean_error(self): + def mean_error(self) -> float | None: """ returns the mean's error (=std.dev/sqrt(n)) of all added/accumulated values """ @@ -113,7 +114,7 @@ class AccumulatedValue: else: return None - def __str__(self): + def __str__(self) -> str: if self.n==0: return "no value" elif self.n==1: @@ -121,12 +122,12 @@ class AccumulatedValue: else: return "%g +/- %g (%d accumulations)"%(self.mean(),self.mean_error(),self.n) - def __repr__(self): + def __repr__(self) -> str: return str(self) class MeasurementResult(Drawable.Drawable, collections.UserDict): - def __init__(self, quantity_name): + def __init__(self, quantity_name: str) -> None: """ convenient accumulation and interface to plot functions @@ -138,7 +139,7 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict): self.lock = threading.RLock() # get the selected item, if it does not exist, create an empty one - def __getitem__(self, key): + def __getitem__(self, key: float) -> "AccumulatedValue": if key not in self: a=AccumulatedValue() self.data[float(key)]=a @@ -146,23 +147,26 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict): else: return self.data[float(key)] - def __setitem__(self,key,value): - if not isinstance(value, AccumulatedValue): - value=AccumulatedValue(float(value)) + def __setitem__(self,key: float,item: "AccumulatedValue | float")-> None: + if not isinstance(item, AccumulatedValue): + item=AccumulatedValue(float(item)) return collections.UserDict.__setitem__(self, float(key), - value) + item) - def __add__(self, right_value): + def __add__(self, right_value: int) -> "MeasurementResult": if right_value==0: - return self.copy() + new_one=MeasurementResult(self.quantity_name) + for key in self.data: + new_one[key]=self.data[key].copy() + return new_one else: raise Exception("not implemented") - def get_title(self): + def get_title(self) -> str: return self.quantity_name - def get_xdata(self): + def get_xdata(self) -> numpy.ndarray: """ sorted array of all dictionary entries without Accumulated Value objects with n==0 """ @@ -171,32 +175,35 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict): keys.sort() return keys - def get_ydata(self): + def get_ydata(self, channel: int = 0) -> numpy.ndarray: + """ + y data (mean values); the channel argument is accepted for + compatibility with the Drawable interface, MeasurementResult + is single-channel + """ return self.get_xydata()[1] - def get_xydata(self): + def get_xydata(self) -> list[numpy.ndarray]: k=self.get_xdata() v=numpy.array([self.data[key].mean() for key in k], dtype="float64") return [k,v] - def get_errorplotdata(self): + def get_errorplotdata(self) -> list[numpy.ndarray]: k=self.get_xdata() v=numpy.array([self.data[key].mean() for key in k], dtype="float64") e=numpy.array([self.data[key].mean_error() for key in k], dtype="float64") return [k,v,e] - def get_lineplotdata(self): - k=self.get_xdata() - v=numpy.array(self.y, dtype="float64") - return [k, v] + def get_lineplotdata(self) -> list[numpy.ndarray]: + return self.get_xydata() - def uses_statistics(self): + def uses_statistics(self) -> bool: """ drawable interface method returns True """ return True - def write_to_csv(self,destination=sys.stdout, delimiter=" "): + def write_to_csv(self,destination: str | IO[str] = sys.stdout, delimiter: str = " ") -> None: """ writes the data to a file or to sys.stdout destination can be a file or a filename @@ -208,24 +215,21 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict): else: self._write_to_csv_dest(destination, delimiter) - def _write_to_csv_dest(self, dest, delimiter): + def _write_to_csv_dest(self, dest: IO[str], delimiter: str) -> None: dest.write("# quantity:"+str(self.quantity_name)+"\n") dest.write("# x y ysigma n\n") for x in self.get_xdata(): y = self.data[x] - if isinstance(y, (float, int)): - dest.write("%e%s%e%s0%s1\n" % (x, delimiter, y, delimiter, delimiter)) - else: - dest.write("%e%s%e%s%e%s%d\n" % (x, - delimiter, - y.mean(), - delimiter, - y.mean_error(), - delimiter, - y.n)) + dest.write("%e%s%e%s%e%s%d\n" % (x, + delimiter, + y.mean(), + delimiter, + y.mean_error(), + delimiter, + y.n)) - def write_to_hdf(self, hdffile, where, name, title, complib=None, complevel=None): + def write_to_hdf(self, hdffile: tables.File, where: str, name: str, title: str, complib: str | None = None, complevel: int | None = None) -> None: h5_table_format= { "x" : tables.Float32Col(), "y" : tables.Float32Col(), @@ -251,24 +255,19 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict): row=mr_table.row xdata=self.get_xdata() if xdata.shape[0]!=0: - for x in self.get_xdata(): + for x in xdata: y=self.data[x] row["x"]=x - if type(y) in [float, int, int]: - row["y"]=y - row["y_err"]=0.0 - row["n"]=1 - else: - row["y"]=y.mean() - row["y_err"]=y.mean_error() - row["n"]=y.n + row["y"]=y.mean() + row["y_err"]=y.mean_error() + row["n"]=y.n row.append() finally: mr_table.flush() self.lock.release() -def read_from_hdf(hdf_node): +def read_from_hdf(hdf_node: object) -> "MeasurementResult | None": """ reads a MeasurementResult object from the hdf_node or None if the node is not suitable diff --git a/src/damaris/data/Signalpath.py b/src/damaris/data/Signalpath.py index 276a534..abd4da0 100644 --- a/src/damaris/data/Signalpath.py +++ b/src/damaris/data/Signalpath.py @@ -21,16 +21,16 @@ class Signalpath: if abs(reduced_angle[1]) < 1e-6: reduced_angle = reduced_angle[0] % 4 if reduced_angle == 0: - rotated.y = list(self.y) + rotated.y = [a.copy() for a in self.y] elif reduced_angle == 1: - rotated.y = [-self.y[1], self.y[0]] + rotated.y = [-self.y[1], self.y[0].copy()] elif reduced_angle == 2: rotated.y = [-self.y[0], -self.y[1]] elif reduced_angle == 3: - rotated.y = [self.y[1], -self.y[0]] + rotated.y = [self.y[1].copy(), -self.y[0]] else: rad_angle = N.deg2rad(degrees) sin_angle = N.sin(rad_angle) diff --git a/src/damaris/data/__init__.py b/src/damaris/data/__init__.py index 8028bb3..96c3945 100644 --- a/src/damaris/data/__init__.py +++ b/src/damaris/data/__init__.py @@ -1,10 +1,11 @@ from damaris.data.ADC_Result import ADC_Result from damaris.data.Accumulation import Accumulation from damaris.data.MeasurementResult import MeasurementResult, AccumulatedValue +from damaris.data.FitResult import FitResult from damaris.data.DataPool import DataPool from damaris.data.Error_Result import Error_Result from damaris.data.Config_Result import Config_Result from damaris.data.Temperature import TemperatureResult -__all__=["ADC_Result", "Accumulation", "MeasurementResult", "AccumulatedValue", "DataPool", "Error_Result", "Config_Result", "TemperatureResult" ] +__all__=["ADC_Result", "Accumulation", "MeasurementResult", "AccumulatedValue", "FitResult", "DataPool", "Error_Result", "Config_Result", "TemperatureResult" ] diff --git a/src/damaris/experiments/Experiment.py b/src/damaris/experiments/Experiment.py index 0393b32..3f97c83 100644 --- a/src/damaris/experiments/Experiment.py +++ b/src/damaris/experiments/Experiment.py @@ -3,20 +3,20 @@ import types import numpy class StateBase(object): - def __init__(self): + def __init__(self) -> None: pass - def to_xml(self, indent = ""): + def to_xml(self, indent: str = "") -> str: return indent + "" class StateSimple(StateBase): - def __init__(self, time, content=None): + def __init__(self, time: float, content: object | None = None) -> None: super(StateSimple, self).__init__() if time < 0: raise AssertionError("time for state is negative!") self.time = float(time) self.content = content - def to_xml(self, indent = ""): + def to_xml(self, indent: str = "") -> str: s = indent + '\n' @@ -24,14 +24,14 @@ class StateSimple(StateBase): s += indent + ' ' + str(self.content) + '\n' s += indent + '\n' return s - def __repr__(self): + def __repr__(self) -> str: return 'StateSimple(%s, %s)' % (self.time, repr(self.content)) class StateList(StateBase): - def __init__(self): + def __init__(self) -> None: super(StateList, self).__init__() - self.list = [] - def to_xml(self, indent = " "): + self.list: list[object] = [] + def to_xml(self, indent: str = " ") -> str: s = "" for k in self.list: if hasattr(k, "to_xml"): @@ -39,21 +39,21 @@ class StateList(StateBase): else: s += indent + str(k) return s - def append(self, val): + def append(self, val: object) -> None: self.list.append(val) class StateLoop(StateList): """Represents a loop in the state tree""" - def __init__(self, repeat): + def __init__(self, repeat: int) -> None: super(StateLoop, self).__init__() self.repeat = repeat - def to_xml(self, indent = ""): + def to_xml(self, indent: str = "") -> str: s = indent + ('\n' % self.repeat) s += super(StateLoop, self).to_xml(indent + " ") s += indent + '\n' return s - def __repr__(self): + def __repr__(self) -> str: return 'StateLoop(repeat=%d, %s)' \ % (self.repeat, repr(self.list)) @@ -78,32 +78,32 @@ class Experiment: :param list rf_gates: list of rf gates ttl channels len(rf_gates) == len(rf_sources) """ - job_id = 0 + job_id: int = 0 - def __init__(self, gating=None, rf_sources=[], rf_gates=[]): - self.job_id = Experiment.job_id + def __init__(self, gating: float | None = None, rf_sources: list[int] = [], rf_gates: list[int] = []) -> None: + self.job_id: int = Experiment.job_id Experiment.job_id += 1 - self.state_list = StateList() - self.list_stack = [] - self.description = { } - self.gating = gating + self.state_list: StateList = StateList() + self.list_stack: list[StateList] = [] + self.description: dict[str, object] = { } + self.gating: float | None = gating assert type(rf_sources) == type(list()), "rf_sources needs to be a list with the channels" assert type(rf_gates) == type(list()), "rf_gates needs to be a list with the channels" assert len(rf_gates) == len(rf_sources), "rf_sources and rf_gates must have equal number of entries" - self.rf_sources = rf_sources - self.rf_gates = rf_gates + self.rf_sources: list[int] = rf_sources + self.rf_gates: list[int] = rf_gates #for tracking the experiment length: #because loops are possible we need to track the length for each loop level - self.total_time=[0.0] + self.total_time: list[float] = [0.0] #and we need to know the number of iterations of the loops to multiply the state. - self.loop_iterations=[1] + self.loop_iterations: list[int] = [1] # Commands ------------------------------------------------------------------------------------- - def ttl_pulse(self, length, channel = None, value = None): + def ttl_pulse(self, length: float, channel: int | None = None, value: int | None = None) -> None: """ Creates a state with length **length** and switches requested bits of the pulse programmer to HIGH. @@ -151,7 +151,7 @@ class Experiment: self.total_time[-1] += length ## Same as ttl_pulse, but no *channel* keyword - def ttls(self, length = None, value = None): + def ttls(self, length: float | None = None, value: int | None = None) -> None: """ Same as ttl_pulse, but no *channel* keyword @@ -159,6 +159,7 @@ class Experiment: :param int value: lines to set (integer) :return: """ + assert value is not None, "ttls needs an integer value" the_value=int(value) s_content = '' % the_value if length is not None: @@ -168,7 +169,7 @@ class Experiment: self.state_list.append(s_content) - def rf_pulse(self, length=None, phase=0, source=0): + def rf_pulse(self, length: float | None = None, phase: float = 0, source: int = 0) -> None: """ Make an rf pulse, including gating and phase switching. Only possible if Experiment is configured. @@ -183,7 +184,7 @@ class Experiment: self.ttls(length=length, value=self.rf_gates[source]+self.rf_sources[source]) ## Beginning of a new state - def state_start(self, time): + def state_start(self, time: float) -> None: """ Starts a state in the pulse programs with duration *time*. This must be closed with :func:`state_end`. @@ -194,13 +195,13 @@ class Experiment: self.total_time[-1] += time ## End of *state_start* - def state_end(self): + def state_end(self) -> None: """ Closes a previous :func:`state_start` """ self.state_list.append('\n') - def wait(self, time, ttls=None, gating=False): + def wait(self, time: float, ttls: int | None = None, gating: bool = False) -> None: """ Waits for a specified amount of time without performing any actions. @@ -241,6 +242,7 @@ class Experiment: """ if gating: + assert self.gating is not None, "gating time must be set for gating=True" time -= self.gating if ttls is not None: s_content = '' % ttls @@ -251,7 +253,7 @@ class Experiment: self.total_time[-1] += time - def record(self, samples, frequency, timelength=None, sensitivity = None, ttls=None, channels = 3, offset = None, impedance = None): + def record(self, samples: int, frequency: float, timelength: float | None = None, sensitivity: float | list[float] | None = None, ttls: int | None = None, channels: int = 3, offset: int | list[int] | None = None, impedance: float | list[float] | None = None) -> None: """ Records data with a given number of samples, sampling frequency, and sensitivity. This command starts data acquisition from the ADC (Analog-to-Digital Converter). @@ -365,7 +367,7 @@ class Experiment: ## Create a loop on the pulse programmer. Loop contents can not change inside the loop. # @params iterations Number of loop iterations - def loop_start(self, iterations): + def loop_start(self, iterations: int) -> None: """ creates a loop of given number of iterations and has to be closed by loop_end(). Commands inside the loop can not change, i.e., the parameters are the same for each loop run. @@ -384,7 +386,7 @@ class Experiment: self.loop_iterations.append(iterations) ## End loop state - def loop_end(self): + def loop_end(self) -> None: """ Closes a l:func:`loop_start` statement. """ @@ -396,7 +398,7 @@ class Experiment: self.total_time[-1] += looptime*loopiterations - def set_frequency(self, frequency, phase, ttls=0): + def set_frequency(self, frequency: float, phase: float, ttls: int = 0) -> None: """ Sets the frequency and phase of the frequency source and optionally further channels. The state length is 2 µs. Stabilisation time depends on RF source. @@ -412,7 +414,7 @@ class Experiment: self.total_time[-1] += 2e-6 - def set_pfg(self, dac_value=None, length=None, shape=('rec', 0), trigger=4, is_seq=False): + def set_pfg(self, dac_value: int | None = None, length: float | None = None, shape: tuple[str, float] = ('rec', 0), trigger: int = 4, is_seq: bool = False) -> None: """ This sets the value for the PFG, it also sets it back to dac_value=0 automatically. If you don't wish to do so (i.e. line shapes) set is_seq=1 @@ -480,7 +482,7 @@ class Experiment: else: # don't know what to do raise SyntaxError("form is unknown: %s"%form) - def set_dac(self, dac_value, dac_id=1, length=None, is_seq=False, ttls=0): + def set_dac(self, dac_value: int, dac_id: int = 1, length: float | None = None, is_seq: bool = False, ttls: int = 0) -> None: """ Sets the value for the DAC (Digital-to-Analog Converter) and optionally additional TTL lines. This command is used to control analog output signals, such as pulsed field gradients. @@ -544,7 +546,7 @@ class Experiment: self.total_time[-1] += 42*9e-8 - def set_phase(self, phase, ttls=0): + def set_phase(self, phase: float, ttls: int = 0) -> None: """ Sets the phase of the RF source to the specified value. @@ -590,7 +592,7 @@ class Experiment: self.total_time[-1] += 0.5e-6 - def set_description(self, key, value): + def set_description(self, key: str, value: object) -> None: """ Sets a description key-value pair in the experiment description dictionary. @@ -630,7 +632,7 @@ class Experiment: print('Warning: Overwriting existing description "%s" = "%s" with "%s"' % (key, self.description[key], value)) self.description[key] = value - def set_pts_local(self): + def set_pts_local(self) -> None: """ Sets the PTS310/PTS500 frequency source to local mode. State length is 2 µs. @@ -654,7 +656,7 @@ class Experiment: return self.job_id - def write_xml_string(self): + def write_xml_string(self) -> str: """ Returns the current program as a string @@ -707,14 +709,14 @@ class Experiment: return xml_string - def write_quit_job(self): + def write_quit_job(self) -> str: """ Returns a xml quit-job :returns: XML quit-job """ return '\n' - def get_length(self): + def get_length(self) -> float: timelength = 0.0 #calculate the correct timelength also for unclosed loops, if there is no unclosed loop #the timelength of this experiment is self.total_time[-1]. @@ -724,13 +726,13 @@ class Experiment: return timelength class Quit(Experiment): - def write_xml_string(self): + def write_xml_string(self) -> str: return '\n'%self.job_id # /Public Methods ------------------------------------------------------------------------------ -def self_test(): +def self_test() -> None: e = Experiment() e.set_description("key", "value") e.set_frequency(85e6, 90, ttls=16) diff --git a/src/damaris/experiments/dac.py b/src/damaris/experiments/dac.py index 8fab0a3..6c8eef3 100644 --- a/src/damaris/experiments/dac.py +++ b/src/damaris/experiments/dac.py @@ -2,7 +2,7 @@ """ This module holds everything connected with the DAC and PFG """ -def conv(I_out=0): +def conv(I_out: float = 0) -> int: """ converts the demanded Output current in Integer """ diff --git a/src/damaris/gui/BackendDriver.py b/src/damaris/gui/BackendDriver.py index a5f899d..57bf19a 100644 --- a/src/damaris/gui/BackendDriver.py +++ b/src/damaris/gui/BackendDriver.py @@ -120,7 +120,7 @@ class BackendDriver(threading.Thread): self.statefilename=None state_files=glob.glob(os.path.join(self.spool_dir,"*.state")) while len(state_files)==0: - if timeout<0 or self.core_input is None or self.core_input.poll() is not None or self.quit_flag.isSet(): + if timeout<0 or self.core_input is None or self.core_input.poll() is not None or self.quit_flag.is_set(): # look into core log file and include contents logger.error("Backend failed to start: timeout=%s poll=%s", timeout, self.core_input.poll()) log_message='' @@ -156,10 +156,10 @@ class BackendDriver(threading.Thread): break # wait on flag and look after backend - while not self.quit_flag.isSet() and self.is_busy(): + while not self.quit_flag.is_set() and self.is_busy(): self.quit_flag.wait(0.1) - if self.quit_flag.isSet(): + if self.quit_flag.is_set(): self.stop_queue() while self.is_busy(): time.sleep(0.1) diff --git a/src/damaris/gui/DamarisGUI.py b/src/damaris/gui/DamarisGUI.py index d840235..399dcfa 100644 --- a/src/damaris/gui/DamarisGUI.py +++ b/src/damaris/gui/DamarisGUI.py @@ -17,6 +17,7 @@ import tempfile import gi gi.require_version('Gtk', '3.0') gi.require_version('GtkSource', '3.0') +gi.require_version('GdkPixbuf', '2.0') # import 3rd party modules # gui graphics @@ -24,6 +25,7 @@ from gi.repository import GObject as gobject import sqlite3 import uuid +from gi.repository import GLib as glib from gi.repository import Gtk as gtk gtk_version_missmatch = gtk.check_version( 3, 0, 0 ) @@ -31,9 +33,13 @@ if gtk_version_missmatch: raise Exception( "insufficient gtk version: " + gtk_version_missmatch ) from gi.repository import Gdk as gdk +from gi.repository import GdkPixbuf -from gi.repository import GtkSource as gtksourceview2 -gobject.type_register(gtksourceview2.View) +from gi.repository import GtkSource as gtksourceview + +# referencing the class registers the GtkSourceView GType, which GtkBuilder +# needs to resolve the type when loading damaris3.glade +_ = gtksourceview.View from gi.repository import Pango as pango @@ -42,6 +48,9 @@ import numpy # math graphics import matplotlib +import cairo +from matplotlib import cbook +from matplotlib.transforms import Bbox # force noninteractive matplotlib.rcParams[ "interactive" ] = "False" matplotlib.rcParams[ "text.usetex" ] = "False" @@ -52,6 +61,31 @@ from matplotlib.backends.backend_gtk3agg import FigureCanvasGTK3Agg as FigureCan max_points_to_display = 0 + +def apply_font_css(widget, font_string): + """ + Non-deprecated replacement for the deprecated widget.modify_font(): + converts a pango font description string (family, optional size in pt, + optional weight) into a CSS rule applied to the widget's style context. + + Examples: + >>> apply_font_css(textview, "Monospace 10") + >>> apply_font_css(label, "sans bold 16") + """ + fd = pango.FontDescription(font_string) + decl = "font-family: %s;" % fd.get_family() + if fd.get_size() > 0 and not fd.get_size_is_absolute(): + decl += " font-size: %.6gpt;" % (fd.get_size() / float(pango.SCALE)) + if fd.get_weight() != pango.Weight.NORMAL: + decl += " font-weight: %d;" % fd.get_weight() + provider = gtk.CssProvider() + provider.load_from_data(("%s { %s }" % (widget.get_css_name(), decl)).encode("utf-8")) + context = widget.get_style_context() + context.add_provider(provider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + # keep a reference so the provider is not garbage collected + setattr(widget, "_damaris_font_css_provider", provider) + + import matplotlib.axes import matplotlib.figure @@ -61,7 +95,7 @@ from damaris.gui import ResultReader, ResultHandling from damaris.gui import BackendDriver #from damaris.gui.gtkcodebuffer import CodeBuffer, SyntaxLoader #from damaris.data import Drawable # this is a base class, it should be used... -from damaris.data import DataPool, Accumulation, ADC_Result, MeasurementResult, TemperatureResult +from damaris.data import DataPool, Accumulation, ADC_Result, MeasurementResult, TemperatureResult, FitResult # TemperatureMonitor imported lazily to avoid circular import issues # default, can be set to true from start script @@ -183,7 +217,7 @@ class DamarisGUI: if exp_script and res_script: self.start_immediately = start_immediately else: - raise "RuntimeError" + raise RuntimeError("start_immediately requires both experiment and result script") else: self.start_immediately = False self.statusbar_init( ) @@ -267,12 +301,10 @@ class DamarisGUI: def run( self ): # prolong lifetime of clipboard till the very end (avoid error message) self.main_clipboard = self.sw.main_clipboard - gdk.threads_enter( ) if self.start_immediately: self.toolbar_run_button.emit("clicked") gtk.main( ) - gdk.threads_leave( ) self.si = None self.sw = None @@ -590,8 +622,9 @@ class DamarisGUI: logger.debug("Data cleanup took %.3fs", time.time() - data_cleanup_start) # set the text mark for hdf logging - if self.log.textbuffer.get_mark( "lastdumped" ) is None: - self.log.textbuffer.create_mark( "lastdumped", self.log.textbuffer.get_end_iter( ), left_gravity=True ) + with self.log.lock: + if self.log.textbuffer.get_mark( "lastdumped" ) is None: + self.log.textbuffer.create_mark( "lastdumped", self.log.textbuffer.get_end_iter( ), left_gravity=True ) loop_run=0 #user shorter interval because this blocks user events from the GUI @@ -714,9 +747,9 @@ class DamarisGUI: self.save_thread = None self.dump_filename = "" if actual_config[ "data_pool_name" ] != "": - self.dump_states( init=True ) + self.dump_states( init=True, logtext=self.log.snapshot_since_last_dump( ) ) - gobject.timeout_add( 200, self.observe_running_experiment ) + glib.timeout_add( 200, self.observe_running_experiment ) def observe_running_experiment( self ): """ @@ -822,14 +855,12 @@ class DamarisGUI: dump_size = os.stat( self.dump_filename ).st_size / 1e6 logger.info("Done (%.1f s, %.1f MB)", time.time( ) - self.dump_start_time, dump_size) - gdk.threads_enter( ) if e_text: self.experiment_script_statusbar_label.set_text( e_text ) if r_text: self.data_handling_statusbar_label.set_text( r_text ) if b_text: self.backend_statusbar_label.set_text( b_text ) - gdk.threads_leave( ) still_running = [_f for _f in [ self.si.exp_handling, self.si.res_handling, self.si.back_driver, self.dump_thread ] if _f] if len( still_running ) == 0: @@ -838,24 +869,22 @@ class DamarisGUI: if self.save_thread is None and self.dump_filename != "": logger.info("All subprocesses ended, saving data pool") # Disable run button during data saving to prevent UI confusion - gdk.threads_enter() self.toolbar_run_button.set_sensitive(False) - gdk.threads_leave() - # thread to save data... - self.save_thread = threading.Thread( target=self.dump_states, name="dump states" ) + # thread to save data... snapshot log on the main thread first + self.save_thread = threading.Thread( target=self.dump_states, + args=(False, self.log.snapshot_since_last_dump( )), + name="dump states" ) self.save_thread.start( ) self.dump_start_time = time.time( ) self.dump_dots = 0 self.state = DamarisGUI.Stop_State if self.state == DamarisGUI.Stop_State: - gdk.threads_enter( ) self.toolbar_pause_button.set_sensitive( False ) self.toolbar_stop_button.set_sensitive( False ) - gdk.threads_leave( ) if len( still_running ) != 0: - logger.warning("Subprocess(es) still running: %s", ', '.join( [s.getName( ) for s in still_running] )) + logger.warning("Subprocess(es) still running: %s", ', '.join( [s.name for s in still_running] )) return True else: if self.save_thread is not None: @@ -871,6 +900,11 @@ class DamarisGUI: dump_size = os.stat( self.dump_filename ).st_size / 1e6 logger.info("Done (%.1f s, %.1f MB)", time.time( ) - self.dump_start_time, dump_size) + # close hdf5 files that the scripts left open (e.g. after a + # script failure), otherwise pytables keeps them registered + # as open and they block the next experiment run + self.close_left_open_hdf5_files( ) + # keep data to display but throw away everything else self.si = None # delete lock file so that other experiment can start @@ -878,12 +912,10 @@ class DamarisGUI: # now everything is stopped self.state = DamarisGUI.Edit_State - gdk.threads_enter( ) self.sw.enable_editing( ) self.toolbar_run_button.set_sensitive( True ) self.toolbar_stop_button.set_sensitive( False ) self.toolbar_pause_button.set_sensitive( False ) - gdk.threads_leave( ) return False # dump states? @@ -893,16 +925,19 @@ class DamarisGUI: logger.info("Dumping data pool") self.dump_start_time = time.time( ) self.dump_dots = 0 - # thread to save data... - self.dump_thread = threading.Thread( target=self.dump_states, name="dump states" ) + # thread to save data... snapshot log on the main thread first + self.dump_thread = threading.Thread( target=self.dump_states, + args=(False, self.log.snapshot_since_last_dump( )), + name="dump states" ) self.dump_thread.start( ) # or look at them again return True - def dump_states( self, init=False ): + def dump_states( self, init=False, logtext=None ): """ init: constructs basic structure of this file + logtext: pending log window text, captured on the GTK main thread compress: optional argument for zlib compression 0-9 """ @@ -976,128 +1011,138 @@ class DamarisGUI: # now it's time to create the hdf file dump_file = None - if not os.path.isfile( self.dump_filename ): - if not init: - logger.warning("Dump file \"%s\" vanished unexpectedly, creating new one", self.dump_filename) - # have a look to the path and create necessary directories - dir_stack = [ ] - dir_trunk = os.path.dirname( os.path.abspath( self.dump_filename ) ) - while dir_trunk != "" and not os.path.isdir( dir_trunk ): - dir_stack.append( os.path.basename( dir_trunk ) ) - dir_trunk = os.path.dirname( dir_trunk ) - try: - while len( dir_stack ): - dir_trunk = os.path.join( dir_trunk, dir_stack.pop( ) ) - if os.path.isdir( dir_trunk ): - continue - os.mkdir( dir_trunk ) - except OSError as e: - logger.error("Could not create dump directory '%s', so hdf5 dumps disabled: %s", dir_trunk, e) - self.dump_filename = "" - self.dump_timeinterval = 0 - return True + try: + if not os.path.isfile( self.dump_filename ): + if not init: + logger.warning("Dump file \"%s\" vanished unexpectedly, creating new one", self.dump_filename) + # have a look to the path and create necessary directories + dir_stack = [ ] + dir_trunk = os.path.dirname( os.path.abspath( self.dump_filename ) ) + while dir_trunk != "" and not os.path.isdir( dir_trunk ): + dir_stack.append( os.path.basename( dir_trunk ) ) + dir_trunk = os.path.dirname( dir_trunk ) + try: + while len( dir_stack ): + dir_trunk = os.path.join( dir_trunk, dir_stack.pop( ) ) + if os.path.isdir( dir_trunk ): + continue + os.mkdir( dir_trunk ) + except OSError as e: + logger.error("Could not create dump directory '%s', so hdf5 dumps disabled: %s", dir_trunk, e) + self.dump_filename = "" + self.dump_timeinterval = 0 + return True - # create new dump file - dump_file = tables.open_file( self.dump_filename, mode="w", title="DAMARIS experiment data" ) - # write scripts and other useful information - scriptgroup = dump_file.create_group( "/", "scripts", "Used Scripts" ) - exp_text, res_text = self.sw.get_scripts( ) - if self.si.exp_script: - dump_file.create_array( scriptgroup, "experiment_script", exp_text.encode('utf-8') ) - if self.si.res_script: - dump_file.create_array( scriptgroup, "result_script", res_text.encode('utf-8') ) - if self.si.backend_executable: - dump_file.create_array( scriptgroup, "backend_executable", self.si.backend_executable.encode('utf-8') ) - if self.spool_dir: - dump_file.create_array( scriptgroup, "spool_directory", self.spool_dir.encode('utf-8') ) - timeline_tablecols = numpy.recarray( 0, dtype=([ ("time", "S17"), - ("experiments", "int64"), - ("results", "int64") ]) ) - timeline_table = dump_file.create_table( "/", "timeline", timeline_tablecols, - title="Timeline of Experiment" ) - if tables.__version__[ 0 ] == "1": - logarray = dump_file.create_vlarray( where=dump_file.root, - name="log", - atom=tables.StringAtom( length=120 ), - title="log messages", - filters=tables.Filters( complevel=9, complib='zlib' ) ) - else: - logarray = dump_file.create_earray( where=dump_file.root, - name="log", - atom=tables.StringAtom( itemsize=120 ), - shape=(0,), - title="log messages", - filters=tables.Filters( complevel=9, complib='zlib' ) ) + # create new dump file + dump_file = tables.open_file( self.dump_filename, mode="w", title="DAMARIS experiment data" ) + # write scripts and other useful information + scriptgroup = dump_file.create_group( "/", "scripts", "Used Scripts" ) + exp_text, res_text = self.sw.get_scripts( ) + if self.si.exp_script: + dump_file.create_array( scriptgroup, "experiment_script", exp_text.encode('utf-8') ) + if self.si.res_script: + dump_file.create_array( scriptgroup, "result_script", res_text.encode('utf-8') ) + if self.si.backend_executable: + dump_file.create_array( scriptgroup, "backend_executable", self.si.backend_executable.encode('utf-8') ) + if self.spool_dir: + dump_file.create_array( scriptgroup, "spool_directory", self.spool_dir.encode('utf-8') ) + timeline_tablecols = numpy.recarray( 0, dtype=([ ("time", "S17"), + ("experiments", "int64"), + ("results", "int64") ]) ) + timeline_table = dump_file.create_table( "/", "timeline", timeline_tablecols, + title="Timeline of Experiment" ) + if tables.__version__[ 0 ] == "1": + logarray = dump_file.create_vlarray( where=dump_file.root, + name="log", + atom=tables.StringAtom( length=120 ), + title="log messages", + filters=tables.Filters( complevel=9, complib='zlib' ) ) + else: + logarray = dump_file.create_earray( where=dump_file.root, + name="log", + atom=tables.StringAtom( itemsize=120 ), + shape=(0,), + title="log messages", + filters=tables.Filters( complevel=9, complib='zlib' ) ) - if dump_file is None and os.path.isfile( self.dump_filename ) and tables.is_pytables_file( self.dump_filename ): - # prepare for update - dump_file = tables.open_file( self.dump_filename, mode="r+" ) + if dump_file is None and os.path.isfile( self.dump_filename ) and tables.is_pytables_file( self.dump_filename ): + # prepare for update + dump_file = tables.open_file( self.dump_filename, mode="r+" ) - if dump_file is None: - # exit! - logger.error("Could not create dump directory '%s', so hdf5 dumps disabled", dir_trunk) - self.dump_filename = "" - self.dump_timeinterval = 0 - return True + if dump_file is None: + # exit! + logger.error("Could not create dump directory '%s', so hdf5 dumps disabled", dir_trunk) + self.dump_filename = "" + self.dump_timeinterval = 0 + return True - # no undo please! - if dump_file.is_undo_enabled( ): - dump_file.disable_undo( ) + # no undo please! + if dump_file.is_undo_enabled( ): + dump_file.disable_undo( ) - # save the data! - if self.data is not None: - self.data.write_hdf5( dump_file, where="/", name="data_pool", - complib=self.dump_complib, complevel=self.dump_complevel, - incremental=not init, write_interval=self.dump_timeinterval ) + # save the data! + if self.data is not None: + self.data.write_hdf5( dump_file, where="/", name="data_pool", + complib=self.dump_complib, complevel=self.dump_complevel, + incremental=not init, write_interval=self.dump_timeinterval ) - # Re-enable run button if this was called from save thread - # NO: this is dangerous as it allows starting a new experiment before cleanup is finished. - # it will be re-enabled in observe_running_experiment after joining this thread. - # if not init and threading.current_thread().name == "dump states": - # gdk.threads_enter() - # self.toolbar_run_button.set_sensitive(True) - # gdk.threads_leave() + # Re-enable run button if this was called from save thread + # NO: this is dangerous as it allows starting a new experiment before cleanup is finished. + # it will be re-enabled in observe_running_experiment after joining this thread. + # if not init and threading.current_thread().name == "dump states": + # gdk.threads_enter() + # self.toolbar_run_button.set_sensitive(True) + # gdk.threads_leave() - # now save additional information - e = 0 - r = 0 - if self.si is not None: - e = self.si.data.get( "__recentexperiment", -1 ) + 1 - r = self.si.data.get( "__recentresult", -1 ) + 1 - timeline_table = dump_file.root.timeline - timeline_row = timeline_table.row - timeline_row[ "time" ] = time.strftime( "%Y%m%d %H:%M:%S" ) - timeline_row[ "experiments" ] = e - timeline_row[ "results" ] = r - timeline_row.append( ) - timeline_table.flush( ) + # now save additional information + e = 0 + r = 0 + if self.si is not None: + e = self.si.data.get( "__recentexperiment", -1 ) + 1 + r = self.si.data.get( "__recentresult", -1 ) + 1 + timeline_table = dump_file.root.timeline + timeline_row = timeline_table.row + timeline_row[ "time" ] = time.strftime( "%Y%m%d %H:%M:%S" ) + timeline_row[ "experiments" ] = e + timeline_row[ "results" ] = r + timeline_row.append( ) + timeline_table.flush( ) - # save log window information: - # also save backend's logfile information? - logtextbuffer = self.log.textbuffer - last_end = logtextbuffer.get_mark( "lastdumped" ) - if last_end is None: - last_end = logtextbuffer.create_mark( "lastdumped", logtextbuffer.get_start_iter( ), left_gravity=True ) - #logtext_start = logtextbuffer.get_iter_at_mark( last_end ) - logtext_end = logtextbuffer.get_end_iter( ) - logtextbuffer.move_mark( last_end, logtext_end ) - # Create new iterators after the mark move - logtext_start = logtextbuffer.get_iter_at_mark( last_end ) - logtext_end = logtextbuffer.get_end_iter( ) - # recode from unicode - logtext = logtextbuffer.get_text( logtext_start, logtext_end, True ) - # avoid circular references (seems to be necessary with gtk-2.12) - # del logtextbuffer, logtext_start, logtext_end, last_end - for l in logtext.splitlines( ): - dump_file.root.log.append( numpy.array( [ l ], dtype="S120" ) ) + # save log window information: + # also save backend's logfile information? + # logtext was snapshotted on the GTK main thread before spawning + if logtext is not None: + for l in logtext.splitlines( ): + dump_file.root.log.append( numpy.array( [ l ], dtype="S120" ) ) - dump_file.flush( ) - dump_file.close( ) + dump_file.flush( ) + finally: + if dump_file is not None: + dump_file.close( ) self.last_dumped = time.time( ) - del dump_file return True + def close_left_open_hdf5_files( self ): + """ + close hdf5 files that experiment or result scripts left open, + e.g. after a script failure; pytables keeps such files registered + as open, so they cannot be reopened in the next experiment run + """ + open_files = getattr( tables.file, "_open_files", None ) + if open_files is None: + return + if hasattr( open_files, "handlers" ): + filehandles = list( open_files.handlers ) + else: + filehandles = list( open_files.values( ) ) + for filehandle in filehandles: + try: + logger.warning( "Closing hdf5 file left open by script: %s", filehandle.filename ) + filehandle.close( ) + except Exception: + pass + def pause_experiment( self, widget, data=None ): """ pause experiment execution (that means delay backend and let others run) @@ -1236,15 +1281,31 @@ class DamarisGUI: def documentation_init( self ): - self.doc_urls = { - "DAMARIS Manual": "file:///usr/share/doc/python3-damaris/doc/_build/html/index.html", - } + doc_index_candidates = ( + "/usr/share/doc/python3-damaris/html/index.html", + os.path.join( os.path.dirname( os.path.dirname( os.path.dirname( + os.path.dirname( os.path.abspath( __file__ ) ) ) ) ), + "doc", "_build", "html", "index.html" ), + ) + + self.doc_urls = { } + for doc_index in doc_index_candidates: + if os.path.isfile( doc_index ): + self.doc_urls[ "DAMARIS Manual" ] = "file://" + doc_index + break if os.path.isdir( "/usr/share/doc/python%d.%d-doc/html" % (sys.version_info[ :2 ]) ): self.doc_urls[ "Python Docs" ] = "file:///usr/share/doc/python%d.%d-doc/html/index.html" % ( sys.version_info[ :2 ]) - doc_index_url = None + doc_menu = self.xml_gui.get_object( "documentation_menu" ) + for doc_title in self.doc_urls: + doc_menu_item = gtk.MenuItem( label=doc_title ) + doc_menu_item.connect( "activate", self.show_doc_menu ) + doc_menu.append( doc_menu_item ) + doc_menu_item.show( ) + self.xml_gui.get_object( "documentation" ).set_menu( doc_menu ) + self.doc_browser = None def show_doc_menu( self, widget, data=None ): @@ -1254,7 +1315,7 @@ class DamarisGUI: if type( widget ) is gtk.MenuItem: requested_doc = widget.get_child( ).get_text( ) else: - requested_doc = "Python DAMARIS" + requested_doc = "DAMARIS Manual" if requested_doc in self.doc_urls and self.doc_urls[ requested_doc ] is not None: if self.doc_browser is not None: @@ -1312,7 +1373,7 @@ class LogWindow: """Manages the application log window. Provides a timestamped, scrollable text view for application messages. - Messages are dispatched via ``gobject.idle_add`` to keep the GTK main + Messages are dispatched via ``glib.idle_add`` to keep the GTK main loop responsive. Args: @@ -1334,15 +1395,15 @@ class LogWindow: self.damaris_gui = damaris_gui self.textview = self.xml_gui.get_object( "messages_textview" ) self.textview.connect( "key-press-event", self.textview_keypress) - font_desc = pango.FontDescription("Monospace 10") - self.textview.modify_font(font_desc) + apply_font_css(self.textview, "Monospace 10") self.textbuffer = self.textview.get_buffer( ) + self.lock = threading.RLock( ) self.last_timetag = None self( "Started in directory %s\n" % os.getcwd( ) ) def __call__( self, message ): timetag = time.time( ) - gobject.idle_add( self.add_message_callback, timetag, message, priority=gobject.PRIORITY_LOW ) + glib.idle_add( self.add_message_callback, timetag, message, priority=glib.PRIORITY_LOW ) def add_message_callback( self, timetag, message ): date_tag = "" @@ -1351,11 +1412,30 @@ class LogWindow: timetag_int = math.floor( timetag ) timetag_ms = int( (timetag - timetag_int) * 1000 ) date_tag = time.strftime( "%Y%m%d %H:%M:%S.%%03d:\n", time.localtime( timetag_int ) ) % timetag_ms - gdk.threads_enter( ) - self.textbuffer.place_cursor( self.textbuffer.get_end_iter( ) ) - self.textbuffer.insert_at_cursor( date_tag + str( message ) ) - self.textview.scroll_to_mark( self.textbuffer.get_insert( ), 0.1 , False, 0.5, 0.5) - gdk.threads_leave( ) + with self.lock: + self.textbuffer.place_cursor( self.textbuffer.get_end_iter( ) ) + self.textbuffer.insert_at_cursor( date_tag + str( message ) ) + self.textview.scroll_to_mark( self.textbuffer.get_insert( ), 0.1 , False, 0.5, 0.5) + + def snapshot_since_last_dump( self ): + """Return log text appended since the last dump and advance the mark. + + Must be called from the GTK main thread. The result is plain Python + data that can safely be passed to the dump thread. + + Examples: + >>> logtext = self.log.snapshot_since_last_dump( ) + """ + with self.lock: + tb = self.textbuffer + mark = tb.get_mark( "lastdumped" ) + if mark is None: + mark = tb.create_mark( "lastdumped", tb.get_start_iter( ), left_gravity=True ) + start = tb.get_iter_at_mark( mark ) + end = tb.get_end_iter( ) + text = tb.get_text( start, end, True ) + tb.move_mark( mark, end ) + return text def textview_keypress( self, widget, event, data=None ): if event.state & gdk.ModifierType.CONTROL_MASK != 0: @@ -1388,9 +1468,9 @@ class ScriptWidgets: experiment script file. res_script_filename (str or None): Path to the currently loaded result script file. - experiment_script_textview (:class:`gtksourceview2.View`): Text view + experiment_script_textview (:class:`gtksourceview.View`): Text view for the experiment script. - data_handling_textview (:class:`gtksourceview2.View`): Text view for + data_handling_textview (:class:`gtksourceview.View`): Text view for the result script. Public Methods: @@ -1411,7 +1491,7 @@ class ScriptWidgets: def __init__( self, xml_gui, outer_space ): """Initialize text widgets with syntax highlighting and completion. - Sets up two :class:`gtksourceview2.View` instances with Python + Sets up two :class:`gtksourceview.View` instances with Python syntax highlighting, keyword auto-completion, line numbers, and smart indentation. Connects text buffer signals to toolbar and status bar update handlers. @@ -1438,10 +1518,10 @@ class ScriptWidgets: self.data_handling_textview = self.xml_gui.get_object( "data_handling_textview" ) # create and set syntax-highlighting text-buffers as textview backends - self.experiment_script_textbuffer = gtksourceview2.Buffer() - self.data_handling_textbuffer = gtksourceview2.Buffer() + self.experiment_script_textbuffer = gtksourceview.Buffer() + self.data_handling_textbuffer = gtksourceview.Buffer() - lm = gtksourceview2.LanguageManager() + lm = gtksourceview.LanguageManager() langpython = lm.get_language("python") self.experiment_script_textbuffer.set_language(langpython) @@ -1449,22 +1529,20 @@ class ScriptWidgets: self.data_handling_textbuffer.set_language(langpython) self.data_handling_textbuffer.set_highlight_syntax(True) - fontdesc = pango.FontDescription("monospace") - self.experiment_script_textview.set_buffer( self.experiment_script_textbuffer ) self.experiment_script_textview.set_show_line_numbers(True) self.experiment_script_textview.set_auto_indent(True) self.experiment_script_textview.set_insert_spaces_instead_of_tabs(True) self.experiment_script_textview.set_tab_width(4) self.experiment_script_textview.set_smart_home_end(True) - self.experiment_script_textview.modify_font(fontdesc) + apply_font_css(self.experiment_script_textview, "monospace") self.data_handling_textview.set_buffer( self.data_handling_textbuffer ) self.data_handling_textview.set_show_line_numbers(True) self.data_handling_textview.set_auto_indent(True) self.data_handling_textview.set_insert_spaces_instead_of_tabs(True) self.data_handling_textview.set_tab_width(4) self.data_handling_textview.set_smart_home_end(True) - self.data_handling_textview.modify_font(fontdesc) + apply_font_css(self.data_handling_textview, "monospace") keywords = """for if else elif in print try finally except global lambda not or pass def class import from as return yield while continue break assert None True False AccumulatedValue @@ -1477,7 +1555,7 @@ set_pfg set_dac set_phase set_pts_local get_length set_title get_title add_linep add_errorplotdata get_number_of_channels get_style set_style get_xlabel set_xlabel get_ylabel set_ylabel""" - self.keywordsbuffer = gtksourceview2.Buffer() + self.keywordsbuffer = gtksourceview.Buffer() self.keywordsbuffer.set_text(keywords) compexp = self.experiment_script_textview.get_completion() @@ -1486,15 +1564,15 @@ get_ylabel set_ylabel""" compexp.set_property("show-headers", True) compres.set_property("show-headers", True) - self.compwgen = gtksourceview2.CompletionWords.new("Keywords", None) + self.compwgen = gtksourceview.CompletionWords.new("Keywords", None) self.compwgen.register(self.keywordsbuffer) self.compwgen.set_property("minimum-word-size", 2) - self.compwexp = gtksourceview2.CompletionWords.new("Words", None) + self.compwexp = gtksourceview.CompletionWords.new("Words", None) self.compwexp.register(self.experiment_script_textbuffer) self.compwexp.set_property("minimum-word-size", 2) - self.compwres = gtksourceview2.CompletionWords.new("Words", None) + self.compwres = gtksourceview.CompletionWords.new("Words", None) self.compwres.register(self.data_handling_textbuffer) self.compwres.set_property("minimum-word-size", 2) @@ -1726,7 +1804,7 @@ get_ylabel set_ylabel""" def textviews_modified( self, widget = None, event=None ): # mix into toolbar affairs - if type(widget) is gtksourceview2.View: + if type(widget) is gtksourceview.View: return self.textviews_moved( widget ) else: return False @@ -1918,7 +1996,6 @@ get_ylabel set_ylabel""" parent_window = self.xml_gui.get_object( "main_window" ) dialog = gtk.FileChooserDialog( title=open_dialog_title, parent=parent_window, - action=gtk.FileChooserAction.OPEN, buttons=("_Open", gtk.ResponseType.OK, "_Cancel", gtk.ResponseType.CANCEL) ) dialog.set_default_response( gtk.ResponseType.OK ) @@ -1944,7 +2021,6 @@ get_ylabel set_ylabel""" parent_window = self.xml_gui.get_object( "main_window" ) dialog = gtk.FileChooserDialog( title="Open HDF5 File...", parent=parent_window, - action=gtk.FileChooserAction.OPEN, buttons=("_Open", gtk.ResponseType.OK, "_Cancel", gtk.ResponseType.CANCEL) ) dialog.set_default_response( gtk.ResponseType.OK ) @@ -2106,8 +2182,8 @@ get_ylabel set_ylabel""" parent_window = self.xml_gui.get_object( "main_window" ) dialog = gtk.FileChooserDialog( title=dialog_title, parent=parent_window, - action=gtk.FileChooserAction.SAVE, buttons=("_Save", gtk.ResponseType.OK, "_Cancel", gtk.ResponseType.CANCEL) ) + dialog.set_property("action", gtk.FileChooserAction.SAVE) dialog.set_default_response( gtk.ResponseType.OK ) dialog.set_select_multiple( False ) @@ -2369,7 +2445,7 @@ class ConfigTab: self.config_data_pool_write_interval_entry.connect("key-press-event", self.config_keypress) # ADC bit depth setting - self.config_adc_bit_depth_hbox = gtk.HBox(homogeneous=False, spacing=6) + self.config_adc_bit_depth_hbox = gtk.Box(orientation=gtk.Orientation.HORIZONTAL, spacing=6) self.config_adc_bit_depth_label = gtk.Label(label="ADC bit depth:") self.config_adc_bit_depth_spinbutton = gtk.SpinButton.new_with_range(8, 32, 1) self.config_adc_bit_depth_spinbutton.set_value(ADC_Result.default_bit_depth) @@ -2384,7 +2460,7 @@ class ConfigTab: self.config_adc_bit_depth_hbox.show_all() # Temperature monitoring settings - single line layout - self.config_temp_hbox = gtk.HBox(homogeneous=False, spacing=6) + self.config_temp_hbox = gtk.Box(orientation=gtk.Orientation.HORIZONTAL, spacing=6) # Controller type selector self.config_temp_controller_type_label = gtk.Label(label="Controller:") @@ -2395,7 +2471,7 @@ class ConfigTab: self.config_temp_controller_type_combobox.set_tooltip_text("Select temperature controller type") self.config_temp_controller_type_combobox.connect("changed", self.on_temp_controller_type_changed) - self.config_temp_controller_type_hbox = gtk.HBox(homogeneous=False, spacing=0) + self.config_temp_controller_type_hbox = gtk.Box(orientation=gtk.Orientation.HORIZONTAL, spacing=0) self.config_temp_controller_type_hbox.pack_start(self.config_temp_controller_type_label, False, False, 0) self.config_temp_controller_type_hbox.pack_start(self.config_temp_controller_type_combobox, False, False, 0) self.config_temp_hbox.pack_start(self.config_temp_controller_type_hbox, False, False, 0) @@ -2414,7 +2490,7 @@ class ConfigTab: self.config_temp_device_button.set_tooltip_text("Browse for temperature controller device") self.config_temp_device_button.connect("clicked", self.on_temp_device_browse_clicked) - self.config_temp_device_hbox = gtk.HBox(homogeneous=False, spacing=0) + self.config_temp_device_hbox = gtk.Box(orientation=gtk.Orientation.HORIZONTAL, spacing=0) self.config_temp_device_hbox.pack_start(self.config_temp_device_entry, True, True, 0) self.config_temp_device_hbox.pack_start(self.config_temp_device_button, False, False, 0) self.config_temp_hbox.pack_start(self.config_temp_device_hbox, True, True, 0) @@ -2426,7 +2502,7 @@ class ConfigTab: self.config_temp_interval_spinbutton.set_tooltip_text("Time between temperature readings in seconds") self.config_temp_interval_spinbutton.connect("key-press-event", self.config_keypress) - self.config_temp_interval_hbox = gtk.HBox(homogeneous=False, spacing=6) + self.config_temp_interval_hbox = gtk.Box(orientation=gtk.Orientation.HORIZONTAL, spacing=6) self.config_temp_interval_hbox.pack_start(self.config_temp_interval_label, False, False, 0) self.config_temp_interval_hbox.pack_start(self.config_temp_interval_spinbutton, False, False, 0) self.config_temp_hbox.pack_start(self.config_temp_interval_hbox, False, False, 0) @@ -2440,7 +2516,7 @@ class ConfigTab: self.config_temp_baudrate_combobox.append_text("115200") self.config_temp_baudrate_combobox.set_active(0) self.config_temp_baudrate_combobox.set_tooltip_text("Serial baud rate for temperature controller") - self.config_temp_baudrate_hbox = gtk.HBox(homogeneous=False, spacing=6) + self.config_temp_baudrate_hbox = gtk.Box(orientation=gtk.Orientation.HORIZONTAL, spacing=6) self.config_temp_baudrate_hbox.pack_start(self.config_temp_baudrate_combobox, False, False, 0) self.config_temp_hbox.pack_start(self.config_temp_baudrate_hbox, False, False, 0) # Note: baud rate is shown for eurotherm, hidden for simulated @@ -2649,14 +2725,14 @@ pygobject version %(pygobject)s if "data_pool_complib" in config: # find combo-box entry and make it active... model = self.config_data_pool_complib.get_model( ) - iter = model.get_iter_first( ) - while iter is not None: - if model.get( iter, 0 )[ 0 ] == config[ "data_pool_complib" ]: - self.config_data_pool_complib.set_active_iter( iter ) + treeiter = model.get_iter_first( ) + while treeiter is not None: + if model.get( treeiter, 0 )[ 0 ] == config[ "data_pool_complib" ]: + self.config_data_pool_complib.set_active_iter( treeiter ) break - iter = model.iter_next( iter ) + treeiter = model.iter_next( treeiter ) # if this compression method is not supported, warn and do nothing - if iter is None: + if treeiter is None: logger.warning("Compression method %s is not supported", config[ "data_pool_complib" ]) def on_adc_bit_depth_changed(self, widget): @@ -2671,11 +2747,9 @@ pygobject version %(pygobject)s title="Select Temperature Controller Device", parent=None, action=gtk.FileChooserAction.OPEN, - buttons=( - gtk.STOCK_CANCEL, gtk.ResponseType.CANCEL, - gtk.STOCK_OPEN, gtk.ResponseType.OK - ) ) + dialog.add_button("Cancel", gtk.ResponseType.CANCEL) + dialog.add_button("Open", gtk.ResponseType.OK) # Set default folder to /dev/serial/by-id/ if it exists default_folder = "/dev/serial/by-id/" @@ -2735,8 +2809,8 @@ pygobject version %(pygobject)s if model is None: return for i in range(model.iter_n_children(None)): - iter = model.iter_nth_child(None, i) - if iter and model.get(iter, 0)[0] == text: + treeiter = model.iter_nth_child(None, i) + if treeiter and model.get(treeiter, 0)[0] == text: combobox.set_active(i) return @@ -2767,10 +2841,10 @@ pygobject version %(pygobject)s font = self.config_script_font_button.get_font_name( ) experiment_script_textview = self.xml_gui.get_object( "experiment_script_textview" ) if experiment_script_textview: - experiment_script_textview.modify_font( pango.FontDescription( font ) ) + apply_font_css(experiment_script_textview, font) data_handling_textview = self.xml_gui.get_object( "data_handling_textview" ) if data_handling_textview: - data_handling_textview.modify_font( pango.FontDescription( font ) ) + apply_font_css(data_handling_textview, font) def browse_backend_executable_dialog( self, widget ): """ @@ -2907,6 +2981,189 @@ pygobject version %(pygobject)s configfile.write( "\n" ) +# crosshair icon for the display toolbar, drawn from inline XPM data +# so it does not depend on an installed icon theme +crosshair_xpm = ( + "24 24 2 1", + " c None", + ". c #cc0000", + " .. ", + " .. ", + " .. ", + " .. ", + " .. ", + " ", + " ", + " ...... ", + " . . ", + " . . ", + " . .... . ", + "..... . .... . .....", + "..... . .... . .....", + " . .... . ", + " . . ", + " . . ", + " ...... ", + " ", + " ", + " .. ", + " .. ", + " .. ", + " .. ", + " .. ", + ) + + +class BlitSafeCanvas( FigureCanvas ): + """ + GTK3Agg canvas that paints blitted regions immediately. + + The stock blit appends the region to an internal bbox queue and the next + paint then redraws only that region; a full redraw (data update, zoom) + happening meanwhile is clipped to it, wiping the axis labels from the + screen. Painting the region right away keeps every regular paint a + complete one and stays a partial update. + """ + def blit( self, bbox=None ): + if bbox is None: + bbox = self.figure.bbox + window = self.get_window( ) + if window is None: + return + scale = self.device_pixel_ratio + allocation = self.get_allocation( ) + x = int( bbox.x0 / scale ) + y = allocation.height - int( bbox.y1 / scale ) + width = ( int( bbox.x1 ) - int( bbox.x0 ) ) // scale + height = ( int( bbox.y1 ) - int( bbox.y0 ) ) // scale + region = cairo.Region( cairo.RectangleInt( x, y, width, height ) ) + context = window.begin_draw_frame( region ) + try: + buffer = cbook._unmultiplied_rgba8888_to_premultiplied_argb32( + numpy.asarray( self.copy_from_bbox( bbox ) ) ) + image = cairo.ImageSurface.create_for_data( + buffer.ravel( ).data, cairo.FORMAT_ARGB32, + int( bbox.x1 ) - int( bbox.x0 ), int( bbox.y1 ) - int( bbox.y0 ) ) + image.set_device_scale( scale, scale ) + cr = context.get_cairo_context( ) + cr.set_source_surface( image, x, y ) + cr.paint( ) + finally: + window.end_draw_frame( context ) + + +class CrosshairCursor( matplotlib.widgets.Cursor ): + """ + Crosshair cursor with blitting that coalesces motion events to a fixed + frame rate. GTK delivers motion-notify events at the mouse's full + reporting rate (up to 1000 Hz); restoring the plot background for every + single event makes the GUI sluggish, in particular on remote X11 + connections. A frame rate limit is visually indistinguishable. + + Per frame only the thin strips around the old and the new line position + are restored, redrawn and blitted. Blitting the full axes bbox instead + requires a pixel format conversion of several megapixels per frame, + which is far too slow on HiDPI screens. + """ + def __init__( self, ax, frame_rate=60, **lineprops ): + matplotlib.widgets.Cursor.__init__( self, ax, useblit=True, **lineprops ) + self.frame_interval = 1.0 / frame_rate + self.last_frame_time = 0.0 + self._last_x = None + self._last_y = None + + def clear( self, event ): + """Refresh the saved background after a full render.""" + if self.ignore( event ): + return + if self.useblit: + # save the full figure so the saved array stays aligned with + # the canvas buffer and strips can be restored by direct + # array copy (restore_region with a bbox argument addresses + # a different coordinate frame and restores the wrong area) + self.background = self.canvas.copy_from_bbox( + self.canvas.figure.bbox ) + # the render removed the cursor lines from the buffer, but + # the screen still shows them until the repaint arrives; + # motion events processed in between would draw the cursor + # at its new position next to the stale one, so wipe the old + # line positions right away + for strip in ( self._strip_bbox( self._last_x, None, True ), + self._strip_bbox( self._last_y, None, False ) ): + if strip is not None: + self.canvas.blit( strip ) + self.linev.set_visible( False ) + self.lineh.set_visible( False ) + self._last_x = None + self._last_y = None + + def _strip_bbox( self, prev, cur, vertical ): + """Bbox covering the old and new position of one cursor line. + + The strips are padded by a pixel on each side so that integer + truncation downstream never clips a line pixel at the edges. + """ + bbox = self.ax.bbox + positions = [p for p in ( prev, cur ) if p is not None] + if not positions: + return None + if vertical: + lo = max( min( positions ) - 4, bbox.x0 ) + hi = min( max( positions ) + 5, bbox.x1 ) + if hi <= lo: + return None + return Bbox( [[lo, bbox.y0 - 1], [hi, bbox.y1 + 1]] ) + lo = max( min( positions ) - 4, bbox.y0 ) + hi = min( max( positions ) + 5, bbox.y1 ) + if hi <= lo: + return None + return Bbox( [[bbox.x0 - 1, lo], [bbox.x1 + 1, hi]] ) + + def onmove( self, event ): + if self.ignore( event ) or not self.canvas.widgetlock.available( self ): + return + now = time.monotonic( ) + if now - self.last_frame_time < self.frame_interval: + return + self.last_frame_time = now + if self.background is None: + return + if event.inaxes is self.ax and event.xdata is not None and event.ydata is not None: + self.linev.set_xdata( ( event.xdata, event.xdata ) ) + self.lineh.set_ydata( ( event.ydata, event.ydata ) ) + self.linev.set_visible( self.visible and self.vertOn ) + self.lineh.set_visible( self.visible and self.horizOn ) + new_x, new_y = event.x, event.y + else: + # pointer left the plot: hide the lines by restoring the plain + # background, no full render needed + self.linev.set_visible( False ) + self.lineh.set_visible( False ) + new_x = new_y = None + bbox = self.ax.bbox + vstrip = self._strip_bbox( self._last_x, new_x, True ) + hstrip = self._strip_bbox( self._last_y, new_y, False ) + self._last_x, self._last_y = new_x, new_y + # erase the previous lines by copying the strips back from the + # saved background + background = numpy.asarray( self.background ) + width, height = self.canvas.get_width_height( physical=True ) + buffer = numpy.frombuffer( self.canvas.buffer_rgba( ), + dtype=numpy.uint8 ).reshape( height, width, 4 ) + for strip in ( vstrip, hstrip ): + if strip is not None: + rows = slice( height - int( strip.y1 ), + height - int( strip.y0 ) + 1 ) + cols = slice( int( strip.x0 ), int( strip.x1 ) + 1 ) + buffer[rows, cols] = background[rows, cols] + if self.linev.get_visible( ) or self.lineh.get_visible( ): + self.ax.draw_artist( self.linev ) + self.ax.draw_artist( self.lineh ) + for strip in ( vstrip, hstrip ): + if strip is not None: + self.canvas.blit( strip ) + + class MonitorWidgets: """Manages the matplotlib-based data visualization panel. @@ -2996,7 +3253,7 @@ class MonitorWidgets: self.matplot_axes.set_xscale( "linear" ) # add figure to canvas - self.matplot_canvas = FigureCanvas( self.matplot_figure ) + self.matplot_canvas = BlitSafeCanvas( self.matplot_figure ) # add ctrl + right-click context menu to canvas self.matplot_canvas.connect("button-press-event", self.on_canvas_button_press) @@ -3013,13 +3270,10 @@ class MonitorWidgets: self.matplot_toolbar.remove(self.matplot_toolbar.get_nth_item(6)) # now let's add a button to the toolbar self.matplot_cursor_button = gtk.ToggleButton(label='') - try: - icon_theme = gtk.IconTheme.get_default() - icon = icon_theme.load_icon("crosshairs", 24, 0) - image = gtk.Image.new_from_pixbuf(icon) - self.matplot_cursor_button.set_image(image) - except: - pass + cursor_icon = GdkPixbuf.Pixbuf.new_from_xpm_data( crosshair_xpm ) + self.matplot_cursor_button.set_image( gtk.Image.new_from_pixbuf( cursor_icon ) ) + # gtk-button-images is False on many desktops, which would hide the icon + self.matplot_cursor_button.set_always_show_image( True ) self.matplot_cursor_button.set_tooltip_text('Enable Crosshairs') self.matplot_cursor_button.show() self.matplot_cursor_button.connect('toggled', self.toggle_cursor) @@ -3031,7 +3285,7 @@ class MonitorWidgets: try: # The coordinate label is stored in the toolbar's message attribute if hasattr(self.matplot_toolbar, 'message'): - self.matplot_toolbar.message.modify_font(pango.FontDescription("sans bold 16")) + apply_font_css(self.matplot_toolbar.message, "sans bold 16") except: pass @@ -3073,6 +3327,8 @@ class MonitorWidgets: self.update_counter = 0 self.update_counter_lock = threading.Lock( ) self.description_text = None + self.fitresultlines = [ ] + self.fit_source_keys = set( ) self.clipping_marker = None self.source_list_cache = {} @@ -3089,13 +3345,13 @@ class MonitorWidgets: if none_iter is not None: self.display_source_combobox.set_active_iter( none_iter ) - def source_list_find_one( self, model, iter, what ): + def source_list_find_one( self, model, treeiter, what ): """find node in subcategory""" - while iter is not None: - if str( model.get( iter, 0 )[ 0 ] ) == what: - return iter - iter = model.iter_next( iter ) - return iter + while treeiter is not None: + if str( model.get( treeiter, 0 )[ 0 ] ) == what: + return treeiter + treeiter = model.iter_next( treeiter ) + return treeiter def source_list_find( self, namelist ): """ @@ -3122,35 +3378,35 @@ class MonitorWidgets: del namelist[:i] # Continue search from here retval = found_iter - iter = self.display_source_treestore.iter_children( retval ) - while iter is not None and len( namelist ) > 0: + treeiter = self.display_source_treestore.iter_children( retval ) + while treeiter is not None and len( namelist ) > 0: name = namelist[ 0 ] - iter = self.source_list_find_one( self.display_source_treestore, iter, name ) - if iter is not None: - retval = iter + treeiter = self.source_list_find_one( self.display_source_treestore, treeiter, name ) + if treeiter is not None: + retval = treeiter namelist.pop( 0 ) # Update cache partial_path += "/" + name self.source_list_cache[partial_path] = retval - iter = self.display_source_treestore.iter_children( retval ) + treeiter = self.display_source_treestore.iter_children( retval ) return retval else: del self.source_list_cache[partial_path] model = self.display_source_treestore retval = None - #iter = model.get_iter_root( ) - iter = model.get_iter_first( ) + #treeiter = model.get_iter_root( ) + treeiter = model.get_iter_first( ) current_path = [] - while iter is not None and len( namelist ) > 0: + while treeiter is not None and len( namelist ) > 0: name = namelist[ 0 ] - iter = self.source_list_find_one( model, iter, name ) - if iter is not None: - retval = iter + treeiter = self.source_list_find_one( model, treeiter, name ) + if treeiter is not None: + retval = treeiter namelist.pop( 0 ) current_path.append(name) self.source_list_cache["/".join(current_path)] = retval - iter = model.iter_children( retval ) + treeiter = model.iter_children( retval ) return retval def source_list_add( self, source_name, parent=None ): @@ -3177,17 +3433,17 @@ class MonitorWidgets: def source_list_remove( self, source_name ): namelist = source_name.split( "/" ) pwd = namelist[ : ] - iter = self.source_list_find( namelist ) - if iter is None or len( namelist ) > 0: + treeiter = self.source_list_find( namelist ) + if treeiter is None or len( namelist ) > 0: logger.warning("source_list_remove: Not found") return model = self.display_source_treestore - if model.iter_has_child( iter ): + if model.iter_has_child( treeiter ): logger.warning("source_list_remove: Request to delete a tree") return while True: - parent = model.iter_parent( iter ) - model.remove( iter ) + parent = model.iter_parent( treeiter ) + model.remove( treeiter ) pwd.pop( ) # We now test, if we want to remove parent too if parent is None: @@ -3198,7 +3454,7 @@ class MonitorWidgets: if "/".join( pwd ) in self.data_pool: # The parent has data connected to it break - iter = parent + treeiter = parent def source_list_current( self ): ai = self.display_source_combobox.get_active_iter( ) @@ -3214,50 +3470,47 @@ class MonitorWidgets: register a listener and save reference to data assume to be in gtk/gdk lock """ - gdk.threads_enter( ) - try: - if self.data_pool is not None: - # maybe some extra cleanup needed - if self.displayed_data[ 1 ] is not None and hasattr( self.displayed_data[ 1 ], "unregister_listener" ): - self.displayed_data[ 1 ].unregister_listener( self.datastructures_listener ) - self.displayed_data[ 1 ] = None - self.displayed_data = [ None, None ] - - self.data_pool.unregister_listener( self.datapool_listener ) - self.data_pool = None - - self.update_counter_lock.acquire( ) - self.update_counter = 0 - self.update_counter_lock.release( ) - - # display states - self.source_list_reset( ) - self.__rescale = True + if self.data_pool is not None: + # maybe some extra cleanup needed + if self.displayed_data[ 1 ] is not None and hasattr( self.displayed_data[ 1 ], "unregister_listener" ): + self.displayed_data[ 1 ].unregister_listener( self.datastructures_listener ) + self.displayed_data[ 1 ] = None self.displayed_data = [ None, None ] - self.display_source_path_label.set_label( "" ) - self.clear_display( ) + + self.data_pool.unregister_listener( self.datapool_listener ) + self.data_pool = None - if data_pool is not None: - # keep track of data - self.data_pool = data_pool - self.data_pool.register_listener( self.datapool_listener ) - - # Populate source list with existing data since the listener - # was registered after the data was loaded - keys = sorted([k for k in data_pool if k != "__builtins__"]) - for key in keys: - self.source_list_add(key) - - # Select first data item if available - keys = [k for k in data_pool if not k.startswith("__")] - if keys: - keys.sort() - first_key = keys[0] - first_iter = self.source_list_find(first_key.split("/")) - if first_iter: - self.display_source_combobox.set_active_iter(first_iter) - finally: - gdk.threads_leave( ) + self.update_counter_lock.acquire( ) + self.update_counter = 0 + self.update_counter_lock.release( ) + + # display states + self.source_list_reset( ) + self.__rescale = True + self.displayed_data = [ None, None ] + self.fit_source_keys = set( ) + self.display_source_path_label.set_label( "" ) + self.clear_display( ) + + if data_pool is not None: + # keep track of data + self.data_pool = data_pool + self.data_pool.register_listener( self.datapool_listener ) + + # Populate source list with existing data since the listener + # was registered after the data was loaded + keys = sorted([k for k in data_pool if k != "__builtins__"]) + for key in keys: + self.source_list_add(key) + + # Select first data item if available + keys = [k for k in data_pool if not k.startswith("__")] + if keys: + keys.sort() + first_key = keys[0] + first_iter = self.source_list_find(first_key.split("/")) + if first_iter: + self.display_source_combobox.set_active_iter(first_iter) #################### observing data structures and produce idle events @@ -3284,7 +3537,7 @@ class MonitorWidgets: self.update_counter_lock.acquire( ) self.update_counter += 1 self.update_counter_lock.release( ) - gobject.idle_add( self.datapool_idle_listener, event, priority=gobject.PRIORITY_DEFAULT_IDLE ) + glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE ) else: if event.what == DataPool.Event.updated_value and \ ( @@ -3293,13 +3546,13 @@ class MonitorWidgets: self.update_counter_lock.acquire() self.update_counter += 1 self.update_counter_lock.release() - gobject.idle_add( self.update_display_idle_event, self.displayed_data[ 0 ][ : ], - priority=gobject.PRIORITY_DEFAULT_IDLE ) + glib.idle_add(self.update_display_idle_event, self.displayed_data[ 0 ][ : ], + priority=glib.PRIORITY_DEFAULT_IDLE ) else: self.update_counter_lock.acquire( ) self.update_counter += 1 self.update_counter_lock.release( ) - gobject.idle_add( self.datapool_idle_listener, event, priority=gobject.PRIORITY_DEFAULT_IDLE ) + glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE ) if event.what in [ DataPool.Event.updated_value, DataPool.Event.new_key ]: #print "Update hdf5 file ..." @@ -3326,8 +3579,8 @@ class MonitorWidgets: self.update_counter_lock.acquire( ) self.update_counter += 1 self.update_counter_lock.release( ) - gobject.idle_add( self.update_display_idle_event, self.displayed_data[ 0 ][ : ], - priority=gobject.PRIORITY_DEFAULT_IDLE ) + glib.idle_add(self.update_display_idle_event, self.displayed_data[ 0 ][ : ], + priority=glib.PRIORITY_DEFAULT_IDLE ) ################### consume idle events @@ -3348,11 +3601,7 @@ class MonitorWidgets: if self.update_counter > 10: logger.warning("Update queue too long (%d>10): skipping one update", self.update_counter) else: - gdk.threads_enter( ) - try: - self.update_display( ) - finally: - gdk.threads_leave( ) + self.update_display( ) else: # unregister old one if self.displayed_data[ 1 ] is not None and hasattr( self.displayed_data[ 1 ], @@ -3366,20 +3615,22 @@ class MonitorWidgets: if self.update_counter > 10: logger.warning("Update queue too long (%d>10): skipping one update", self.update_counter) else: - gdk.threads_enter( ) - try: - self.renew_display( ) - finally: - gdk.threads_leave( ) + self.renew_display( ) new_data_struct = None elif event.what == DataPool.Event.new_key: - # update combo-box by inserting and rely on consistent information - gdk.threads_enter( ) - self.source_list_add( event.subject ) - gdk.threads_leave( ) + if isinstance( self.data_pool.get( event.subject ), FitResult ): + # fits are shown as overlay on their measurement only, + # they do not get their own entry in the display tree + self.fit_source_keys.add( event.subject ) + else: + # update combo-box by inserting and rely on consistent information + self.source_list_add( event.subject ) elif event.what == DataPool.Event.deleted_key: + if event.subject in self.fit_source_keys: + # fit entries are not in the display tree + self.fit_source_keys.discard( event.subject ) + return # update combo-box by removing and rely on consistent information - gdk.threads_enter( ) if (self.displayed_data[ 0 ] is not None and self.displayed_data[ 0 ] == event.subject): self.displayed_data = [ None, None ] @@ -3389,13 +3640,10 @@ class MonitorWidgets: # not necessary, because event will be submitted #self.clear_display() self.source_list_remove( event.subject ) - gdk.threads_leave( ) elif event.what == DataPool.Event.destroy: - gdk.threads_enter( ) self.source_list_reset( ) self.displayed_data = [ None, None ] self.clear_display( ) - gdk.threads_leave( ) return def update_display_idle_event( self, subject=None ): @@ -3409,11 +3657,7 @@ class MonitorWidgets: return if self.displayed_data[ 0 ] is None or subject != self.displayed_data[ 0 ]: return - gdk.threads_enter( ) - try: - self.update_display( ) - finally: - gdk.threads_leave( ) + self.update_display( ) ######################## events from buttons @@ -3455,7 +3699,7 @@ class MonitorWidgets: self.update_counter += 1 self.update_counter_lock.release( ) event = DataPool.Event( DataPool.Event.updated_value, new_data_name ) - gobject.idle_add( self.datapool_idle_listener, event, priority=gobject.PRIORITY_DEFAULT_IDLE ) + glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE ) def display_autoscaling_toggled( self, widget, data=None ): self.matplot_axes.set_autoscale_on( self.display_autoscaling_checkbutton.get_active( ) ) @@ -3549,8 +3793,8 @@ class MonitorWidgets: if widget.get_active(): # Activate cursor if not hasattr(self, 'matplot_cursor') or self.matplot_cursor is None: - self.matplot_cursor = matplotlib.widgets.Cursor(self.matplot_axes, - useblit=False, color='red', linewidth=1) + self.matplot_cursor = CrosshairCursor(self.matplot_axes, + color='red', linewidth=1) self.matplot_canvas.draw() # TODO: Deactivate the pane button @@ -3598,7 +3842,7 @@ class MonitorWidgets: menu.append(item_copy_svg) menu.show_all() - menu.popup(None, None, None, None, event.button, event.time) + menu.popup_at_pointer(event) def copy_canvas_as_png(self, widget): """Copy canvas to clipboard as PNG""" @@ -3718,6 +3962,15 @@ class MonitorWidgets: self.measurementresultgraph = None self.matplot_axes.clear( ) self.matplot_axes.grid( True ) + if not hasattr( self, "fitresultlines" ): + self.fitresultlines = [ ] + elif self.fitresultlines: + for line in self.fitresultlines: + try: + line.remove( ) + except: + pass + self.fitresultlines = [ ] if not hasattr( self, "graphen" ): self.graphen = [ ] elif self.graphen: @@ -3738,6 +3991,11 @@ class MonitorWidgets: except: pass self.clipping_marker = None + # the crosshair line artists were removed from the axes above, + # recreate the crosshair if it is enabled + if self.matplot_cursor_button.get_active( ): + self.matplot_cursor = CrosshairCursor(self.matplot_axes, + color='red', linewidth=1) self.matplot_canvas.draw_idle( ) def update_display( self, subject=None ): @@ -3960,10 +4218,36 @@ class MonitorWidgets: self.measurementresultline[ 0 ].remove( ) self.measurementresultline = None + # remove fit overlay lines + for line in self.fitresultlines: + try: + line.remove( ) + except: + pass + self.fitresultlines = [ ] + + # collect fits belonging to this measurement: + # either by source attribute or by nested data pool key + displayed_key = self.displayed_data[ 0 ] + fit_results = [ ] + for key in self.data_pool.keys( ): + if key == displayed_key: + continue + fit = self.data_pool.get( key ) + if isinstance( fit, FitResult ) and \ + (fit.source == displayed_key or key.startswith( displayed_key + "/" )): + fit_results.append( (key, fit) ) + fit_results.sort( key=lambda key_fit: key_fit[ 0 ] ) + fit_xydata = [ fit.get_lineplotdata( ) for key, fit in fit_results ] + [ k, v, e ] = in_result.get_errorplotdata( ) if k.shape[ 0 ] != 0: xmin = k.min( ) ymin = (v - e).min( ) + for fx, fy in fit_xydata: + if fx.shape[ 0 ] != 0: + xmin = min( xmin, fx.min( ) ) + ymin = min( ymin, fy.min( ) ) if xmin > 0: self.display_x_scaling_combobox.set_sensitive( True ) @@ -3991,6 +4275,10 @@ class MonitorWidgets: if self.__rescale: # or self.display_autoscaling_checkbutton.get_active(): xmax = k.max( ) ymax = (v + e).max( ) + for fx, fy in fit_xydata: + if fx.shape[ 0 ] != 0: + xmax = max( xmax, fx.max( ) ) + ymax = max( ymax, fy.max( ) ) # is there a range problem? if xmin == xmax or ymin == ymax: # fix range and scaling problems @@ -4032,6 +4320,44 @@ class MonitorWidgets: if k.shape[ 0 ] != 0 and v.shape == k.shape: self.measurementresultline = self.matplot_axes.plot( k, v, 'r-' ) + # draw the fits as overlay lines + fit_colors = [ "green", "purple", "darkorange" ] + for i, (fx, fy) in enumerate( fit_xydata ): + if fx.shape[ 0 ] != 0 and fy.shape == fx.shape: + self.fitresultlines.extend( + self.matplot_axes.plot( fx, fy, "-", + color=fit_colors[ i % len( fit_colors ) ], + linewidth=1.5 ) ) + + # show fit parameters + description_lines = [ ] + for fit_key, fit in fit_results: + if fit.params: + if len( fit_results ) > 1: + description_lines.append( str( fit.get_title( ) or fit_key ) ) + for param_key in fit.params: + param_value = fit.params[ param_key ] + if isinstance( param_value, float ): + description_lines.append( "%s = %.4g" % (param_key, param_value) ) + else: + description_lines.append( "%s = %s" % (param_key, param_value) ) + if description_lines: + description_string = "\n".join( description_lines ) + if self.description_text is None: + self.description_text = self.matplot_axes.text(0.98, 0.95, description_string, + size=10, + transform=self.matplot_axes.transAxes, + va='top', ha='right', ma='left', + bbox=dict(facecolor='lightgray', alpha=0.5, boxstyle='round,pad=0.2')) + else: + self.description_text.set_text( description_string ) + elif self.description_text is not None: + try: + self.description_text.remove() + except: + pass + self.description_text = None + # Any title to be set? title = in_result.get_title( ) + "" @@ -4159,7 +4485,7 @@ class ScriptInterface: # start them if self.back_driver is not None: self.back_driver.start( ) - while (not self.back_driver.quit_flag.isSet( ) and \ + while (not self.back_driver.quit_flag.is_set( ) and \ (self.back_driver.core_pid is None or self.back_driver.core_pid <= 0)): while gtk.events_pending(): gtk.main_iteration_do(False) diff --git a/src/damaris/gui/ExperimentHandling.py b/src/damaris/gui/ExperimentHandling.py index 6823719..72a9ff8 100644 --- a/src/damaris/gui/ExperimentHandling.py +++ b/src/damaris/gui/ExperimentHandling.py @@ -5,6 +5,7 @@ import sys import time import logging import ast +import contextlib from damaris.experiments.Experiment import Quit from damaris.experiments import Experiment @@ -36,6 +37,19 @@ class _SandboxStdout: return False +@contextlib.contextmanager +def _sandbox_stdout_redirect(log_callback): + """Temporarily redirect process-global stdout/stderr to the sandbox log.""" + old_stdout, old_stderr = sys.stdout, sys.stderr + sandbox = _SandboxStdout(log_callback) + try: + sys.stdout = sandbox + sys.stderr = sandbox + yield sandbox + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + + class ExperimentHandling(threading.Thread): """ runs the experiment script in sandbox @@ -52,17 +66,25 @@ class ExperimentHandling(threading.Thread): self.data["__recentexperiment"]=-1 def synchronize(self, before=0, waitsteps=0.1): - while (self.data["__recentexperiment"]>self.data["__recentresult"]+before) and not self.quit_flag.isSet(): + while (self.data["__recentexperiment"]>self.data["__recentresult"]+before) and not self.quit_flag.is_set(): self.quit_flag.wait(waitsteps) - if self.quit_flag.isSet(): + if self.quit_flag.is_set(): raise StopExperiment def sleep(self, seconds): self.quit_flag.wait(seconds) - if self.quit_flag.isSet(): + if self.quit_flag.is_set(): raise StopExperiment def run(self): + # Redirect stdout/stderr so print() in sandboxed scripts goes to the GUI + if self._log_callback is not None: + with _sandbox_stdout_redirect(self._log_callback): + self._run_script() + else: + self._run_script() + + def _run_script(self): dataspace={} exp_classes = __import__('damaris.experiments', dataspace, dataspace, ['Experiment']) for name in dir(exp_classes): @@ -77,13 +99,6 @@ class ExperimentHandling(threading.Thread): self.location = None exp_iterator=None - # Redirect stdout/stderr so print() in sandboxed scripts goes to the GUI - _orig_stdout = sys.stdout - _orig_stderr = sys.stderr - if self._log_callback is not None: - sys.stdout = _SandboxStdout(self._log_callback) - sys.stderr = _SandboxStdout(self._log_callback) - # check for time.sleep() try: tree = ast.parse(self.script) @@ -131,7 +146,7 @@ class ExperimentHandling(threading.Thread): traceback_file=None return - while exp_iterator is not None and not self.quit_flag.isSet(): + while exp_iterator is not None and not self.quit_flag.is_set(): # get next experiment from script try: job=next(exp_iterator) @@ -171,7 +186,7 @@ class ExperimentHandling(threading.Thread): # relax for a short time if "__resultsinadvance" in self.data and self.data["__resultsinadvance"]+100self.no): + while (not self.quit_flag.is_set()) and (self.stop_no is None or self.stop_no>self.no): if not os.access(expected_filename,os.R_OK): # stop polling, if required if self.poll_time<0: break @@ -560,11 +560,11 @@ class BlockingResultReader(ResultReader): self.in_advance+=1 in_advance_filename=os.path.join(self.spool_dir,self.result_pattern%(self.in_advance+1)) - if self.quit_flag.isSet(): break + if self.quit_flag.is_set(): break r=self.get_result_object(expected_filename) - if self.quit_flag.isSet(): break + if self.quit_flag.is_set(): break - if self.quit_flag.isSet(): break + if self.quit_flag.is_set(): break yield r if self.clear_results: diff --git a/src/damaris/gui/TemperatureMonitor.py b/src/damaris/gui/TemperatureMonitor.py index d945b70..af28e57 100644 --- a/src/damaris/gui/TemperatureMonitor.py +++ b/src/damaris/gui/TemperatureMonitor.py @@ -13,25 +13,6 @@ import datetime from damaris.data import TemperatureResult from damaris.tools.temperature import TemperatureController -# GTK imports are done lazily to avoid initialization issues -_HAS_GTK = None -_gdk = None - - -def _get_gdk(): - """Lazily import gdk to avoid GTK initialization issues.""" - global _HAS_GTK, _gdk - if _HAS_GTK is None: - try: - import gi - gi.require_version('Gdk', '3.0') - from gi.repository import Gdk as gdk - _gdk = gdk - _HAS_GTK = True - except (ImportError, ValueError): - _HAS_GTK = False - return _gdk if _HAS_GTK else None - class TemperatureMonitor(threading.Thread): """ @@ -72,6 +53,10 @@ class TemperatureMonitor(threading.Thread): self.result = TemperatureResult() self._running = False self._lock = threading.RLock() + self._consecutive_failures = 0 + self._max_consecutive_failures = 10 + self._last_error = None + self._connection_ok = True # Initialize the result with metadata self.result.xlabel = "Time (s)" @@ -124,21 +109,29 @@ class TemperatureMonitor(threading.Thread): data_pool_result.job_id = self.result.job_id if hasattr(self.result, 'job_id') else None data_pool_result.job_date = self.result.job_date if hasattr(self.result, 'job_date') else None - # Store in data_pool in GTK thread (if GTK available) - gdk = _get_gdk() - if gdk is not None: - gdk.threads_enter() - try: - self.data_pool[self.data_key] = data_pool_result - finally: - gdk.threads_leave() - else: - self.data_pool[self.data_key] = data_pool_result + # Store in data_pool (dict item assignment is atomic under the GIL) + self.data_pool[self.data_key] = data_pool_result + + # Reset consecutive failure counter on successful read + self._consecutive_failures = 0 + self._last_error = None + self._connection_ok = True except Exception as e: - print(f"TemperatureMonitor error: {e}") - import traceback - traceback.print_exc() + self._consecutive_failures += 1 + self._last_error = str(e) + if self._consecutive_failures <= 3: + print(f"TemperatureMonitor read error (attempt {self._consecutive_failures}): {e}") + elif self._consecutive_failures == 5: + print(f"TemperatureMonitor: repeated read errors, checking connection...") + if hasattr(self.controller, 'check_connection'): + self._connection_ok = self.controller.check_connection() + if not self._connection_ok: + print(f"TemperatureMonitor: WARNING - controller appears disconnected after {self._consecutive_failures} failures") + elif self._consecutive_failures >= self._max_consecutive_failures: + print(f"TemperatureMonitor: ERROR - {self._consecutive_failures} consecutive failures, controller may be disconnected") + else: + print(f"TemperatureMonitor error (failure #{self._consecutive_failures}): {e}") # Sleep in small increments for responsive shutdown sleep_remaining = self.interval diff --git a/src/damaris/tools/eurotherm.py b/src/damaris/tools/eurotherm.py index d231779..b49fd6a 100644 --- a/src/damaris/tools/eurotherm.py +++ b/src/damaris/tools/eurotherm.py @@ -1,5 +1,6 @@ import serial import re +import time import operator from functools import reduce @@ -57,21 +58,22 @@ class Eurotherm(TemperatureController): def send_read_param(self, param): self.s.write(EOT + self.device + param + ENQ) - def read_param(self, param): - self.s.flushInput() - self.send_read_param(param) - answer = self.s.read(self._expect_len) - m = reply_pattern.search(answer) - if m is None: - # Reading _expect_len bytes was not enough... - answer += self.s.read(200) + def read_param(self, param, retries=3, retry_delay=0.05): + for attempt in range(retries): + self.s.flushInput() + self.send_read_param(param) + answer = self.s.read(self._expect_len) m = reply_pattern.search(answer) - if m is not None: - self._expect_len = len(answer) - return m.group(1) - else: - print("received:", repr(answer)) - return None + if m is None: + answer += self.s.read(200) + m = reply_pattern.search(answer) + if m is not None: + self._expect_len = len(answer) + return m.group(1) + if attempt < retries - 1: + time.sleep(retry_delay) + print("received:", repr(answer)) + return None def write_param(self, mnemonic, data): if len(mnemonic) > 2: @@ -128,6 +130,14 @@ class Eurotherm(TemperatureController): """Set target temperature in Celsius.""" return self.write_param('SL', str(temperature)) + def check_connection(self) -> bool: + """Check if the Eurotherm device is connected and responsive.""" + try: + result = self.read_param('PV') + return result is not None + except Exception: + return False + def close(self): """Close the serial connection.""" if hasattr(self, 's') and self.s is not None: diff --git a/src/damaris/tools/lakeshore.py b/src/damaris/tools/lakeshore.py index 561947d..17b43d4 100644 --- a/src/damaris/tools/lakeshore.py +++ b/src/damaris/tools/lakeshore.py @@ -1,5 +1,6 @@ import serial import re +import time import numpy as np from .temperature import TemperatureController, register_controller @@ -91,13 +92,17 @@ class Lakeshore(TemperatureController): else: return 0.0 - def read_param(self, param): + def read_param(self, param, retries=3, retry_delay=0.05): """Read a parameter using simple ASCII commands.""" - self.s.write(f"{param}?\n") - answer = self.s.readline().strip() - if answer and len(answer) > 1: - # Remove the first character (command echo) and return the value - return answer[1:] + for attempt in range(retries): + self.s.flushInput() + self.s.write(f"{param}?\n") + answer = self.s.readline().strip() + if answer and len(answer) > 1: + # Remove the first character (command echo) and return the value + return answer[1:] + if attempt < retries - 1: + time.sleep(retry_delay) return None def get_value(self, name): @@ -134,9 +139,17 @@ class Lakeshore(TemperatureController): def set_setpoint(self, temperature: float) -> bool: """Set target temperature in Celsius.""" - self.s.write(f"SETP {temperature}\n") + self.s.write(f"SETP 1,{temperature}\n") return True + def check_connection(self) -> bool: + """Check if the Lakeshore device is connected and responsive.""" + try: + result = self.read_param('KRDG') + return result is not None + except Exception: + return False + def close(self): """Close the serial connection.""" if hasattr(self, 's') and self.s is not None: diff --git a/src/damaris/tools/temperature.py b/src/damaris/tools/temperature.py index a50c01d..eec67ac 100644 --- a/src/damaris/tools/temperature.py +++ b/src/damaris/tools/temperature.py @@ -149,3 +149,13 @@ class TemperatureController(ABC): if they have resources to clean up. """ pass + + def check_connection(self) -> bool: + """ + Check if the controller is connected and responsive. + + Returns: + bool: True if the controller is connected and responsive, + False otherwise. Subclasses should override this method. + """ + return False diff --git a/tests/exp_test.py b/tests/exp_test.py index 475079d..6d472dc 100644 --- a/tests/exp_test.py +++ b/tests/exp_test.py @@ -11,6 +11,7 @@ def test(i): return e def experiment(): - for i in range(1400): - sleep(0.1) - yield test(i) \ No newline at end of file + for i in range(100): + #sleep(0.1) + yield test(i) + synchronize() \ No newline at end of file diff --git a/tests/res_test.py b/tests/res_test.py index 205074a..5555699 100644 --- a/tests/res_test.py +++ b/tests/res_test.py @@ -1,6 +1,6 @@ import tables import numpy - +# from damaris import FitResult r = MeasurementResult("test") def result(): @@ -9,14 +9,29 @@ def result(): except: print("h5 already opened") accu = Accumulation(error=False) + for num,ts in enumerate(results): data["accu"] = accu data["data/ts %i"%num] = ts+0 ts.write_to_hdf(h5, "/", f"adc_{num}", f"adc_{num}") data["fft"] = (ts+0).fft() r[num] = ts.y[0][0] + if num > 4: + x = r.get_xdata() + y = r.get_ydata() + coeffs = numpy.polyfit(x, y, 2) + x_line = numpy.linspace(x.min(), x.max(), 100) + y_line = numpy.polyval(coeffs, x_line) + + fit = FitResult(x=x_line, + y=y_line, + title="Magnetization(tau) fit", + source="test", + params={"a": coeffs[0], "b": coeffs[1], "c": coeffs[2]}) + data["test/fit"] = fit data["test"] = r accu+=ts + print(accu) accu.write_to_hdf(h5, "/", "name", "title") try: h5.close() diff --git a/tests/test_ADC_Result.py b/tests/test_ADC_Result.py index ead41e3..3bda02b 100644 --- a/tests/test_ADC_Result.py +++ b/tests/test_ADC_Result.py @@ -195,14 +195,14 @@ class TestADCResult(unittest.TestCase): np.testing.assert_array_equal(adc.y[i]+10, y[i] + 10, strict=True) adc += 10 for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i], y[i] + 10, strict=True) + np.testing.assert_array_equal(adc.y[i], (y[i] + 10).astype("float32"), strict=True) # test float addition for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i] + 10., y[i] + 10., strict=True) + np.testing.assert_array_equal(adc.y[i] + 10., (y[i] + 10 + 10.).astype("float32"), strict=True) adc += 10. for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i], y[i] + 10 + 10., strict=True) + np.testing.assert_array_equal(adc.y[i], (y[i] + 10 + 10.).astype("float32"), strict=True) def test_operator_sub_scalar(self): """ @@ -215,14 +215,14 @@ class TestADCResult(unittest.TestCase): np.testing.assert_array_equal(adc.y[i] - 10, y[i] - 10, strict=True) adc -= 10 for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i], y[i] - 10, strict=True) + np.testing.assert_array_equal(adc.y[i], (y[i] - 10).astype("float32"), strict=True) # test float subtraction for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i] - 10., y[i] - 10., strict=True) + np.testing.assert_array_equal(adc.y[i] - 10., (y[i] - 10 - 10.).astype("float32"), strict=True) adc -= 10. for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i], y[i] - 10 - 10., strict=True) + np.testing.assert_array_equal(adc.y[i], (y[i] - 10 - 10.).astype("float32"), strict=True) def test_operator_mul_scalar(self): """ @@ -236,14 +236,14 @@ class TestADCResult(unittest.TestCase): np.testing.assert_array_equal(adc.y[i] * 10, y[i] * 10, strict=True) adc *= 10 for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i], y[i] * 10, strict=True) + np.testing.assert_array_equal(adc.y[i], (y[i] * 10).astype("float32"), strict=True) # test float multiplication for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i] * 10.0, y[i] * 10.0, strict=True) + np.testing.assert_array_equal(adc.y[i] * 10.0, (y[i] * 10 * 10.0).astype("float32"), strict=True) adc *= 10.0 for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i], y[i] * 10 * 10.0, strict=True) + np.testing.assert_array_equal(adc.y[i], (y[i] * 10 * 10.0).astype("float32"), strict=True) def test_operator_truediv_scalar(self): """ @@ -255,7 +255,7 @@ class TestADCResult(unittest.TestCase): np.testing.assert_array_equal(adc.y[i]/10, y[i] / 10, strict=True) adc /= 10 for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i], y[i] / 10, strict=True) + np.testing.assert_array_equal(adc.y[i], (y[i] / 10).astype("float32"), strict=True) def test_operator_floordiv_scalar(self): """ @@ -267,10 +267,37 @@ class TestADCResult(unittest.TestCase): np.testing.assert_array_equal(adc.y[i]//10, y[i] // 10, strict=True) adc //= 10 for i in range(adc.get_nChannels()): - np.testing.assert_array_equal(adc.y[i], y[i] // 10, strict=True) + np.testing.assert_array_equal(adc.y[i], (y[i] // 10).astype("float32"), strict=True) self.assertRaises(ValueError, adc.__floordiv__, 1.0) self.assertRaises(ValueError, adc.__rfloordiv__, 1.0) + def test_operators_keep_channel_metadata(self): + """operator results keep nChannels/is_clipped identical to the operand""" + adc = self.create_adc_result() + adc.is_clipped = True + results = [adc + 1, adc - 1, adc * 2, adc ** 2, adc / 2., adc // 2, + 10 + adc, 10 - adc, 2 * adc, 100. / adc, 100 // adc, -adc] + for r in results: + self.assertEqual(r.get_nChannels(), adc.get_nChannels()) + self.assertEqual(r.is_clipped, adc.is_clipped) + + def test_hdf_roundtrip_keeps_nchannels(self): + """read_from_hdf must restore the number of channels""" + import tempfile + import tables + from damaris.data.ADC_Result import read_from_hdf + + adc = self.create_adc_result() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "adc.h5") + with tables.open_file(path, "w") as h5: + adc.write_to_hdf(h5, h5.root, "adc", "test") + with tables.open_file(path, "r") as h5: + adc2 = read_from_hdf(h5.root.adc) + self.assertIsNotNone(adc2) + self.assertTrue(adc2.contains_data()) + self.assertEqual(adc2.get_nChannels(), adc.get_nChannels()) + def test_operator_overload_adc_result_exception(self): """ Test the ValueError raised when ADC_Result is used as an operand in an arithmetic operation @@ -289,5 +316,31 @@ class TestADCResult(unittest.TestCase): self.assertRaises(ValueError, adc1.__rfloordiv__, adc2) self.assertRaises(ValueError, adc1.__pow__, adc2) + def test_empty_state_types(self): + """Empty ADC_Result must hold an ndarray x and a list-of-ndarray y.""" + adc = ADC_Result() + self.assertIsInstance(adc.x, np.ndarray) + self.assertIsInstance(adc.y, list) + self.assertEqual(adc.x.size, 0) + self.assertEqual(adc.get_xmin(), 0) + self.assertEqual(adc.get_xmax(), 0) + self.assertEqual(adc.get_xminpos(), 0) + self.assertEqual(adc.get_ymin(), 0) + self.assertEqual(adc.get_ymax(), 0) + self.assertEqual(adc.get_yminpos(), 0) + + def test_constructor_coerces_lists(self): + """Plain lists passed to the constructor must become numpy arrays.""" + x = [0.0, 1.0, 2.0] + y = [[10, 20, 30], [15, 25, 35]] + index = [(0, 2)] + adc = ADC_Result(x, y, index, 1000.0, {"key": "value"}, 1, datetime(2023, 1, 1)) + self.assertIsInstance(adc.x, np.ndarray) + np.testing.assert_array_equal(adc.x, [0.0, 1.0, 2.0]) + for ch in adc.y: + self.assertIsInstance(ch, np.ndarray) + np.testing.assert_array_equal(adc.y[0], [10, 20, 30]) + np.testing.assert_array_equal(adc.y[1], [15, 25, 35]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_Accumulation.py b/tests/test_Accumulation.py index e0b3330..f7354c1 100644 --- a/tests/test_Accumulation.py +++ b/tests/test_Accumulation.py @@ -1,5 +1,6 @@ import os import subprocess +import threading import unittest from datetime import datetime @@ -258,7 +259,7 @@ class TestAccumulation(unittest.TestCase): np.testing.assert_array_equal(accu.y[i], y[i] - 10) # test float subtraction - adc -= 10. + accu -= 10. for i in range(adc.get_nChannels()): np.testing.assert_array_equal(accu.y[i], y[i] - 10 - 10.) @@ -312,5 +313,252 @@ class TestAccumulation(unittest.TestCase): self.assertRaises(ValueError, accu.__floordiv__, 1.0) self.assertRaises(ValueError, accu.__ifloordiv__, 1.0) + def test_add_invalid_operand_raises(self): + """accu + bogus must raise ValueError instead of returning None.""" + adc = self.create_adc_result() + accu = Accumulation() + accu += adc + self.assertRaises(ValueError, accu.__add__, "foo") + self.assertRaises(ValueError, accu.__iadd__, "foo") + + def test_neg_empty_raises(self): + """-empty accumulation must raise ValueError.""" + self.assertRaises(ValueError, Accumulation().__neg__) + + def test_add_adc_result_does_not_modify_job_ids(self): + """accu + adc must not insert the adc job id into accu.job_ids.""" + accu = Accumulation() + accu += self.create_adc_result() + n_before = len(accu.job_ids) + adc2 = self.create_adc_result() + adc2.job_id = 12 + r = accu + adc2 + self.assertIn(12, r.job_ids) + self.assertNotIn(12, accu.job_ids) + self.assertEqual(len(accu.job_ids), n_before) + + def test_empty_plus_accumulation_copies_data(self): + """empty_accu + accu must copy arrays and metadata, not alias them.""" + adc = self.create_adc_result() + accu = Accumulation() + accu += adc + accu.is_clipped = True + + r = Accumulation() + accu + self.assertIsNot(r.y[0], accu.y[0]) + self.assertIsNot(r.job_ids, accu.job_ids) + self.assertTrue(r.is_clipped) + # in-place operation on the result must leave the operand unchanged + r *= 2 + np.testing.assert_array_equal(accu.y[0], np.array([10, 20, 30], dtype="float32")) + + def test_accumulation_plus_accumulation_keeps_both_job_ids(self): + """accu1 + accu2 must keep the provenance of both operands.""" + adc1 = self.create_adc_result() + adc2 = self.create_adc_result() + adc2.job_id = 12 + accu1 = Accumulation() + accu1 += adc1 + accu2 = Accumulation() + accu2 += adc2 + r = accu1 + accu2 + self.assertEqual(set(r.job_ids.keys()), {11, 12}) + self.assertEqual(set(accu1.job_ids.keys()), {11}) + self.assertEqual(set(accu2.job_ids.keys()), {12}) + + def test_empty_error_plus_non_error_raises(self): + """error accumulation + non-error accumulation must raise ValueError.""" + accu = Accumulation() + accu += self.create_adc_result() + empty_err = Accumulation(error=True) + self.assertRaises(ValueError, empty_err.__add__, accu) + free = [] + def check_lock(): + if empty_err.lock.acquire(timeout=1): + free.append(True) + empty_err.lock.release() + t = threading.Thread(target=check_lock) + t.start() + t.join(3) + self.assertTrue(free, "lock leaked after ValueError in __add__") + + def test_neg_keeps_y_square_as_list(self): + """-accu must store y_square as a list of per-channel arrays.""" + x = np.array([0.0, 1.0, 2.0]) + y = [np.array([10, 20, 30], dtype="float32")] + y_square = [np.array([100.0, 400.0, 900.0], dtype="float32")] + accu = Accumulation(x=x, y=y, y_2=y_square, n=2, index=[(0, 2)], sampl_freq=1000.0, error=True) + + r = -accu + + self.assertIsInstance(r.y_square, list) + # sums of squares are invariant under negation + np.testing.assert_array_equal(r.get_ysquare(0), y_square[0]) + + def test_iadd_adc_after_ndarray_y_2(self): + """empty accu with ndarray y_square += adc must not crash (was AttributeError).""" + y_square_2d = np.array([[100.0, 400.0, 900.0]], dtype="float32") + accu = Accumulation(y_2=y_square_2d, error=True) + + accu += self.create_adc_result() + + self.assertIsInstance(accu.y_square, list) + np.testing.assert_array_equal(accu.get_ydata(0), np.array([10, 20, 30], dtype="float32")) + np.testing.assert_array_equal(accu.get_ysquare(0), np.array([10, 20, 30], dtype="float32") ** 2) + + def test_iadd_accumulation_after_ndarray_y_2(self): + """empty accu with ndarray y_square += accu must not crash (was AttributeError).""" + full = Accumulation(error=True) + full += self.create_adc_result() + y_square_2d = np.array([[100.0, 400.0, 900.0]], dtype="float32") + accu = Accumulation(y_2=y_square_2d, error=True) + + accu += full + + self.assertIsInstance(accu.y_square, list) + np.testing.assert_array_equal(accu.get_ydata(0), np.array([10, 20, 30], dtype="float32")) + np.testing.assert_array_equal(accu.get_ysquare(0), np.array([10, 20, 30], dtype="float32") ** 2) + + def test_constructor_normalizes_ndarray_y_2(self): + """an ndarray passed as y_2 must be stored as a list of channel arrays.""" + x = np.array([0.0, 1.0, 2.0]) + y = [np.array([10, 20, 30], dtype="float32")] + y_square_2d = np.array([[100.0, 400.0, 900.0]], dtype="float32") + + accu = Accumulation(x=x, y=y, y_2=y_square_2d, n=2, index=[(0, 2)], sampl_freq=1000.0, error=True) + + self.assertIsInstance(accu.y_square, list) + np.testing.assert_array_equal(accu.get_ysquare(0), y_square_2d[0]) + + def test_add_accumulations_without_time_period(self): + """accu + accu without time information must not crash.""" + accu1 = Accumulation() + accu1 += self.create_adc_result() + accu2 = Accumulation() + accu2 += self.create_adc_result() + accu1.time_period = None + accu2.time_period = [] + + r = accu1 + accu2 + + self.assertIsNone(r.time_period) + + def test_iadd_accumulation_without_time_period(self): + """accu += accu without time information must not crash (was IndexError).""" + accu1 = Accumulation() + accu1 += self.create_adc_result() + accu2 = Accumulation() + accu2 += self.create_adc_result() + accu1.time_period = [] + accu2.time_period = [] + + accu1 += accu2 + + self.assertIsNone(accu1.time_period) + + def test_add_adc_without_time_period(self): + """accu + adc without time information must not crash.""" + accu = Accumulation() + accu += self.create_adc_result() + accu.time_period = [] + + r = accu + self.create_adc_result() + + self.assertIsNone(r.time_period) + + def test_time_period_merged_on_add(self): + """accu + accu with both time periods set merges to min/max.""" + accu1 = Accumulation() + accu1 += self.create_adc_result() + accu2 = Accumulation() + accu2 += self.create_adc_result() + accu1.time_period = [datetime(2023, 1, 3), datetime(2023, 1, 5)] + accu2.time_period = [datetime(2023, 1, 2), datetime(2023, 1, 7)] + + r = accu1 + accu2 + + self.assertEqual(r.time_period, [datetime(2023, 1, 2), datetime(2023, 1, 7)]) + + def test_time_period_merged_with_adc_job_date(self): + """accu + adc merges self.time_period with other.job_date.""" + accu = Accumulation() + accu += self.create_adc_result() + accu.time_period = [datetime(2023, 1, 3), datetime(2023, 1, 5)] + adc = self.create_adc_result() + adc.job_date = datetime(2023, 1, 1) + + r = accu + adc + + self.assertEqual(r.time_period, [datetime(2023, 1, 1), datetime(2023, 1, 5)]) + + def test_iadd_adc_without_job_date_sets_time_period_none(self): + """empty accu += adc without job_date gets time_period None.""" + accu = Accumulation() + adc = self.create_adc_result() + adc.job_date = None + + accu += adc + + self.assertIsNone(accu.time_period) + + def test_get_accu_by_index_without_time_period(self): + """get_accu_by_index without time information must not crash.""" + accu = Accumulation() + adc = self.create_adc_result() + adc.index = [(0, 1), (1, 2)] + accu += adc + accu.time_period = None + + r = accu.get_accu_by_index(0) + + self.assertIsNone(r.time_period) + + def test_write_to_tecmag_without_time_period_raises(self): + """write_to_tecmag without time information must raise ValueError.""" + accu = Accumulation() + accu += self.create_adc_result() + accu.time_period = None + + self.assertRaises(ValueError, accu.write_to_tecmag, "/tmp/damaris_tecmag_no_time.tnt") + self.assertTrue(accu.lock.acquire(timeout=1), "lock leaked after ValueError in write_to_tecmag") + accu.lock.release() + + def test_empty_state_accessors(self): + """Empty Accumulation accessors must return ndarrays, min/max helpers 0.""" + accu = Accumulation() + self.assertIsInstance(accu.x, np.ndarray) + self.assertEqual(accu.x.size, 0) + self.assertIsInstance(accu.get_ydata(), np.ndarray) + self.assertEqual(accu.get_ydata().size, 0) + self.assertIsInstance(accu.get_yerr(0), np.ndarray) + self.assertEqual(accu.get_yerr(0).size, 0) + self.assertEqual(accu.get_xmin(), 0) + self.assertEqual(accu.get_xmax(), 0) + self.assertEqual(accu.get_ymin(), 0) + self.assertEqual(accu.get_ymax(), 0) + + def test_get_yerr_without_statistics_on_empty(self): + """get_yerr on an empty accumulation without statistics must not crash.""" + accu = Accumulation(error=False) + err = accu.get_yerr(0) + self.assertIsInstance(err, np.ndarray) + self.assertEqual(err.size, 0) + + def test_constructor_coerces_lists(self): + """Plain lists passed to the constructor must become numpy arrays.""" + x = [0.0, 1.0, 2.0] + y = [[10, 20, 30], [15, 25, 35]] + y_2 = [[100.0, 400.0, 900.0], [225.0, 625.0, 1225.0]] + accu = Accumulation(x=x, y=y, y_2=y_2, n=1, + index=[(0, 2)], sampl_freq=1000.0, error=True) + self.assertIsInstance(accu.x, np.ndarray) + np.testing.assert_array_equal(accu.x, [0.0, 1.0, 2.0]) + for ch in accu.y: + self.assertIsInstance(ch, np.ndarray) + for ch in accu.y_square: + self.assertIsInstance(ch, np.ndarray) + np.testing.assert_array_equal(accu.y[0], [10, 20, 30]) + np.testing.assert_array_equal(accu.y_square[0], [100.0, 400.0, 900.0]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_Config_Result.py b/tests/test_Config_Result.py new file mode 100644 index 0000000..9c5d612 --- /dev/null +++ b/tests/test_Config_Result.py @@ -0,0 +1,29 @@ +import unittest + +from damaris.data.Config_Result import Config_Result + + +class TestConfigResult(unittest.TestCase): + def test_constructor_with_no_arguments(self): + """Test initializer with no arguments.""" + cr = Config_Result() + self.assertEqual(cr.get_config_dictionary(), {}) + self.assertEqual(cr.description, {}) + + def test_constructor_with_arguments(self): + """Test initializer with config and description arguments.""" + config = {"a": 1} + desc = {"b": 2} + cr = Config_Result(config=config, desc=desc) + self.assertEqual(cr.get_config_dictionary(), config) + self.assertEqual(cr.description, desc) + + def test_get_set_config(self): + cr = Config_Result() + cr.set_config("key", "value") + self.assertEqual(cr.get_config("key"), "value") + self.assertIsNone(cr.get_config("missing")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_DamarisFFT.py b/tests/test_DamarisFFT.py new file mode 100644 index 0000000..429d12a --- /dev/null +++ b/tests/test_DamarisFFT.py @@ -0,0 +1,44 @@ +import unittest + +import numpy as np + +from damaris.data.DamarisFFT import DamarisFFT + + +class TestDamarisFFT(unittest.TestCase): + @staticmethod + def make_fft(): + fft = DamarisFFT() + fft.sampling_rate = 100.0 + fft.x = np.arange(10, dtype="float32") / 100.0 + fft.y = [np.arange(10, dtype="float32")] + fft.xlabel = "Time / s" + return fft + + def test_clip_without_arguments_is_placeholder(self): + """clip() without arguments returns self unchanged (placeholder use).""" + fft = self.make_fft() + self.assertIs(fft.clip(), fft) + self.assertEqual(len(fft.x), 10) + np.testing.assert_array_equal(fft.y[0], np.arange(10, dtype="float32")) + + def test_clip_swaps_reversed_bounds(self): + fft = self.make_fft() + fft.clip(start=0.05, stop=0.01) + expected_x = (np.arange(10, dtype="float32") / 100.0)[1:5] + np.testing.assert_array_equal(fft.x, expected_x) + np.testing.assert_array_equal(fft.y[0], np.arange(1, 5, dtype="float32")) + + def test_clip_single_bound(self): + fft = self.make_fft() + fft.clip(start=0.02) + self.assertEqual(len(fft.x), 7) + self.assertEqual(len(fft.y[0]), 7) + + def test_clip_too_close_raises(self): + fft = self.make_fft() + self.assertRaises(ValueError, fft.clip, 0.021, 0.029) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_Drawable.py b/tests/test_Drawable.py new file mode 100644 index 0000000..e025f75 --- /dev/null +++ b/tests/test_Drawable.py @@ -0,0 +1,54 @@ +import unittest + +import numpy as np + +from damaris.data.Drawable import Drawable + + +class TestDrawable(unittest.TestCase): + def test_initial_types(self): + """x must start as an empty ndarray, y as a list of arrays.""" + d = Drawable() + self.assertIsInstance(d.x, np.ndarray) + self.assertEqual(d.x.size, 0) + self.assertIsInstance(d.y, list) + + def test_empty_min_max_helpers(self): + """min/max helpers must return 0 instead of raising on empty data.""" + d = Drawable() + self.assertEqual(d.get_xmin(), 0) + self.assertEqual(d.get_xmax(), 0) + self.assertEqual(d.get_xminpos(), 0) + self.assertEqual(d.get_ymin(), 0) + self.assertEqual(d.get_ymax(), 0) + self.assertEqual(d.get_yminpos(), 0) + + def test_min_max_helpers_with_data(self): + d = Drawable() + d.x = np.array([-1.0, 0.0, 2.0, 5.0]) + d.y = [np.array([1.0, -2.0, 3.0]), np.array([0.5, 4.0, -0.5])] + self.assertEqual(d.get_xmin(), -1.0) + self.assertEqual(d.get_xmax(), 5.0) + self.assertEqual(d.get_xminpos(), 2.0) + self.assertEqual(d.get_ymin(), -2.0) + self.assertEqual(d.get_ymax(), 4.0) + self.assertEqual(d.get_yminpos(), 0.5) + + def test_yminpos_without_positive_values(self): + d = Drawable() + d.x = np.array([1.0]) + d.y = [np.array([-1.0, -2.0])] + self.assertEqual(d.get_yminpos(), 0) + + def test_set_xdata_set_ydata(self): + d = Drawable() + d.x = np.zeros(3) + d.y = [np.zeros(3)] + d.set_xdata(1, 5.0) + d.set_ydata(0, 1, 7.0) + self.assertEqual(d.get_xdata()[1], 5.0) + self.assertEqual(d.get_ydata(0)[1], 7.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_Error_Result.py b/tests/test_Error_Result.py new file mode 100644 index 0000000..1098057 --- /dev/null +++ b/tests/test_Error_Result.py @@ -0,0 +1,23 @@ +import unittest + +import numpy as np + +from damaris.data.Error_Result import Error_Result + + +class TestErrorResult(unittest.TestCase): + def test_plotdata_are_ndarrays(self): + """get_xdata/get_ydata must return numpy arrays like every Drawable.""" + er = Error_Result("core failed") + self.assertIsInstance(er.get_xdata(), np.ndarray) + self.assertIsInstance(er.get_ydata(), np.ndarray) + np.testing.assert_array_equal(er.get_xdata(), [0.0]) + np.testing.assert_array_equal(er.get_ydata(), [0.0]) + + def test_add_returns_self(self): + er = Error_Result("msg") + self.assertIs(er + 1, er) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ExperimentHandling.py b/tests/test_ExperimentHandling.py new file mode 100644 index 0000000..b5f6afe --- /dev/null +++ b/tests/test_ExperimentHandling.py @@ -0,0 +1,70 @@ +import sys +import threading +import unittest + +from damaris.gui.ExperimentHandling import ExperimentHandling +from damaris.gui.ResultHandling import ResultHandling + + +class _FakeWriter: + def __init__(self): + self.sent = [] + + def send_next(self, job, quit=False): + self.sent.append(job) + + +class _FakeResultIterator: + def __init__(self): + self.quit_flag = threading.Event() + + def __iter__(self): + return iter([]) + + +class TestSandboxStdoutRestore(unittest.TestCase): + """ + sys.stdout/sys.stderr are process-global: the sandbox redirect in + run() must be undone even when the script raises or returns early. + """ + + def test_experiment_stdout_restored_after_script_exception(self): + orig_stdout = sys.stdout + orig_stderr = sys.stderr + handled = ExperimentHandling("raise ValueError('boom')", None, None, + log_callback=lambda line: None) + handled.run() + self.assertIs(sys.stdout, orig_stdout) + self.assertIs(sys.stderr, orig_stderr) + self.assertIsNotNone(handled.raised_exception) + + def test_experiment_stdout_restored_on_success(self): + orig_stdout = sys.stdout + writer = _FakeWriter() + handled = ExperimentHandling("print('hello')\nexperiment = lambda: iter([])", + writer, None, log_callback=lambda line: None) + handled.run() + self.assertIs(sys.stdout, orig_stdout) + self.assertIsNone(handled.raised_exception) + + def test_result_stdout_restored_after_script_exception(self): + orig_stdout = sys.stdout + orig_stderr = sys.stderr + handling = ResultHandling("raise ValueError('boom')", _FakeResultIterator(), + None, log_callback=lambda line: None) + handling.run() + self.assertIs(sys.stdout, orig_stdout) + self.assertIs(sys.stderr, orig_stderr) + self.assertIsNotNone(handling.raised_exception) + + def test_result_stdout_restored_without_result_function(self): + orig_stdout = sys.stdout + handling = ResultHandling("x = 1", _FakeResultIterator(), None, + log_callback=lambda line: None) + handling.run() + self.assertIs(sys.stdout, orig_stdout) + self.assertIsNone(handling.raised_exception) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_MeasurementResult.py b/tests/test_MeasurementResult.py new file mode 100644 index 0000000..1657ff9 --- /dev/null +++ b/tests/test_MeasurementResult.py @@ -0,0 +1,86 @@ +import io +import os +import tempfile +import unittest + +import numpy as np +import tables + +from damaris.data.MeasurementResult import MeasurementResult, read_from_hdf + + +class TestMeasurementResult(unittest.TestCase): + def test_add_zero_preserves_data(self): + """mr + 0 must return a MeasurementResult with identical data.""" + mr = MeasurementResult("test_quantity") + for i in range(3): + mr[i] = float(i * i) + mr2 = mr + 0 + self.assertIsInstance(mr2, MeasurementResult) + self.assertEqual(mr2.quantity_name, "test_quantity") + np.testing.assert_array_equal(mr2.get_xdata(), mr.get_xdata()) + np.testing.assert_array_equal(mr2.get_ydata(), mr.get_ydata()) + + def test_add_zero_returns_independent_copy(self): + """Changing the copy must not change the original.""" + mr = MeasurementResult("q") + mr[1.0] = 5.0 + mr2 = mr + 0 + self.assertIsNot(mr2[1.0], mr[1.0]) + mr2[1.0] = mr2[1.0] + 100.0 + self.assertEqual(mr[1.0].mean(), 5.0) + + def test_add_nonzero_raises(self): + mr = MeasurementResult("q") + self.assertRaises(Exception, mr.__add__, 1) + + def test_get_lineplotdata_returns_means(self): + """get_lineplotdata must return the accumulated means, not self.y.""" + mr = MeasurementResult("test_quantity") + for i in range(3): + mr[float(i)] = float(i * i) + k, v = mr.get_lineplotdata() + self.assertIsInstance(k, np.ndarray) + self.assertIsInstance(v, np.ndarray) + self.assertEqual(k.shape, v.shape) + np.testing.assert_array_equal(v, mr.get_ydata()) + np.testing.assert_array_equal(v, [0.0, 1.0, 4.0]) + + def test_get_lineplotdata_empty(self): + """get_lineplotdata of an empty result must return empty arrays.""" + mr = MeasurementResult("q") + k, v = mr.get_lineplotdata() + self.assertEqual(k.shape[0], 0) + self.assertEqual(v.shape[0], 0) + + def test_write_to_csv(self): + mr = MeasurementResult("q") + mr[1.0] = 5.0 + mr[2.0] = 7.0 + buf = io.StringIO() + mr.write_to_csv(buf) + lines = buf.getvalue().splitlines() + self.assertEqual(lines[0], "# quantity:q") + self.assertEqual(lines[1], "# x y ysigma n") + self.assertEqual(len(lines), 4) + self.assertEqual(lines[2].split()[3], "1") + self.assertEqual(lines[3].split()[3], "1") + + def test_write_to_hdf_roundtrip(self): + mr = MeasurementResult("q") + mr[1.0] = 5.0 + mr[2.0] = 7.0 + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.h5") + with tables.open_file(path, mode="w") as h5file: + mr.write_to_hdf(h5file, "/", "mr", "test result") + with tables.open_file(path, mode="r") as h5file: + mr2 = read_from_hdf(h5file.get_node("/mr")) + self.assertIsNotNone(mr2) + self.assertEqual(mr2.quantity_name, "q") + np.testing.assert_array_equal(mr2.get_xdata(), mr.get_xdata()) + np.testing.assert_array_equal(mr2.get_ydata(), mr.get_ydata()) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 09ed734..8bd5d7a 100644 --- a/uv.lock +++ b/uv.lock @@ -938,6 +938,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyrefly" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/3e/ea6893be167fa14307d7f9a924bed9ecbbbd91b01953890b9b3c58ed5359/pyrefly-1.4.0.tar.gz", hash = "sha256:bb57c004a4842cfe93f93aebb5203ca6209a5f0c8bda47e8bc289e70940fd910", size = 6962062, upload-time = "2026-10-15T16:54:11.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/17/dfce6611acc00cb26667e5e1011438cc510f3067f961e0f07f7a0c68f416/pyrefly-1.4.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:460cec39bc8743915e9c2c8c10ba926ad0b29b099d89c1859fb97e0059f42fa7", size = 15705624, upload-time = "2026-10-15T16:53:43.412Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a7b96442123fa6ed733de0d1ea933e35d6fc6b7c9cf16e05279abb493493/pyrefly-1.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a6370d686b6cbbac8869832f09a52012888371956fbc68d446a349bc85ccd43", size = 14978669, upload-time = "2026-10-15T16:53:46.546Z" }, + { url = "https://files.pythonhosted.org/packages/23/97/dfcfec6a9d8948ab4b2746f7e87558a82bffabb7a54c22e7d3ce4bdc1932/pyrefly-1.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f1b16923a6583a6764e8aec07787e2ef4df644c7030294cba71339783a9451c", size = 15438858, upload-time = "2026-10-15T16:53:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/31/12/6fa414aa87e398d6ddc7145a5d993b0975fc3511198dbb72c47873653ab6/pyrefly-1.4.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:688ad4cb4d6e711d67026fdcfb37b599f86061efe11ba5704287330e7041a69e", size = 16852241, upload-time = "2026-10-15T16:53:51.496Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/2d7312262cf6481d6ad46eed55b8f2592c3cff60f95cc2f30a114cd1484e/pyrefly-1.4.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5f3c9dddb6f3128beb49cf4e0f5cb88d4d17f039008d8e0c2be653966acd234", size = 16751917, upload-time = "2026-10-15T16:53:54.39Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/b44a1cb23667bcbcdc88558cbef79fa9e07a1152c2a1da148036b1a2e0d1/pyrefly-1.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6951f51687304caebfedf13dd1a943aeafdbc95abcc4e24a683b97467a77007", size = 16066345, upload-time = "2026-10-15T16:53:57.436Z" }, + { url = "https://files.pythonhosted.org/packages/a6/07/7ca6ff0c53e3a90ecdab05790d33f430fca5c48687613a0b182744166d98/pyrefly-1.4.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:838de4d4a8b9b8ed39bedd0b8b55629c69661d73a6fa3a3a4adff24b9dbeb399", size = 15461223, upload-time = "2026-10-15T16:54:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/61/be/ec7b2f38a898ce5b2219dda730e87d31fc8cfb2108a3d5844f9d590f1281/pyrefly-1.4.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:afb9ce2e0ae3e6106fb45f6d47536ac92b6423e601e08401cdefa850066444f5", size = 16119099, upload-time = "2026-10-15T16:54:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/1b9549168b38a575d9c168e17b4b4ee30028d602ec0f7b07c198ef265dbe/pyrefly-1.4.0-py3-none-win32.whl", hash = "sha256:839db536d0ac29f1b23e2d974fb911075c2283e3e623755c45d826aafac175bc", size = 14858880, upload-time = "2026-10-15T16:54:05.114Z" }, + { url = "https://files.pythonhosted.org/packages/03/02/18f075d4de0e255f184f42fc28c1f0ecc9f49dd528678479fc90cf12246c/pyrefly-1.4.0-py3-none-win_amd64.whl", hash = "sha256:7aff37c2b711c8240ed650da0c6148237b327bf7e5dfacee65884f1adea22305", size = 15726220, upload-time = "2026-10-15T16:54:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/92/62/92947076681550904c72a9dd03b24e92694ede9a48548f5e487c849e66ea/pyrefly-1.4.0-py3-none-win_arm64.whl", hash = "sha256:88879f0cb749e5fea81d9ff4c2d5431a70349c458bcdf0b61de72c79214d90a5", size = 14943690, upload-time = "2026-10-15T16:54:09.689Z" }, +] + [[package]] name = "pyserial" version = "3.5" @@ -976,6 +995,11 @@ dependencies = [ { name = "tables" }, ] +[package.dev-dependencies] +dev = [ + { name = "pyrefly" }, +] + [package.metadata] requires-dist = [ { name = "matplotlib", specifier = ">=1.4.1,<=3.6" }, @@ -990,6 +1014,9 @@ requires-dist = [ { name = "tables", specifier = ">=3.1.1" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pyrefly" }] + [[package]] name = "pyxdg" version = "0.28"