mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 09:47:52 +02:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fad25286c | ||
|
|
359f9e37d2 | ||
|
|
8e7885f36d | ||
|
|
6d2e1c9d08 | ||
|
|
2c6719cf39 | ||
|
|
14f54da852 | ||
|
|
8ad7ad23ae | ||
|
|
0ebe35ac7a | ||
|
|
aac6e172f6 | ||
|
|
e527353974 | ||
|
|
fbd71c1313 | ||
|
|
583e643dac | ||
|
|
415c4ee3f2 | ||
|
|
96a88d2315 | ||
|
|
1a06dd7534 | ||
|
|
7bcf88d5eb | ||
|
|
b8aa37321d | ||
|
|
2f7c1b92a9 | ||
|
|
2d851b6b4e | ||
|
|
65bfccce8f | ||
|
|
69c38d67e4 | ||
|
|
f7db33f2c5 | ||
|
|
a9f16884b0 | ||
|
|
0eff18f5a0 | ||
|
|
abd88f7109 | ||
|
|
7dca0c09ff | ||
|
|
f0589b79ec | ||
|
|
687a68287d | ||
|
|
8b202852a5 | ||
|
|
bdaeef831b | ||
|
|
cab53543e6 | ||
|
|
3344f1b92a | ||
|
|
3af57abc48 | ||
|
|
49ba6feb3a | ||
|
|
2ed5d7208c | ||
|
|
223f102aa9 | ||
|
|
118f6af2b9 | ||
|
|
fc4b54239e | ||
|
|
ff545bf5c9 | ||
|
|
140310babe | ||
|
|
ea7a44213f | ||
|
|
98c0c64e85 | ||
|
|
413e4356cf | ||
|
|
e0b52fcedc | ||
|
|
f391a2fd00 | ||
|
|
ce9816480b | ||
|
|
5dd0af6894 | ||
|
|
7575c91c99 | ||
|
|
64708bc1b2 | ||
|
|
c7e35d7da6 | ||
|
|
cab32be009 | ||
|
|
0226188079 | ||
|
|
3c0e5955d4 | ||
|
|
9eae697df8 | ||
|
|
3e408b304b | ||
|
|
187c748e87 | ||
|
|
ed84293197 | ||
|
|
3f6d5c6641 | ||
|
|
8be98c9bb6 | ||
|
|
95ead7117e | ||
|
|
349c06bcac | ||
|
|
11aa15fefd | ||
|
|
af2fcffd5f | ||
|
|
92388c3cab | ||
|
|
cbe27e46cf | ||
|
|
2d1665c76b | ||
|
|
cac45626fc | ||
|
|
512e698e26 | ||
|
|
e6952a6d13 | ||
|
|
947ba9f8b7 | ||
|
|
a2f7aa58f9 | ||
|
|
441d8e9d83 | ||
|
|
fb53632574 | ||
|
|
ab3d75e5d7 | ||
|
|
2fa1755170 | ||
|
|
8a60cad918 | ||
|
|
242737b516 | ||
|
|
ce54daf754 | ||
|
|
98e711f4d0 | ||
|
|
167a891c47 | ||
|
|
c2be3e6186 | ||
|
|
b57b3bb1af | ||
|
|
abc5f911fe | ||
|
|
37680152fb | ||
| 23c206d550 | |||
|
|
1f4bb404d4 | ||
| 67f619ee89 | |||
| a9dd191629 | |||
| dfce55b675 | |||
| 6a3df34cdf | |||
| 8c9a9c9353 | |||
|
|
ffa5029535 | ||
| 0bf452ad1b | |||
|
|
20e9516595 | ||
| dc5fd9959f | |||
|
|
f75554bd7b | ||
|
|
5353fed7bf | ||
|
|
0f109e6ad3 | ||
| c2d469b454 | |||
|
|
d40da67140 | ||
| 7bec0b5e6c | |||
|
|
d32088b643 | ||
|
|
18b5d46678 | ||
| 378be81bf6 | |||
|
|
bb806c149d | ||
|
|
14e92e8d68 | ||
|
|
0feca4b157 | ||
|
|
a999f7669a | ||
|
|
4fb0a3b058 | ||
|
|
075cc79d6f | ||
| 2abbc2e408 | |||
| 91d8ffacff | |||
| 1325704750 | |||
| cd11ee51c1 | |||
| b16406a7fc | |||
| ff534ad67f | |||
| 87163fde32 | |||
| e7f644c507 | |||
| d8c101cdd7 | |||
| 67905e896c | |||
| 1bb3020703 | |||
|
|
f706010c2a | ||
| 601e45f790 | |||
| 9883caa1b3 | |||
| d37fbf5c4f | |||
| 48075e4fe3 | |||
| a460f3c0bd | |||
| 3e1708bf48 | |||
| 10e2906445 | |||
| 989a3f0808 | |||
| 8fee13a67b | |||
| cc69cf9b61 | |||
| d8038a8cd0 | |||
| 65b94ecd45 | |||
| db2d33e891 | |||
| 5e5c0ed980 | |||
| 004faecc69 | |||
| 81b6355932 | |||
| b790dd2d08 | |||
|
|
ac868c80de | ||
| 947db1e0f3 | |||
| 86f4deffd8 | |||
| b5f7b4feee | |||
| f10de383a8 | |||
| 4ee18ac3af |
@@ -6,10 +6,14 @@ image: $CI_DOCKER_REGISTRY/python:3.9
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
- Formatter
|
||||
- Unittests
|
||||
- test
|
||||
- Deploy
|
||||
|
||||
formatter:
|
||||
@@ -35,7 +39,7 @@ pylint:
|
||||
expire_in: 1 week
|
||||
|
||||
tests:
|
||||
stage: Unittests
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
@@ -68,7 +72,7 @@ semver:
|
||||
- git tag
|
||||
|
||||
# build
|
||||
- pip install python-semantic-release wheel
|
||||
- pip install python-semantic-release==7.* wheel
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- export REPOSITORY_USERNAME=__token__
|
||||
- export REPOSITORY_PASSWORD=$CI_PYPI_TOKEN
|
||||
@@ -94,4 +98,4 @@ semver:
|
||||
# - curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/beamline-experiment-control/221870/
|
||||
# rules:
|
||||
# - if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
# - if: '$CI_COMMIT_REF_NAME == "production"'
|
||||
# - if: '$CI_COMMIT_REF_NAME == "production"'
|
||||
|
||||
581
.pylintrc
Normal file
581
.pylintrc
Normal file
@@ -0,0 +1,581 @@
|
||||
[MASTER]
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-allow-list=PyQt5, pyqtgraph
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
|
||||
# for backward compatibility.)
|
||||
extension-pkg-whitelist=
|
||||
|
||||
# Return non-zero exit code if any of these messages/categories are detected,
|
||||
# even if score is above --fail-under value. Syntax same as enable. Messages
|
||||
# specified are enabled, while categories only check already-enabled messages.
|
||||
fail-on=
|
||||
|
||||
# Specify a score threshold to be exceeded before program exits with error.
|
||||
fail-under=8.0
|
||||
|
||||
# Files or directories to be skipped. They should be base names, not paths.
|
||||
ignore=CVS
|
||||
|
||||
# Add files or directories matching the regex patterns to the ignore-list. The
|
||||
# regex matches against paths and can be in Posix or Windows format.
|
||||
ignore-paths=
|
||||
|
||||
# Files or directories matching the regex patterns are skipped. The regex
|
||||
# matches against base names, not paths.
|
||||
ignore-patterns=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
|
||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
||||
# number of processors available to use.
|
||||
jobs=1
|
||||
|
||||
# Control the amount of potential inferred values when inferring a single
|
||||
# object. This can help the performance when dealing with large functions or
|
||||
# complex, nested conditions.
|
||||
limit-inference-results=100
|
||||
|
||||
# List of plugins (as comma separated values of python module names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.9
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
|
||||
confidence=
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once). You can also use "--disable=all" to
|
||||
# disable everything first and then reenable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use "--disable=all --enable=classes
|
||||
# --disable=W".
|
||||
disable=missing-module-docstring,
|
||||
missing-class-docstring,
|
||||
import-error,
|
||||
no-name-in-module,
|
||||
raw-checker-failed,
|
||||
bad-inline-option,
|
||||
locally-disabled,
|
||||
file-ignored,
|
||||
suppressed-message,
|
||||
deprecated-pragma,
|
||||
use-symbolic-message-instead,
|
||||
unused-wildcard-import,
|
||||
logging-fstring-interpolation,
|
||||
line-too-long,
|
||||
too-many-instance-attributes,
|
||||
wrong-import-order
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once). See also the "--disable" option for examples.
|
||||
enable=c-extension-no-member
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Python expression which should return a score less than or equal to 10. You
|
||||
# have access to the variables 'error', 'warning', 'refactor', and 'convention'
|
||||
# which contain the number of messages in each category, as well as 'statement'
|
||||
# which is the total number of statements analyzed. This score is used by the
|
||||
# global evaluation report (RP0004).
|
||||
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details.
|
||||
#msg-template=
|
||||
|
||||
# Set the output format. Available formats are text, parseable, colorized, json
|
||||
# and msvs (visual studio). You can also give a reporter class, e.g.
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
output-format=text
|
||||
|
||||
# Tells whether to display a full report or only the messages.
|
||||
reports=no
|
||||
|
||||
# Activate the evaluation score.
|
||||
score=yes
|
||||
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=sys.exit,argparse.parse_error
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# The type of string formatting that logging methods do. `old` means using %
|
||||
# formatting, `new` is for `{}` formatting.
|
||||
logging-format-style=old
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format.
|
||||
logging-modules=logging
|
||||
|
||||
|
||||
[SPELLING]
|
||||
|
||||
# Limits count of emitted suggestions for spelling mistakes.
|
||||
max-spelling-suggestions=4
|
||||
|
||||
# Spelling dictionary name. Available dictionaries: none. To make it work,
|
||||
# install the 'python-enchant' package.
|
||||
spelling-dict=
|
||||
|
||||
# List of comma separated words that should be considered directives if they
|
||||
# appear and the beginning of a comment and should not be checked.
|
||||
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
|
||||
|
||||
# List of comma separated words that should not be checked.
|
||||
spelling-ignore-words=
|
||||
|
||||
# A path to a file that contains the private dictionary; one word per line.
|
||||
spelling-private-dict-file=
|
||||
|
||||
# Tells whether to store unknown words to the private dictionary (see the
|
||||
# --spelling-private-dict-file option) instead of raising a message.
|
||||
spelling-store-unknown-words=no
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,
|
||||
XXX,
|
||||
TODO
|
||||
|
||||
# Regular expression of note tags to take in consideration.
|
||||
#notes-rgx=
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# List of decorators that produce context managers, such as
|
||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
||||
# produce valid context managers.
|
||||
contextmanager-decorators=contextlib.contextmanager
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=
|
||||
|
||||
# Tells whether missing members accessed in mixin class should be ignored. A
|
||||
# class is considered mixin if its name matches the mixin-class-rgx option.
|
||||
ignore-mixin-members=yes
|
||||
|
||||
# Tells whether to warn about missing members when the owner of the attribute
|
||||
# is inferred to be None.
|
||||
ignore-none=yes
|
||||
|
||||
# This flag controls whether pylint should warn about no-member and similar
|
||||
# checks whenever an opaque object is returned when inferring. The inference
|
||||
# can return multiple potential results while evaluating a Python object, but
|
||||
# some branches might not be evaluated, which results in partial inference. In
|
||||
# that case, it might be useful to still emit no-member and other checks for
|
||||
# the rest of the inferred objects.
|
||||
ignore-on-opaque-inference=yes
|
||||
|
||||
# List of class names for which member attributes should not be checked (useful
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis). It
|
||||
# supports qualified module names, as well as Unix pattern matching.
|
||||
ignored-modules=
|
||||
|
||||
# Show a hint with possible names when a member name was not found. The aspect
|
||||
# of finding the hint is based on edit distance.
|
||||
missing-member-hint=yes
|
||||
|
||||
# The minimum edit distance a name should have in order to be considered a
|
||||
# similar match for a missing member name.
|
||||
missing-member-hint-distance=1
|
||||
|
||||
# The total number of similar names that should be taken in consideration when
|
||||
# showing a hint for a missing member.
|
||||
missing-member-max-choices=1
|
||||
|
||||
# Regex pattern to define which classes are considered mixins ignore-mixin-
|
||||
# members is set to 'yes'
|
||||
mixin-class-rgx=.*[Mm]ixin
|
||||
|
||||
# List of decorators that change the signature of a decorated function.
|
||||
signature-mutators=
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid defining new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
# Tells whether unused global variables should be treated as a violation.
|
||||
allow-global-unused-variables=yes
|
||||
|
||||
# List of names allowed to shadow builtins
|
||||
allowed-redefined-builtins=
|
||||
|
||||
# List of strings which can identify a callback function by name. A callback
|
||||
# name must start or end with one of those strings.
|
||||
callbacks=cb_,
|
||||
_cb
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expected to
|
||||
# not be used).
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Argument names that match this expression will be ignored. Default to name
|
||||
# with leading underscore.
|
||||
ignored-argument-names=_.*|^ignored_|^unused_
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||
expected-line-ending-format=
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=100
|
||||
|
||||
# Maximum number of lines in a module.
|
||||
max-module-lines=1000
|
||||
|
||||
# Allow the body of a class to be on the same line as the declaration if body
|
||||
# contains single statement.
|
||||
single-line-class-stmt=no
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Comments are removed from the similarity computation
|
||||
ignore-comments=yes
|
||||
|
||||
# Docstrings are removed from the similarity computation
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Imports are removed from the similarity computation
|
||||
ignore-imports=no
|
||||
|
||||
# Signatures are removed from the similarity computation
|
||||
ignore-signatures=no
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
min-similarity-lines=4
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming style matching correct argument names.
|
||||
argument-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct argument names. Overrides argument-
|
||||
# naming-style.
|
||||
#argument-rgx=
|
||||
|
||||
# Naming style matching correct attribute names.
|
||||
attr-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||
# style.
|
||||
#attr-rgx=
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma.
|
||||
bad-names=foo,
|
||||
bar,
|
||||
baz,
|
||||
toto,
|
||||
tutu,
|
||||
tata
|
||||
|
||||
# Bad variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be refused
|
||||
bad-names-rgxs=
|
||||
|
||||
# Naming style matching correct class attribute names.
|
||||
class-attribute-naming-style=any
|
||||
|
||||
# Regular expression matching correct class attribute names. Overrides class-
|
||||
# attribute-naming-style.
|
||||
#class-attribute-rgx=
|
||||
|
||||
# Naming style matching correct class constant names.
|
||||
class-const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct class constant names. Overrides class-
|
||||
# const-naming-style.
|
||||
#class-const-rgx=
|
||||
|
||||
# Naming style matching correct class names.
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# Regular expression matching correct class names. Overrides class-naming-
|
||||
# style.
|
||||
#class-rgx=
|
||||
|
||||
# Naming style matching correct constant names.
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct constant names. Overrides const-naming-
|
||||
# style.
|
||||
#const-rgx=
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming style matching correct function names.
|
||||
function-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct function names. Overrides function-
|
||||
# naming-style.
|
||||
#function-rgx=
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma.
|
||||
good-names=i,
|
||||
ii,
|
||||
jj,
|
||||
kk,
|
||||
dr,
|
||||
j,
|
||||
k,
|
||||
ex,
|
||||
Run,
|
||||
cb,
|
||||
_
|
||||
|
||||
# Good variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be accepted
|
||||
good-names-rgxs=.*scanID.*,.*RID.*,.*pointID.*,.*ID.*,.*_2D.*,.*_1D.*
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name.
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming style matching correct inline iteration names.
|
||||
inlinevar-naming-style=any
|
||||
|
||||
# Regular expression matching correct inline iteration names. Overrides
|
||||
# inlinevar-naming-style.
|
||||
#inlinevar-rgx=
|
||||
|
||||
# Naming style matching correct method names.
|
||||
method-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct method names. Overrides method-naming-
|
||||
# style.
|
||||
#method-rgx=
|
||||
|
||||
# Naming style matching correct module names.
|
||||
module-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct module names. Overrides module-naming-
|
||||
# style.
|
||||
#module-rgx=
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
# These decorators are taken in consideration only for invalid-name.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Naming style matching correct variable names.
|
||||
variable-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct variable names. Overrides variable-
|
||||
# naming-style.
|
||||
#variable-rgx=
|
||||
|
||||
|
||||
[STRING]
|
||||
|
||||
# This flag controls whether inconsistent-quotes generates a warning when the
|
||||
# character used as a quote delimiter is used inconsistently within a module.
|
||||
check-quote-consistency=no
|
||||
|
||||
# This flag controls whether the implicit-str-concat should generate a warning
|
||||
# on implicit string concatenation in sequences defined over several lines.
|
||||
check-str-concat-over-line-jumps=no
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# List of modules that can be imported at any level, not just the top level
|
||||
# one.
|
||||
allow-any-import-level=
|
||||
|
||||
# Allow wildcard imports from modules that define __all__.
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma.
|
||||
deprecated-modules=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of external dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
ext-import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of all (i.e. internal and
|
||||
# external) dependencies to the given file (report RP0402 must not be
|
||||
# disabled).
|
||||
import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of internal dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
int-import-graph=
|
||||
|
||||
# Force import order to recognize a module as part of the standard
|
||||
# compatibility libraries.
|
||||
known-standard-library=
|
||||
|
||||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
# Couples of modules and preferred modules, separated by a comma.
|
||||
preferred-modules=
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# Warn about protected attribute access inside special methods
|
||||
check-protected-access-in-special-methods=no
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,
|
||||
__new__,
|
||||
setUp,
|
||||
__post_init__
|
||||
|
||||
# List of member names, which should be excluded from the protected access
|
||||
# warning.
|
||||
exclude-protected=_asdict,
|
||||
_fields,
|
||||
_replace,
|
||||
_source,
|
||||
_make
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=cls
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# List of regular expressions of class ancestor names to ignore when counting
|
||||
# public methods (see R0903)
|
||||
exclude-too-few-public-methods=
|
||||
|
||||
# List of qualified class names to ignore when counting class parents (see
|
||||
# R0901)
|
||||
ignored-parents=
|
||||
|
||||
# Maximum number of arguments for function / method.
|
||||
max-args=5
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
# Maximum number of boolean expressions in an if statement (see R0916).
|
||||
max-bool-expr=5
|
||||
|
||||
# Maximum number of branch for function / method body.
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body.
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
# Maximum number of return / yield for function / method body.
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of statements in function / method body.
|
||||
max-statements=50
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=2
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
# "BaseException, Exception".
|
||||
overgeneral-exceptions=BaseException,
|
||||
Exception
|
||||
191
CHANGELOG.md
191
CHANGELOG.md
@@ -2,6 +2,197 @@
|
||||
|
||||
<!--next-version-placeholder-->
|
||||
|
||||
## v0.11.0 (2023-09-04)
|
||||
|
||||
### Feature
|
||||
|
||||
* Colorbutton next to each curve in the table to be able to set up colors ([`2c6719c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2c6719cf390e6638cadbc814eb0c085bb45c3c6c))
|
||||
|
||||
### Fix
|
||||
|
||||
* User selected colors are preserved with the new scan ([`8e7885f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8e7885f36dd2812e3285c4d2d101212055644c7b))
|
||||
* Colorbutton change now symbols as well ([`6d2e1c9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6d2e1c9d08595a45f502287c6490905e8df3db10))
|
||||
|
||||
## v0.10.0 (2023-09-01)
|
||||
|
||||
### Feature
|
||||
|
||||
* Load and export configuration into .yaml from GUI ([`e527353`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e5273539741a1261e69b1bf76af78c7c1ab0d901))
|
||||
* Error messages if name or entry is wrong ([`415c4ee`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/415c4ee3f232c02ee5a00a82352c7fbb0d324449))
|
||||
* Number of columns can be dynamically changed ([`65bfccc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/65bfccce8fce158150652fead769721de805d99e))
|
||||
* Multi window interface created for extreme BL ([`69c38d6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/69c38d67e4e9b8a30767f6f67defce6c5c2e5b16))
|
||||
|
||||
### Fix
|
||||
|
||||
* Check if num_columns is not higher that actual number of plots ([`aac6e17`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/aac6e172f6e4583e751bee00db6f381aaff8ac69))
|
||||
* Add max number of columns according to the number of plots ([`fbd71c1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fbd71c131386508a9ec7bb5963afefc13f8b1618))
|
||||
* More specific error messages ([`583e643`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/583e643dacac3d7aaa744751baef2da69f6f892e))
|
||||
* Bec_dispatcher.py can take multiple workers as a list ([`7bcf88d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7bcf88d5eb139aa3cf491185b9fb3f45aa5e39a2))
|
||||
* Config.yaml can be passed as a console argument to extreme.py ([`b8aa373`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b8aa37321d6ac0ebd9f2237c8d2ed6594b614b57))
|
||||
* Columns span generalised for any number of columns ([`2d851b6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2d851b6b4eb0002e32908c2effbfb79122f18c24))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Updated documentation and TODOs ([`0ebe35a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0ebe35ac7a144db84c323f9ecb85dfdf6de66c21))
|
||||
* Fixed documentation ([`2f7c1b9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2f7c1b92a9624741f6dea44fc8f3c19a8a506fd9))
|
||||
|
||||
## v0.9.0 (2023-08-29)
|
||||
|
||||
### Feature
|
||||
|
||||
* Migrate to .yaml config file instead of argparse ([`a9f1688`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a9f16884b0b274e36fdb531b56a26343692a78f5))
|
||||
* Better color coding of curves ([`0eff18f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0eff18f5a074ea806d43d52ae72bf87f0187a26d))
|
||||
|
||||
## v0.8.1 (2023-08-29)
|
||||
|
||||
### Fix
|
||||
|
||||
* Added missing local .ui file ([`f0589b7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f0589b79ec7f50ee9d040b911d1874b4232659d5))
|
||||
|
||||
## v0.8.0 (2023-08-29)
|
||||
|
||||
### Feature
|
||||
|
||||
* User can specify tuple of (x,y) devices which wants to plot ([`3344f1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3344f1b92a7e4f4ecd2e63c66aa01d3a4c325070))
|
||||
* Fit table hardcode to "gaussian_fit_worker_3" ([`3af57ab`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3af57abc4888dfcd0224bf50708488dc8192be84))
|
||||
* Crosshair snapped to x, y data automatically, clicked coordinates glows ([`49ba6fe`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/49ba6feb3a8494336c5772a06e9569d611fc240a))
|
||||
* Crosshair snaps to data, but it is activated with button due to debug ([`223f102`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/223f102aa9f0e625fecef37c827c55f9062330d7))
|
||||
* Dap fit plotted as curve, data as scatter ([`118f6af`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/118f6af2b97188398a3dd0e2121f73328c53465b))
|
||||
* Oneplot can receive one motor and one monitor signal ([`ff545bf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ff545bf5c9e707f2dd9b43f9d059aa8605f3916b))
|
||||
* Oneplot initialized as an example app for plotting motor vs monitor signals + dispatcher loop over msg ([`98c0c64`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/98c0c64e8577f7e40eb0324dfe97d0ae4670c3a2))
|
||||
|
||||
### Fix
|
||||
|
||||
* User can disable dap_worker and just choose signals to plot ([`cab5354`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cab53543e644921df69c57c70ad2b3a03bbafcc1))
|
||||
* Crosshair snaps correctly to x dataset ([`2ed5d72`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2ed5d7208c42f8a1175a49236d706ebf503875e4))
|
||||
|
||||
## v0.7.0 (2023-08-28)
|
||||
|
||||
### Feature
|
||||
|
||||
* Labels of current motors are shown in motors limits ([`413e435`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/413e4356cfde6e2432682332e470eb69427ad397))
|
||||
* Total number of points, scatter size and number of point to dim after last position can be changed from GUI ([`e0b52fc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e0b52fcedca46d913d1677b45f9815eccd92e8f7))
|
||||
* Speed and frequency can be updated from GUI ([`f391a2f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f391a2fd004f1dc8187cfe12d60f856427ae01ec))
|
||||
* Speed and frequency is retrieved from devices ([`ce98164`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ce9816480b82373895b602d1a1bca7d1d9725f01))
|
||||
* Delete coordinate table row by DELETE or BACKSPACE key ([`5dd0af6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5dd0af6894a5d97457d60ef18b098e40856e4875))
|
||||
* Motor selection ([`cab32be`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cab32be0092185870b5a12398103475342c8b1fd))
|
||||
* New GUI ([`0226188`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0226188079f1dac4eece6b1a6fa330620f1504bc))
|
||||
* Keyboard shortcut to go to coordinates ([`3c0e595`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3c0e5955d40a67935b8fb064d5c52fd3f29bd1a1))
|
||||
* Ability to choose how many points should be dimmed before reaching the threshold + total number of point which should be stored. ([`9eae697`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9eae697df8a2f3961454db9ed397353f110c0e67))
|
||||
* Stop movement function, one callback function for 2 motors, move_finished is emitted in move_motor function not in callback ([`187c748`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/187c748e87264448d5026d9fa2f15b5fc9a55949))
|
||||
* Controls are disabled while motor is moving and enabled when motor movement is finished ([`ed84293`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ed842931971fbf87ed2f3e366eb822531ef5aacc))
|
||||
* Motor coordinates are now scatter instead of image ([`3f6d5c6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3f6d5c66411459703c402f7449e8b1abae9a2b08))
|
||||
* Going to absolute coordinates saves coordinate in the table for later use with tag ([`8be98c9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8be98c9bb6af941a69c593c62d5c52339d2262bc))
|
||||
* Table with coordinates getting initial coordinates of motor ([`92388c3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/92388c3cab7e024978aaa2906afbd698015dec66))
|
||||
* Motor move to absolute (X,Y) coordinates ([`cbe27e4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cbe27e46cfb6282c71844641e1ed6059e8fa96bf))
|
||||
* Motor limits can be changed by spinBoxes ([`2d1665c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2d1665c76b8174d9fffa3442afa98fe1ea6ac207))
|
||||
* Switch for keyboard shortcuts for motor movement ([`cac4562`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cac45626fc9a315f9012b110760a92e27e5ed226))
|
||||
* Setting map according to motor limits ([`512e698`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/512e698e26d9eef05b4f430475ccc268b68ad632))
|
||||
* Map of motor position ([`e6952a6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e6952a6d13c84487fd6ab08056f1f5b46d594b8a))
|
||||
* Motor_example.py created, motor samx and samy can be moved by buttons ([`947ba9f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/947ba9f8b730e96082cb51ff6894734a0e119ca1))
|
||||
|
||||
### Fix
|
||||
|
||||
* Line_plot.py default changed back to "gauss_bpm" ([`64708bc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/64708bc1b2e6a4256da9123d0215fc87e0afa455))
|
||||
* Motor selection is disabled while motor is moving ([`c7e35d7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c7e35d7da69853343aa7eee53c8ad988eb490d93))
|
||||
* Init_motor_map receive motor position from motor_thread ([`95ead71`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/95ead7117e59e0979aec51b85b49537ab728cad4))
|
||||
* Motor movement absolute fixed - movement by thread ([`11aa15f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/11aa15fefda7433e885cc8586f93c97af83b0c48))
|
||||
|
||||
## v0.6.3 (2023-08-17)
|
||||
|
||||
### Fix
|
||||
|
||||
* Crosshair handles dynamic changes of number of curves in 1D plot ([`242737b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/242737b516af7c524a6c8a98db566815f0f4ab65))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Crosshair class documentation ([`8a60cad`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8a60cad9187df2b2bc93dc78dd01ceb42df9c9af))
|
||||
|
||||
## v0.6.2 (2023-08-17)
|
||||
|
||||
### Fix
|
||||
|
||||
* Correct coordinates for cursor table ([`ce54daf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ce54daf754cb2410790216585467c0ffcc8e3587))
|
||||
|
||||
## v0.6.1 (2023-08-14)
|
||||
|
||||
### Fix
|
||||
|
||||
* Crosshair snaps to correct coordinates also with logx and logy ([`167a891`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/167a891c474b09ef7738e473c4a2e89dbbcbe881))
|
||||
|
||||
## v0.6.0 (2023-08-11)
|
||||
|
||||
### Feature
|
||||
|
||||
* New GUI for line_plot.py ([`b57b3bb`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b57b3bb1afc7c85acc7ed328ac8a219f392869f1))
|
||||
* Cursor universal signals ([`20e9516`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/20e951659558b7fc023e357bfe07d812c5fd020a))
|
||||
|
||||
## v0.5.0 (2023-08-11)
|
||||
|
||||
### Feature
|
||||
|
||||
* Add generic connect function for slots ([`6a3df34`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6a3df34cdfbec2434153362ded630305e5dc5e28))
|
||||
* Add possibility to provide service config ([`8c9a9c9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8c9a9c93535ee77c0622b483a3157af367ebce1f))
|
||||
|
||||
### Fix
|
||||
|
||||
* Dispatcher argparse and scan_plot tests ([`67f619e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/67f619ee897e0040c6310e67d69fbb2e0685293d))
|
||||
* Gui event removing bugs ([`a9dd191`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a9dd191629295ca476e2f9a1b9944ff355216583))
|
||||
|
||||
## v0.4.0 (2023-08-11)
|
||||
|
||||
### Feature
|
||||
|
||||
* Cursor universal for 1D and 2D ([`f75554b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f75554bd7b072207847956a8720b9a62c20ba2c8))
|
||||
* Added qt_utils package with general Crosshair function ([`5353fed`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5353fed7bfe1819819fa3348ec93d2d0ba540628))
|
||||
* 2D plot updating ([`d32088b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d32088b643a4d0613c32fb464a0a55a3b6b684d6))
|
||||
* Metadata available on_dap_update ([`18b5d46`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/18b5d46678619a972815532629ce96c121f5fcc9))
|
||||
* Plotting from streamer ([`bb806c1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bb806c149dee88023ecb647b523cbd5189ea9001))
|
||||
* Added Legend to plot ([`0feca4b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0feca4b1578820ec1f5f3ead3073e4d45c23798b))
|
||||
* Cursor coordinate as a QTable ([`a999f76`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a999f7669a12910ad66e10a6d2e75197b2dce1c2))
|
||||
* Changed from PlotItem to GraphicsLayoutWidget, added LabelItem ([`075cc79`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/075cc79d6fa011803cf4a06fbff8faa951c1b59f))
|
||||
* Add display_ui_file.py ([`91d8ffa`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/91d8ffacffcbeebdf7623caf62e07244c4dcee16))
|
||||
* Add disconnect_dap_slot ([`1325704`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1325704750ebab897e3dcae80c9d455bfbbf886f))
|
||||
* Inherit from GraphicsView for consistency with 2D plot ([`d8c101c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d8c101cdd7f960a152a1f318911cac6eecf6bad4))
|
||||
* Add BECScanPlot2D ([`67905e8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/67905e896c81383f57c268db544b3314104bda38))
|
||||
* Emit the full bec message to slots ([`1bb3020`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1bb30207038f3a54c0e96dbbbcd1ea7f6c70eca2))
|
||||
|
||||
### Fix
|
||||
|
||||
* Q selection for gui_event signal ([`0bf452a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0bf452ad1b7d9ad941e2ef4b8d61ec4ed5266415))
|
||||
* Fixed logic in data subscription ([`c2d469b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c2d469b4543fcf237b274399b83969cc2213b61b))
|
||||
* Scan_plot to accept metadata from dap signal ([`7bec0b5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7bec0b5e6c1663670f8fcc2fc6aa6c8b6df28b61))
|
||||
* Plotting latest 1d curves ([`378be81`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/378be81bf6dd5e9239f8f1fb908cafc97161c79d))
|
||||
* Testing the data structure of plotting ([`4fb0a3b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4fb0a3b058957f5b37227ff7c8e9bdf5259a1cde))
|
||||
* Fix examples when run directly as a script ([`cd11ee5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cd11ee51c1c725255e748a32b89a74487e84a631))
|
||||
* Module paths ([`e7f644c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e7f644c5079a8665d7d872eb0b27ed7da6cbd078))
|
||||
|
||||
## v0.3.0 (2023-07-19)
|
||||
|
||||
### Feature
|
||||
|
||||
* Add auto-computed color_list from colormaps ([`3e1708b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3e1708bf48bc15a25c0d01242fff28d6db868e02))
|
||||
* Add functionality for plotting multiple signals ([`10e2906`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/10e29064455f50bc3b66c55b4361575957db1489))
|
||||
* Added lineplot widget ([`989a3f0`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/989a3f080839b98f1e1c2118600cddf449120124))
|
||||
* Added ctrl_c from grum ([`8fee13a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8fee13a67bef3ed6ed6de9d47438f04687f548d8))
|
||||
|
||||
### Fix
|
||||
|
||||
* Add warning for non-existing signalz ([`48075e4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/48075e4fe3187f6ac8d0b61f94f8df73b8fd6daf))
|
||||
* Documentation and bugfix for mouse_moved ([`a460f3c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a460f3c0bd7b9e106a758bc330f361868407b1e3))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add notes about qt designer install via conda-forge ([`d8038a8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d8038a8cd0efa3a16df403390164603e4e8afdd8))
|
||||
* Added license ([`db2d33e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/db2d33e8912dc493cce9ee7f09d8336155110079))
|
||||
|
||||
## v0.2.1 (2023-07-13)
|
||||
|
||||
### Fix
|
||||
|
||||
* Fixed setup config (wrong name) ([`947db1e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/947db1e0f32b067e67f94a7c8321da5194b1547b))
|
||||
* Fixed bec_lib dependency ([`86f4def`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/86f4deffd899111e8997010487ec54c6c62c43ab))
|
||||
|
||||
## v0.2.0 (2023-07-13)
|
||||
|
||||
### Feature
|
||||
|
||||
29
LICENSE
Normal file
29
LICENSE
Normal file
@@ -0,0 +1,29 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2023, bec
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
457
bec_widgets/basic_plot.py
Normal file
457
bec_widgets/basic_plot.py
Normal file
@@ -0,0 +1,457 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.core import BECMessage
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtWidgets import QCheckBox, QTableWidgetItem
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets, uic
|
||||
from pyqtgraph.Qt.QtCore import pyqtSignal
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
from bec_lib.core.redis_connector import MessageObject, RedisConnector
|
||||
|
||||
client = bec_dispatcher.client
|
||||
|
||||
|
||||
class BasicPlot(QtWidgets.QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
roi_signal = pyqtSignal(tuple)
|
||||
|
||||
def __init__(self, name="", y_value_list=["gauss_bpm"]) -> None:
|
||||
"""
|
||||
Basic plot widget for displaying scan data.
|
||||
|
||||
Args:
|
||||
name (str, optional): Name of the plot. Defaults to "".
|
||||
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
|
||||
"""
|
||||
|
||||
super(BasicPlot, self).__init__()
|
||||
# Set style for pyqtgraph plots
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "basic_plot.ui"), self)
|
||||
|
||||
# Set splitter distribution of widgets
|
||||
self.splitter.setSizes([3, 1])
|
||||
|
||||
self._idle_time = 100
|
||||
self.title = ""
|
||||
self.label_bottom = ""
|
||||
self.label_left = ""
|
||||
self.producer = RedisConnector(["localhost:6379"]).producer()
|
||||
|
||||
self.scan_motors = []
|
||||
self.y_value_list = y_value_list
|
||||
self.previous_y_value_list = None
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
self.curves = []
|
||||
self.pens = []
|
||||
self.brushs = []
|
||||
|
||||
self.plotter_scan_id = None
|
||||
|
||||
# TODO to be moved to utils function
|
||||
plotstyles = {
|
||||
"symbol": "o",
|
||||
"symbolSize": 10,
|
||||
}
|
||||
|
||||
color_list = BasicPlot.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
|
||||
|
||||
# setup plots - GraphicsLayoutWidget
|
||||
# LabelItem
|
||||
self.label = pg.LabelItem(justify="center")
|
||||
self.glw.addItem(self.label)
|
||||
self.label.setText("ROI region")
|
||||
|
||||
# PlotItem - main window
|
||||
self.glw.nextRow()
|
||||
self.plot = pg.PlotItem()
|
||||
self.plot.setLogMode(True, True)
|
||||
self.glw.addItem(self.plot)
|
||||
self.plot.addLegend()
|
||||
|
||||
# ImageItem - 2D view #TODO add 2D plot for ROI and 1D plot for mouse click
|
||||
self.glw.nextRow()
|
||||
self.plot_roi = pg.PlotItem()
|
||||
self.img = pg.ImageItem()
|
||||
self.glw.addItem(self.plot_roi)
|
||||
self.plot_roi.addItem(self.img)
|
||||
|
||||
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
|
||||
self.roi_selector = pg.LinearRegionItem([-1, 1])
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
brush = mkBrush(color=color_list[ii])
|
||||
curve = pg.PlotDataItem(
|
||||
**plotstyles, symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value
|
||||
)
|
||||
self.plot.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
self.pens.append(pen)
|
||||
self.brushs.append(brush)
|
||||
|
||||
self.add_crosshair(self.plot)
|
||||
self.add_crosshair(self.plot_roi)
|
||||
|
||||
self.crosshair_v = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.crosshair_h = pg.InfiniteLine(angle=0, movable=False)
|
||||
#
|
||||
# for plot in (self.plot_roi, self.plot):
|
||||
# plot.addItem(self.crosshair_v, ignoreBounds=True)
|
||||
# plot.addItem(self.crosshair_h, ignoreBounds=True)
|
||||
|
||||
# self.plot.addItem(self.crosshair_v, ignoreBounds=True)
|
||||
# self.plot.addItem(self.crosshair_h, ignoreBounds=True)
|
||||
|
||||
# self.plot_roi.addItem(self.crosshair_v, ignoreBounds=True)
|
||||
# self.plot_roi.addItem(self.crosshair_h, ignoreBounds=True)
|
||||
|
||||
# Add textItems
|
||||
self.add_text_items()
|
||||
|
||||
# Manage signals
|
||||
self.proxy = pg.SignalProxy(
|
||||
self.plot.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
|
||||
)
|
||||
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
|
||||
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
|
||||
|
||||
# Debug functions
|
||||
self.pushButton_debug.clicked.connect(self.generate_2D_data_update)
|
||||
# self.generate_2D_data()
|
||||
|
||||
self._current_proj = None
|
||||
self._current_metadata_ep = "px_stream/projection_{}/metadata"
|
||||
|
||||
self.data_retriever = threading.Thread(target=self.on_projection, daemon=True)
|
||||
self.data_retriever.start()
|
||||
|
||||
def debug(self):
|
||||
"""
|
||||
Debug button just for quick testing
|
||||
"""
|
||||
|
||||
def generate_2D_data(self):
|
||||
data = np.random.normal(size=(1, 100))
|
||||
self.img.setImage(data)
|
||||
|
||||
def generate_2D_data_update(self):
|
||||
data = np.random.normal(size=(200, 300))
|
||||
self.img.setImage(data, levels=(0.2, 0.5))
|
||||
|
||||
def add_crosshair(self, plot):
|
||||
crosshair_v = pg.InfiniteLine(angle=90, movable=False)
|
||||
crosshair_h = pg.InfiniteLine(angle=0, movable=False)
|
||||
|
||||
plot.addItem(crosshair_v)
|
||||
plot.addItem(crosshair_h)
|
||||
|
||||
def get_roi_region(self):
|
||||
"""For testing purpose now, get roi region and print it to self.label as tuple"""
|
||||
region = self.roi_selector.getRegion()
|
||||
self.label.setText(f"x = {(10**region[0]):.4f}, y ={(10**region[1]):.4f}")
|
||||
return_dict = {
|
||||
"horiz_roi": [
|
||||
np.where(self.plotter_data_x[0] > 10 ** region[0])[0][0],
|
||||
np.where(self.plotter_data_x[0] < 10 ** region[1])[0][-1],
|
||||
]
|
||||
}
|
||||
msg = BECMessage.DeviceMessage(signals=return_dict).dumps()
|
||||
self.producer.set_and_publish("px_stream/gui_event", msg=msg)
|
||||
self.roi_signal.emit(region)
|
||||
|
||||
def add_text_items(self): # TODO probably can be removed
|
||||
"""Add text items to the plot"""
|
||||
|
||||
# self.mouse_box_data.setText("Mouse cursor")
|
||||
# TODO Via StyleSheet, one may set the color of the full QLabel
|
||||
# self.mouse_box_data.setStyleSheet(f"QLabel {{color : rgba{self.pens[0].color().getRgb()}}}")
|
||||
|
||||
def mouse_moved(self, event: tuple) -> None:
|
||||
"""
|
||||
Update the mouse table with the current mouse position and the corresponding data.
|
||||
|
||||
Args:
|
||||
event (tuple): Mouse event containing the position of the mouse cursor.
|
||||
The position is stored in first entry as horizontal, vertical pixel.
|
||||
"""
|
||||
pos = event[0]
|
||||
if not self.plot.sceneBoundingRect().contains(pos):
|
||||
return
|
||||
mousePoint = self.plot.vb.mapSceneToView(pos)
|
||||
self.crosshair_v.setPos(mousePoint.x())
|
||||
self.crosshair_h.setPos(mousePoint.y())
|
||||
if not self.plotter_data_x:
|
||||
return
|
||||
|
||||
closest_point = self.closest_x_y_value(
|
||||
mousePoint.x(), self.plotter_data_x[0], self.plotter_data_y[0]
|
||||
)
|
||||
# self.precision = 3
|
||||
# ii = 0
|
||||
# y_value = self.y_value_list[ii]
|
||||
# x_data = f"{10**closest_point[0]:.{self.precision}f}"
|
||||
# y_data = f"{10**closest_point[1]:.{self.precision}f}"
|
||||
#
|
||||
# # Write coordinate to QTable
|
||||
# self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
|
||||
# self.mouse_table.setItem(ii, 2, QTableWidgetItem(str(x_data)))
|
||||
# self.mouse_table.setItem(ii, 3, QTableWidgetItem(str(y_data)))
|
||||
#
|
||||
# self.mouse_table.resizeColumnsToContents()
|
||||
|
||||
def closest_x_y_value(self, input_value, list_x, list_y) -> tuple:
|
||||
"""
|
||||
Find the closest x and y value to the input value.
|
||||
|
||||
Args:
|
||||
input_value (float): Input value
|
||||
list_x (list): List of x values
|
||||
list_y (list): List of y values
|
||||
|
||||
Returns:
|
||||
tuple: Closest x and y value
|
||||
"""
|
||||
arr = np.asarray(list_x)
|
||||
i = (np.abs(arr - input_value)).argmin()
|
||||
return list_x[i], list_y[i]
|
||||
|
||||
def update(self):
|
||||
"""Update the plot with the new data."""
|
||||
# check if roi selector is in the plot
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
# check if QTable was initialised and if list of devices was changed
|
||||
if self.y_value_list != self.previous_y_value_list:
|
||||
self.setup_cursor_table()
|
||||
self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
|
||||
|
||||
self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
|
||||
# if len(self.plotter_data_x[0]) <= 1:
|
||||
# return
|
||||
# self.plot.setLabel("bottom", self.label_bottom)
|
||||
# self.plot.setLabel("left", self.label_left)
|
||||
# for ii in range(len(self.y_value_list)):
|
||||
# self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, data: dict, metadata: dict) -> None:
|
||||
"""Update function that is called during the scan callback. To avoid
|
||||
too many renderings, the GUI is only processing events every <_idle_time> ms.
|
||||
|
||||
Args:
|
||||
data (dict): Dictionary containing a new scan segment
|
||||
metadata (dict): Scan metadata
|
||||
|
||||
"""
|
||||
if metadata["scanID"] != self.plotter_scan_id:
|
||||
self.plotter_scan_id = metadata["scanID"]
|
||||
self._reset_plot_data()
|
||||
|
||||
self.title = f"Scan {metadata['scan_number']}"
|
||||
|
||||
self.scan_motors = scan_motors = metadata.get("scan_report_devices")
|
||||
# client = BECClient()
|
||||
remove_y_value_index = [
|
||||
index
|
||||
for index, y_value in enumerate(self.y_value_list)
|
||||
if y_value not in client.device_manager.devices
|
||||
]
|
||||
if remove_y_value_index:
|
||||
for ii in sorted(remove_y_value_index, reverse=True):
|
||||
# TODO Use bec warning message??? to be discussed with Klaus
|
||||
warnings.warn(
|
||||
f"Warning: no matching signal for {self.y_value_list[ii]} found in list of devices. Removing from plot."
|
||||
)
|
||||
self.remove_curve_by_name(self.plot, self.y_value_list[ii])
|
||||
self.y_value_list.pop(ii)
|
||||
|
||||
self.precision = client.device_manager.devices[scan_motors[0]]._info["describe"][
|
||||
scan_motors[0]
|
||||
]["precision"]
|
||||
# TODO after update of bec_lib, this will be new way to access data
|
||||
# self.precision = client.device_manager.devices[scan_motors[0]].precision
|
||||
|
||||
x = data["data"][scan_motors[0]][scan_motors[0]]["value"]
|
||||
self.plotter_data_x.append(x)
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
y = data["data"][y_value][y_value]["value"]
|
||||
self.plotter_data_y[ii].append(y)
|
||||
self.label_bottom = scan_motors[0]
|
||||
self.label_left = f"{', '.join(self.y_value_list)}"
|
||||
|
||||
# print(f'metadata scan N{metadata["scan_number"]}') #TODO put as label on top of plot
|
||||
# print(f'Data point = {data["point_id"]}') #TODO can be used for progress bar
|
||||
|
||||
if len(self.plotter_data_x) <= 1:
|
||||
return
|
||||
self.update_signal.emit()
|
||||
|
||||
def _reset_plot_data(self):
|
||||
"""Reset the plot data."""
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
for ii in range(len(self.y_value_list)):
|
||||
self.curves[ii].setData([], [])
|
||||
self.plotter_data_y.append([])
|
||||
|
||||
def setup_cursor_table(self):
|
||||
"""QTable formatting according to N of devices displayed in plot."""
|
||||
|
||||
# Init number of rows in table according to n of devices
|
||||
self.mouse_table.setRowCount(len(self.y_value_list))
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setChecked(True)
|
||||
# TODO just for testing, will be replaced by removing/adding curve
|
||||
checkbox.stateChanged.connect(lambda: print("status Changed"))
|
||||
# checkbox.stateChanged.connect(lambda: self.remove_curve_by_name(plot=self.plot, checkbox=checkbox, name=y_value))
|
||||
self.mouse_table.setCellWidget(ii, 0, checkbox)
|
||||
self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
|
||||
|
||||
self.mouse_table.resizeColumnsToContents()
|
||||
|
||||
@staticmethod
|
||||
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
|
||||
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
|
||||
"""Removes a curve from the given plot by the specified name.
|
||||
|
||||
Args:
|
||||
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
|
||||
name (str): The name of the curve to remove.
|
||||
"""
|
||||
# if checkbox.isChecked():
|
||||
for item in plot.items:
|
||||
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
|
||||
plot.removeItem(item)
|
||||
return
|
||||
|
||||
# else:
|
||||
# return
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = BasicPlot.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
|
||||
def on_projection(self):
|
||||
while True:
|
||||
if self._current_proj is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
endpoint = f"px_stream/projection_{self._current_proj}/data"
|
||||
msgs = client.producer.lrange(topic=endpoint, start=-1, end=-1)
|
||||
data = [BECMessage.DeviceMessage.loads(msg) for msg in msgs]
|
||||
if not data:
|
||||
continue
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
self.plotter_data_y = [
|
||||
np.sum(
|
||||
np.sum(data[-1].content["signals"]["data"] * self._current_norm, axis=1)
|
||||
/ np.sum(self._current_norm, axis=0),
|
||||
axis=0,
|
||||
).squeeze()
|
||||
]
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_dap_update(self, data: dict, metadata: dict):
|
||||
self.img.setImage(data["z"].T)
|
||||
# time.sleep(0,1)
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def new_proj(self, data):
|
||||
proj_nr = data["proj_nr"]
|
||||
endpoint = f"px_stream/projection_{proj_nr}/metadata"
|
||||
msg_raw = client.producer.get(topic=endpoint)
|
||||
msg = BECMessage.DeviceMessage.loads(msg_raw)
|
||||
self._current_q = msg.content["signals"]["q"]
|
||||
self._current_norm = msg.content["signals"]["norm_sum"]
|
||||
self._current_metadata = msg.content["signals"]["metadata"]
|
||||
|
||||
self.plotter_data_x = [self._current_q]
|
||||
self._current_proj = proj_nr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
from bec_widgets import ctrl_c
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--signals",
|
||||
help="specify recorded signals",
|
||||
nargs="+",
|
||||
default=["gauss_bpm"],
|
||||
)
|
||||
# default = ["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
|
||||
value = parser.parse_args()
|
||||
print(f"Plotting signals for: {', '.join(value.signals)}")
|
||||
client = bec_dispatcher.client
|
||||
# client.start()
|
||||
app = QtWidgets.QApplication([])
|
||||
ctrl_c.setup(app)
|
||||
plot = BasicPlot(y_value_list=value.signals)
|
||||
# bec_dispatcher.connect(plot)
|
||||
bec_dispatcher.connect_proj_id(plot.new_proj)
|
||||
bec_dispatcher.connect_dap_slot(plot.on_dap_update, "px_dap_worker")
|
||||
plot.show()
|
||||
# client.callbacks.register("scan_segment", plot, sync=False)
|
||||
app.exec_()
|
||||
77
bec_widgets/basic_plot.ui
Normal file
77
bec_widgets/basic_plot.ui
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>845</width>
|
||||
<height>635</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Line Plot</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="opaqueResize">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="verticalLayoutWidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_debug">
|
||||
<property name="text">
|
||||
<string>Debug</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="GraphicsLayoutWidget" name="glw"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QTableWidget" name="mouse_table">
|
||||
<property name="textElideMode">
|
||||
<enum>Qt::ElideMiddle</enum>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Display</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Device</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
237
bec_widgets/bec_dispatcher.py
Normal file
237
bec_widgets/bec_dispatcher.py
Normal file
@@ -0,0 +1,237 @@
|
||||
import argparse
|
||||
import itertools
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
|
||||
from bec_lib import BECClient
|
||||
from bec_lib.core import BECMessage, MessageEndpoints, ServiceConfig
|
||||
from bec_lib.core.redis_connector import RedisConsumerThreaded
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BECDap:
|
||||
"""Utility class to keep track of slots associated with a particular dap redis consumer"""
|
||||
|
||||
consumer: RedisConsumerThreaded
|
||||
slots = set()
|
||||
|
||||
|
||||
# Adding a new pyqt signal requres a class factory, as they must be part of the class definition
|
||||
# and cannot be dynamically added as class attributes after the class has been defined.
|
||||
_signal_class_factory = (
|
||||
type(f"Signal{i}", (QObject,), dict(signal=pyqtSignal(dict, dict))) for i in itertools.count()
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Connection:
|
||||
"""Utility class to keep track of slots connected to a particular redis consumer"""
|
||||
|
||||
consumer: RedisConsumerThreaded
|
||||
slots = set()
|
||||
# keep a reference to a new signal class, so it is not gc'ed
|
||||
_signal_container = next(_signal_class_factory)()
|
||||
|
||||
def __post_init__(self):
|
||||
self.signal = self._signal_container.signal
|
||||
|
||||
|
||||
class _BECDispatcher(QObject):
|
||||
new_scan = pyqtSignal(dict, dict)
|
||||
scan_segment = pyqtSignal(dict, dict)
|
||||
new_dap_data = pyqtSignal(dict, dict)
|
||||
|
||||
new_projection_id = pyqtSignal(dict)
|
||||
new_projection_data = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, bec_config=None):
|
||||
super().__init__()
|
||||
self.client = BECClient()
|
||||
|
||||
# TODO: this is a workaround for now to provide service config within qtdesigner, but is
|
||||
# it possible to provide config via a cli arg?
|
||||
if bec_config is None and os.path.isfile("bec_config.yaml"):
|
||||
bec_config = "bec_config.yaml"
|
||||
|
||||
self.client.initialize(config=ServiceConfig(config_path=bec_config))
|
||||
|
||||
self._slot_signal_map = {
|
||||
"on_scan_segment": self.scan_segment,
|
||||
"on_new_scan": self.new_scan,
|
||||
}
|
||||
self._daps = {}
|
||||
self._connections = {}
|
||||
|
||||
self._scan_id = None
|
||||
scan_lock = RLock()
|
||||
|
||||
# self.new_projection_id.connect(self.new_projection_data)
|
||||
|
||||
def _scan_segment_cb(msg):
|
||||
msg = BECMessage.ScanMessage.loads(msg.value)[0]
|
||||
with scan_lock:
|
||||
# TODO: use ScanStatusMessage instead?
|
||||
scan_id = msg.content["scanID"]
|
||||
if self._scan_id != scan_id:
|
||||
self._scan_id = scan_id
|
||||
self.new_scan.emit(msg.content, msg.metadata)
|
||||
self.scan_segment.emit(msg.content, msg.metadata)
|
||||
|
||||
scan_segment_topic = MessageEndpoints.scan_segment()
|
||||
self._scan_segment_thread = self.client.connector.consumer(
|
||||
topics=scan_segment_topic,
|
||||
cb=_scan_segment_cb,
|
||||
)
|
||||
self._scan_segment_thread.start()
|
||||
|
||||
def connect(self, widget):
|
||||
for slot_name, signal in self._slot_signal_map.items():
|
||||
slot = getattr(widget, slot_name, None)
|
||||
if callable(slot):
|
||||
signal.connect(slot)
|
||||
|
||||
def connect_slot(self, slot, topic):
|
||||
# create new connection for topic if it doesn't exist
|
||||
if topic not in self._connections:
|
||||
|
||||
def cb(msg):
|
||||
msg = BECMessage.MessageReader.loads(msg.value)
|
||||
if not isinstance(msg, list):
|
||||
msg = [msg]
|
||||
for msg_i in msg:
|
||||
self._connections[topic].signal.emit(msg_i.content, msg_i.metadata)
|
||||
|
||||
consumer = self.client.connector.consumer(topics=topic, cb=cb)
|
||||
consumer.start()
|
||||
|
||||
self._connections[topic] = _Connection(consumer)
|
||||
|
||||
# connect slot if it's not connected
|
||||
if slot not in self._connections[topic].slots:
|
||||
self._connections[topic].signal.connect(slot)
|
||||
self._connections[topic].slots.add(slot)
|
||||
|
||||
def disconnect_slot(self, slot, topic):
|
||||
if topic not in self._connections:
|
||||
return
|
||||
|
||||
if slot not in self._connections[topic].slots:
|
||||
return
|
||||
|
||||
self._connections[topic].signal.disconnect(slot)
|
||||
self._connections[topic].slots.remove(slot)
|
||||
|
||||
if not self._connections[topic].slots:
|
||||
# shutdown consumer if there are no more connected slots
|
||||
self._connections[topic].consumer.shutdown()
|
||||
del self._connections[topic]
|
||||
|
||||
def connect_dap_slot(self, slot, dap_names):
|
||||
if not isinstance(dap_names, list):
|
||||
dap_names = [dap_names]
|
||||
|
||||
for dap_name in dap_names:
|
||||
if dap_name not in self._daps: # create a new consumer and connect slot
|
||||
self.add_new_dap_connection(slot, dap_name)
|
||||
|
||||
else:
|
||||
# connect slot if it's not yet connected
|
||||
if slot not in self._daps[dap_name].slots:
|
||||
self.new_dap_data.connect(slot)
|
||||
self._daps[dap_name].slots.add(slot)
|
||||
|
||||
def add_new_dap_connection(self, slot, dap_name):
|
||||
def _dap_cb(msg):
|
||||
msg = BECMessage.ProcessedDataMessage.loads(msg.value)
|
||||
if not isinstance(msg, list):
|
||||
msg = [msg]
|
||||
for i in msg:
|
||||
self.new_dap_data.emit(i.content["data"], i.metadata)
|
||||
|
||||
dap_ep = MessageEndpoints.processed_data(dap_name)
|
||||
consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb)
|
||||
consumer.start()
|
||||
|
||||
self.new_dap_data.connect(slot)
|
||||
|
||||
self._daps[dap_name] = _BECDap(consumer)
|
||||
self._daps[dap_name].slots.add(slot)
|
||||
|
||||
def disconnect_dap_slot(self, slot, dap_name):
|
||||
if dap_name not in self._daps:
|
||||
return
|
||||
|
||||
if slot not in self._daps[dap_name].slots:
|
||||
return
|
||||
|
||||
self.new_dap_data.disconnect(slot)
|
||||
self._daps[dap_name].slots.remove(slot)
|
||||
|
||||
if not self._daps[dap_name].slots:
|
||||
# shutdown consumer if there are no more connected slots
|
||||
self._daps[dap_name].consumer.shutdown()
|
||||
del self._daps[dap_name]
|
||||
|
||||
# def connect_proj_data(self, slot):
|
||||
# keys = self.client.producer.keys("px_stream/projection_*")
|
||||
# keys = keys or []
|
||||
#
|
||||
# def _dap_cb(msg):
|
||||
# msg = BECMessage.DeviceMessage.loads(msg.value)
|
||||
# self.new_projection_data.emit(msg.content["data"])
|
||||
#
|
||||
# proj_numbers = set(key.decode().split("px_stream/projection_")[1].split("/")[0] for key in keys)
|
||||
# last_proj_id = sorted(proj_numbers)[-1]
|
||||
# dap_ep = MessageEndpoints.processed_data(f"px_stream/projection_{last_proj_id}/")
|
||||
#
|
||||
# consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb)
|
||||
# consumer.start()
|
||||
#
|
||||
# self.new_projection_data.connect(slot)
|
||||
|
||||
def connect_proj_id(self, slot):
|
||||
def _dap_cb(msg):
|
||||
msg = BECMessage.DeviceMessage.loads(msg.value)
|
||||
self.new_projection_id.emit(msg.content["signals"])
|
||||
|
||||
dap_ep = "px_stream/proj_nr"
|
||||
consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb)
|
||||
consumer.start()
|
||||
|
||||
self.new_projection_id.connect(slot)
|
||||
|
||||
def connect_proj_data(self, slot: object, data_ep: str) -> object:
|
||||
def _dap_cb(msg):
|
||||
msg = BECMessage.DeviceMessage.loads(msg.value)
|
||||
self.new_projection_data.emit(msg.content["signals"])
|
||||
|
||||
consumer = self.client.connector.consumer(topics=data_ep, cb=_dap_cb)
|
||||
consumer.start()
|
||||
self._daps[data_ep] = _BECDap(consumer)
|
||||
self._daps[data_ep].slots.add(slot)
|
||||
|
||||
self.new_projection_data.connect(slot)
|
||||
|
||||
def disconnect_proj_data(self, slot, data_ep):
|
||||
if data_ep not in self._daps:
|
||||
return
|
||||
|
||||
if slot not in self._daps[data_ep].slots:
|
||||
return
|
||||
|
||||
self.new_projection_data.disconnect(slot)
|
||||
self._daps[data_ep].slots.remove(slot)
|
||||
|
||||
if not self._daps[data_ep].slots:
|
||||
# shutdown consumer if there are no more connected slots
|
||||
self._daps[data_ep].consumer.shutdown()
|
||||
del self._daps[data_ep]
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bec-config", default=None)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
bec_dispatcher = _BECDispatcher(args.bec_config)
|
||||
@@ -1,98 +0,0 @@
|
||||
import argparse
|
||||
import os
|
||||
from threading import RLock
|
||||
|
||||
from PyQt5 import uic
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import QApplication, QMainWindow
|
||||
from scan_plot import BECScanPlot
|
||||
|
||||
from bec_lib.core import BECMessage, MessageEndpoints, RedisConnector
|
||||
|
||||
|
||||
class BEC_UI(QMainWindow):
|
||||
new_scan_data = pyqtSignal(dict)
|
||||
new_dap_data = pyqtSignal(dict) # signal per proc instance?
|
||||
new_scan = pyqtSignal()
|
||||
|
||||
def __init__(self, uipath):
|
||||
super().__init__()
|
||||
self._scan_channels = set()
|
||||
self._dap_channels = set()
|
||||
|
||||
self._scan_thread = None
|
||||
self._dap_threads = []
|
||||
|
||||
ui = uic.loadUi(uipath, self)
|
||||
|
||||
_, fname = os.path.split(uipath)
|
||||
self.setWindowTitle(fname)
|
||||
|
||||
for sp in ui.findChildren(BECScanPlot):
|
||||
for chan in (sp.x_channel, *sp.y_channel_list):
|
||||
if chan.startswith("dap."):
|
||||
chan = chan.partition("dap.")[-1]
|
||||
self._dap_channels.add(chan)
|
||||
else:
|
||||
self._scan_channels.add(chan)
|
||||
|
||||
sp.initialize() # TODO: move this elsewhere?
|
||||
|
||||
self.new_scan_data.connect(sp.redraw_scan) # TODO: merge
|
||||
self.new_dap_data.connect(sp.redraw_dap)
|
||||
self.new_scan.connect(sp.clearData)
|
||||
|
||||
# Scan setup
|
||||
self._scan_id = None
|
||||
scan_lock = RLock()
|
||||
|
||||
def _scan_cb(msg):
|
||||
msg = BECMessage.ScanMessage.loads(msg.value)
|
||||
with scan_lock:
|
||||
scan_id = msg[0].content["scanID"]
|
||||
if self._scan_id != scan_id:
|
||||
self._scan_id = scan_id
|
||||
self.new_scan.emit()
|
||||
self.new_scan_data.emit(msg[0].content["data"])
|
||||
|
||||
bec_connector = RedisConnector("localhost:6379")
|
||||
|
||||
if self._scan_channels:
|
||||
scan_readback = MessageEndpoints.scan_segment()
|
||||
self._scan_thread = bec_connector.consumer(
|
||||
topics=scan_readback,
|
||||
cb=_scan_cb,
|
||||
)
|
||||
self._scan_thread.start()
|
||||
|
||||
# DAP setup
|
||||
def _proc_cb(msg):
|
||||
msg = BECMessage.ProcessedDataMessage.loads(msg.value)
|
||||
self.new_dap_data.emit(msg.content["data"])
|
||||
|
||||
if self._dap_channels:
|
||||
for chan in self._dap_channels:
|
||||
proc_ep = MessageEndpoints.processed_data(chan)
|
||||
dap_thread = bec_connector.consumer(topics=proc_ep, cb=_proc_cb)
|
||||
dap_thread.start()
|
||||
self._dap_threads.append(dap_thread)
|
||||
|
||||
self.show()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="bec-pyqt", formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument("uipath", type=str, help="Path to a BEC ui file")
|
||||
|
||||
args, rem = parser.parse_known_args()
|
||||
|
||||
app = QApplication(rem)
|
||||
BEC_UI(args.uipath)
|
||||
app.exec_()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -37,6 +37,8 @@ class ConfigPlotter(pg.GraphicsWidget):
|
||||
def _init_ui(self):
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
|
||||
# pylint: disable=no-member
|
||||
self.pen = mkPen(color=(56, 76, 107), width=4, style=QtCore.Qt.SolidLine)
|
||||
|
||||
self.view = pg.GraphicsView()
|
||||
|
||||
38
bec_widgets/ctrl_c.py
Normal file
38
bec_widgets/ctrl_c.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import signal
|
||||
import socket
|
||||
from PyQt5.QtNetwork import QAbstractSocket
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.signalwatchdog = SignalWatchdog() # need to store to keep socket pair alive
|
||||
signal.signal(signal.SIGINT, make_quit_handler(app))
|
||||
|
||||
|
||||
def make_quit_handler(app):
|
||||
def handler(*args):
|
||||
print() # make ^C appear on its own line
|
||||
app.quit()
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
class SignalWatchdog(QAbstractSocket):
|
||||
def __init__(self):
|
||||
"""
|
||||
Propagates system signals from Python to QEventLoop
|
||||
adapted from https://stackoverflow.com/a/65802260/655404
|
||||
"""
|
||||
super().__init__(QAbstractSocket.SctpSocket, None)
|
||||
|
||||
self.writer, self.reader = writer, reader = socket.socketpair()
|
||||
writer.setblocking(False)
|
||||
|
||||
fd_writer = writer.fileno()
|
||||
fd_reader = reader.fileno()
|
||||
|
||||
signal.set_wakeup_fd(fd_writer) # Python hook
|
||||
self.setSocketDescriptor(fd_reader) # Qt hook
|
||||
|
||||
self.readyRead.connect(
|
||||
lambda: None
|
||||
) # dummy function call that lets the Python interpreter run
|
||||
33
bec_widgets/display_ui_file.py
Normal file
33
bec_widgets/display_ui_file.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt5 import QtWidgets, uic
|
||||
|
||||
|
||||
class UI(QtWidgets.QWidget):
|
||||
def __init__(self, uipath):
|
||||
super().__init__()
|
||||
|
||||
self.ui = uic.loadUi(uipath, self)
|
||||
|
||||
_, fname = os.path.split(uipath)
|
||||
self.setWindowTitle(fname)
|
||||
|
||||
self.show()
|
||||
|
||||
|
||||
def main():
|
||||
"""A basic script to display UI file
|
||||
|
||||
Run the script, passing UI file path as an argument, e.g.
|
||||
$ python bec_widgets/display_ui_file.py bec_widgets/line_plot.ui
|
||||
"""
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
UI(sys.argv[1])
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
77
bec_widgets/examples/extreme/config_example.yaml
Normal file
77
bec_widgets/examples/extreme/config_example.yaml
Normal file
@@ -0,0 +1,77 @@
|
||||
plot_settings:
|
||||
background_color: "black"
|
||||
num_columns: 3
|
||||
colormap: "plasma"
|
||||
#TODO add more settings
|
||||
# - plot size
|
||||
|
||||
plot_data:
|
||||
- plot_name: "BPM plot"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
# entry: "samx"
|
||||
y:
|
||||
label: 'BPM'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
- name: "gauss_adc1"
|
||||
entry: "gauss_adc1"
|
||||
- name: "gauss_adc2"
|
||||
entry: "gauss_adc2"
|
||||
|
||||
- plot_name: "BPM plot 2"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
entry: "samx"
|
||||
y:
|
||||
label: 'BPM'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
- name: "gauss_adc1"
|
||||
entry: "gauss_adc1"
|
||||
- plot_name: "BPM plot 3"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
entry: "samx"
|
||||
y:
|
||||
label: 'BPM'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
- name: "gauss_adc1"
|
||||
entry: "gauss_adc1"
|
||||
- plot_name: "ADC plot"
|
||||
x:
|
||||
label: 'Motor Y'
|
||||
signals:
|
||||
- name: "samy"
|
||||
# entry: "samy" # here I also forgot to specify entry
|
||||
y:
|
||||
label: 'ADC'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
- name: "samx"
|
||||
# I will not specify entry, because I want to take hint from gauss_adc2
|
||||
- plot_name: "Multi"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
entry: "samx"
|
||||
y:
|
||||
label: 'Multi'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
- name: "samx"
|
||||
entry: ["samx", "samx_setpoint"]
|
||||
# entry: ["samx","incorect"] #multiple entries for one device
|
||||
516
bec_widgets/examples/extreme/extreme.py
Normal file
516
bec_widgets/examples/extreme/extreme.py
Normal file
@@ -0,0 +1,516 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import QApplication, QWidget, QTableWidgetItem, QTableWidget, QFileDialog
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, uic
|
||||
|
||||
from bec_lib.core import MessageEndpoints
|
||||
from bec_widgets.qt_utils import Crosshair, Colors
|
||||
from pyqtgraph.Qt import QtWidgets
|
||||
from pyqtgraph import ColorButton
|
||||
|
||||
|
||||
# TODO implement:
|
||||
# - implement scanID database for visualizing previous scans
|
||||
|
||||
|
||||
class PlotApp(QWidget):
|
||||
"""
|
||||
Main class for PlotApp, designed to plot multiple signals in a grid layout
|
||||
based on a flexible YAML configuration.
|
||||
|
||||
Attributes:
|
||||
update_signal (pyqtSignal): Signal to trigger plot updates.
|
||||
plot_data (list of dict): List of dictionaries containing plot configurations.
|
||||
Each dictionary specifies x and y signals, including their
|
||||
name and entry, for a particular plot.
|
||||
|
||||
Args:
|
||||
plot_settings (dict): Dictionary containing global plot settings such as background color.
|
||||
plot_data (list of dict): List of dictionaries specifying the signals to plot.
|
||||
Each dictionary should contain:
|
||||
- 'x': Dictionary specifying the x-axis settings including
|
||||
a 'signals' list with 'name' and 'entry' fields.
|
||||
If there are multiple entries for one device name, they can be passed as a list.
|
||||
- 'y': Similar to 'x', but for the y-axis.
|
||||
Example:
|
||||
[
|
||||
{
|
||||
'plot_name': 'Plot 1',
|
||||
'x': {'label': 'X Label', 'signals': [{'name': 'x1', 'entry': 'x1_entry'}]},
|
||||
'y': {'label': 'Y Label', 'signals': [{'name': 'y1', 'entry': 'y1_entry'}]}
|
||||
},
|
||||
...
|
||||
]
|
||||
parent (QWidget, optional): Parent widget.
|
||||
"""
|
||||
|
||||
update_signal = pyqtSignal()
|
||||
update_dap_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, plot_settings: dict, plot_data: list, parent=None):
|
||||
super(PlotApp, self).__init__(parent)
|
||||
|
||||
# YAML config
|
||||
self.plot_settings = plot_settings
|
||||
self.plot_data = plot_data
|
||||
|
||||
# Setting global plot settings
|
||||
self.init_plot_background(self.plot_settings["background_color"])
|
||||
|
||||
# Loading UI
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "extreme.ui"), self)
|
||||
|
||||
# Nested dictionary to hold x and y data for multiple plots
|
||||
self.data = {}
|
||||
|
||||
self.crosshairs = None
|
||||
self.plots = None
|
||||
self.curves_data = None
|
||||
self.grid_coordinates = None
|
||||
self.scanID = None
|
||||
|
||||
self.user_colors = {} # key: (plot_name, y_name, y_entry), value: color
|
||||
|
||||
# Initialize the UI
|
||||
self.init_ui(self.plot_settings["num_columns"])
|
||||
self.spinBox_N_columns.setValue(
|
||||
self.plot_settings["num_columns"]
|
||||
) # TODO has to be checked if it will not setup more columns than plots
|
||||
self.spinBox_N_columns.setMaximum(len(self.plot_data))
|
||||
self.splitter.setSizes([400, 100])
|
||||
|
||||
# Buttons
|
||||
self.pushButton_save.clicked.connect(self.save_settings_to_yaml)
|
||||
self.pushButton_load.clicked.connect(self.load_settings_from_yaml)
|
||||
|
||||
# Connect the update signal to the update plot method
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self.update_plot
|
||||
)
|
||||
|
||||
# Change layout of plots when the number of columns is changed in GUI
|
||||
self.spinBox_N_columns.valueChanged.connect(lambda x: self.init_ui(x))
|
||||
|
||||
def init_plot_background(self, background_color: str) -> None:
|
||||
"""
|
||||
Initialize plot settings based on the background color.
|
||||
|
||||
Args:
|
||||
background_color (str): The background color ('white' or 'black').
|
||||
|
||||
This method sets the background and foreground colors for pyqtgraph.
|
||||
If the background is dark ('black'), the foreground will be set to 'white',
|
||||
and vice versa.
|
||||
"""
|
||||
if background_color.lower() == "black":
|
||||
pg.setConfigOption("background", "k")
|
||||
pg.setConfigOption("foreground", "w")
|
||||
elif background_color.lower() == "white":
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
else:
|
||||
print(f"Warning: Unknown background color {background_color}. Using default settings.")
|
||||
|
||||
def init_ui(self, num_columns: int = 3) -> None:
|
||||
"""
|
||||
Initialize the UI components, create plots and store their grid positions.
|
||||
|
||||
Args:
|
||||
num_columns (int): Number of columns to wrap the layout.
|
||||
|
||||
This method initializes a dictionary `self.plots` to store the plot objects
|
||||
along with their corresponding x and y signal names. It dynamically arranges
|
||||
the plots in a grid layout based on the given number of columns and dynamically
|
||||
stretches the last plots to fit the remaining space.
|
||||
"""
|
||||
self.glw.clear()
|
||||
self.plots = {}
|
||||
self.grid_coordinates = []
|
||||
|
||||
num_plots = len(self.plot_data)
|
||||
|
||||
# Check if num_columns exceeds the number of plots
|
||||
if num_columns > num_plots:
|
||||
num_columns = num_plots
|
||||
self.plot_settings["num_columns"] = num_columns # Update the settings
|
||||
print(
|
||||
f"Warning: num_columns in the YAML file was greater than the number of plots. Resetting num_columns to {num_columns}."
|
||||
)
|
||||
|
||||
num_rows = num_plots // num_columns
|
||||
last_row_cols = num_plots % num_columns
|
||||
remaining_space = num_columns - last_row_cols
|
||||
|
||||
for i, plot_config in enumerate(self.plot_data):
|
||||
row, col = i // num_columns, i % num_columns
|
||||
colspan = 1
|
||||
|
||||
if row == num_rows and remaining_space > 0:
|
||||
if last_row_cols == 1:
|
||||
colspan = num_columns
|
||||
else:
|
||||
colspan = remaining_space // last_row_cols + 1
|
||||
remaining_space -= colspan - 1
|
||||
last_row_cols -= 1
|
||||
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
x_label = plot_config["x"].get("label", "")
|
||||
y_label = plot_config["y"].get("label", "")
|
||||
|
||||
plot = self.glw.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
|
||||
plot.setLabel("bottom", x_label)
|
||||
plot.setLabel("left", y_label)
|
||||
plot.addLegend()
|
||||
|
||||
self.plots[plot_name] = plot
|
||||
self.grid_coordinates.append((row, col))
|
||||
|
||||
self.init_curves()
|
||||
|
||||
def init_curves(self) -> None:
|
||||
"""
|
||||
Initialize curve data and properties, and update table row labels.
|
||||
|
||||
This method initializes a nested dictionary `self.curves_data` to store
|
||||
the curve objects for each x and y signal pair. It also updates the row labels
|
||||
in `self.tableWidget_crosshair` to include the grid position for each y-value.
|
||||
"""
|
||||
self.curves_data = {}
|
||||
row_labels = []
|
||||
|
||||
for idx, plot_config in enumerate(self.plot_data):
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
plot = self.plots[plot_name]
|
||||
plot.clear()
|
||||
|
||||
y_configs = plot_config["y"]["signals"]
|
||||
colors_ys = Colors.golden_angle_color(
|
||||
colormap=self.plot_settings["colormap"], num=len(y_configs)
|
||||
)
|
||||
|
||||
curve_list = []
|
||||
for i, (y_config, color) in enumerate(zip(y_configs, colors_ys)):
|
||||
y_name = y_config["name"]
|
||||
y_entries = y_config.get("entry", [y_name])
|
||||
|
||||
if not isinstance(y_entries, list):
|
||||
y_entries = [y_entries]
|
||||
|
||||
for y_entry in y_entries:
|
||||
user_color = self.user_colors.get((plot_name, y_name, y_entry), None)
|
||||
color_to_use = user_color if user_color else color
|
||||
|
||||
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
|
||||
brush_curve = mkBrush(color=color_to_use)
|
||||
|
||||
curve_data = pg.PlotDataItem(
|
||||
symbolSize=5,
|
||||
symbolBrush=brush_curve,
|
||||
pen=pen_curve,
|
||||
skipFiniteCheck=True,
|
||||
name=f"{y_name} ({y_entry})",
|
||||
)
|
||||
|
||||
curve_list.append((y_name, y_entry, curve_data))
|
||||
plot.addItem(curve_data)
|
||||
row_labels.append(f"{y_name} ({y_entry}) - {plot_name}")
|
||||
|
||||
# Create a ColorButton and set its color
|
||||
color_btn = ColorButton()
|
||||
color_btn.setColor(color_to_use)
|
||||
color_btn.sigColorChanged.connect(
|
||||
lambda btn=color_btn, plot=plot_name, yname=y_name, yentry=y_entry, curve=curve_data: self.change_curve_color(
|
||||
btn, plot, yname, yentry, curve
|
||||
)
|
||||
)
|
||||
|
||||
# Add the ColorButton as a QWidget to the table
|
||||
color_widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QHBoxLayout()
|
||||
layout.addWidget(color_btn)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
color_widget.setLayout(layout)
|
||||
|
||||
row = len(row_labels) - 1 # The row index in the table
|
||||
self.tableWidget_crosshair.setCellWidget(row, 2, color_widget)
|
||||
|
||||
self.curves_data[plot_name] = curve_list
|
||||
|
||||
self.tableWidget_crosshair.setRowCount(len(row_labels))
|
||||
self.tableWidget_crosshair.setVerticalHeaderLabels(row_labels)
|
||||
self.hook_crosshair()
|
||||
|
||||
# def change_curve_color(self, btn, curve):
|
||||
# """Change the color of a curve."""
|
||||
# color = btn.color()
|
||||
# pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
|
||||
# brush_curve = mkBrush(color=color)
|
||||
# curve.setPen(pen_curve)
|
||||
# curve.setSymbolBrush(brush_curve)
|
||||
|
||||
def change_curve_color(self, btn, plot_name, y_name, y_entry, curve):
|
||||
"""
|
||||
Change the color of a curve and update the corresponding ColorButton.
|
||||
|
||||
Args:
|
||||
btn (ColorButton): The ColorButton that was clicked.
|
||||
plot_name (str): The name of the plot where the curve belongs.
|
||||
y_name (str): The name of the y signal.
|
||||
y_entry (str): The entry of the y signal.
|
||||
curve (PlotDataItem): The curve to be changed.
|
||||
"""
|
||||
color = btn.color()
|
||||
pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
|
||||
brush_curve = mkBrush(color=color)
|
||||
curve.setPen(pen_curve)
|
||||
curve.setSymbolBrush(brush_curve)
|
||||
self.user_colors[(plot_name, y_name, y_entry)] = color
|
||||
|
||||
def hook_crosshair(self):
|
||||
"""Attach crosshairs to each plot and connect them to the update_table method."""
|
||||
self.crosshairs = {}
|
||||
for plot_name, plot in self.plots.items():
|
||||
crosshair = Crosshair(plot, precision=3)
|
||||
crosshair.coordinatesChanged1D.connect(
|
||||
lambda x, y, plot=plot: self.update_table(
|
||||
self.tableWidget_crosshair, x, y, column=0, plot=plot
|
||||
)
|
||||
)
|
||||
crosshair.coordinatesClicked1D.connect(
|
||||
lambda x, y, plot=plot: self.update_table(
|
||||
self.tableWidget_crosshair, x, y, column=1, plot=plot
|
||||
)
|
||||
)
|
||||
self.crosshairs[plot_name] = crosshair
|
||||
|
||||
def update_table(
|
||||
self, table_widget: QTableWidget, x: float, y_values: list, column: int, plot: pg.PlotItem
|
||||
) -> None:
|
||||
"""
|
||||
Update the table with coordinates based on cursor movements and clicks.
|
||||
|
||||
Args:
|
||||
table_widget (QTableWidget): The table to be updated.
|
||||
x (float): The x-coordinate from the plot.
|
||||
y_values (list): The y-coordinates from the plot.
|
||||
column (int): The column in the table to be updated.
|
||||
plot (PlotItem): The plot from which the coordinates are taken.
|
||||
|
||||
This method calculates the correct row in the table for each y-value
|
||||
and updates the cell at (row, column) with the new x and y coordinates.
|
||||
"""
|
||||
plot_name = [name for name, value in self.plots.items() if value == plot][0]
|
||||
|
||||
starting_row = 0
|
||||
for plot_config in self.plot_data:
|
||||
if plot_config.get("plot_name", "") == plot_name:
|
||||
break
|
||||
for y_config in plot_config.get("y", {}).get("signals", []):
|
||||
y_entries = y_config.get("entry", [y_config.get("name", "")])
|
||||
if not isinstance(y_entries, list):
|
||||
y_entries = [y_entries]
|
||||
starting_row += len(y_entries)
|
||||
|
||||
for i, y in enumerate(y_values):
|
||||
row = starting_row + i
|
||||
table_widget.setItem(row, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_plot(self) -> None:
|
||||
"""Update the plot data based on the stored data dictionary."""
|
||||
for plot_name, curve_list in self.curves_data.items():
|
||||
for y_name, y_entry, curve in curve_list:
|
||||
x_config = next(
|
||||
(pc["x"] for pc in self.plot_data if pc.get("plot_name") == plot_name), {}
|
||||
)
|
||||
x_signal_config = x_config["signals"][0]
|
||||
x_name = x_signal_config.get("name", "")
|
||||
x_entry = x_signal_config.get("entry", x_name)
|
||||
|
||||
key = (x_name, x_entry, y_name, y_entry)
|
||||
data_x = self.data.get(key, {}).get("x", [])
|
||||
data_y = self.data.get(key, {}).get("y", [])
|
||||
|
||||
curve.setData(data_x, data_y)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, msg, metadata) -> None:
|
||||
"""
|
||||
Handle new scan segments and saves data to a dictionary.
|
||||
|
||||
Args:
|
||||
msg (dict): Message received with scan data.
|
||||
metadata (dict): Metadata of the scan.
|
||||
"""
|
||||
|
||||
current_scanID = msg.get("scanID", None)
|
||||
if current_scanID is None:
|
||||
return
|
||||
|
||||
if current_scanID != self.scanID:
|
||||
self.scanID = current_scanID
|
||||
self.data = {}
|
||||
self.init_curves()
|
||||
|
||||
for plot_config in self.plot_data:
|
||||
plot_name = plot_config.get("plot_name", "Unnamed Plot")
|
||||
x_config = plot_config["x"]
|
||||
x_signal_config = x_config["signals"][0] # Assuming there's at least one signal for x
|
||||
|
||||
x_name = x_signal_config.get("name", "")
|
||||
if not x_name:
|
||||
raise ValueError(f"Name for x signal must be specified in plot: {plot_name}.")
|
||||
|
||||
x_entry_list = x_signal_config.get("entry", [])
|
||||
if not x_entry_list:
|
||||
x_entry_list = dev[x_name]._hints if hasattr(dev[x_name], "_hints") else [x_name]
|
||||
|
||||
if not isinstance(x_entry_list, list):
|
||||
x_entry_list = [x_entry_list]
|
||||
|
||||
y_configs = plot_config["y"]["signals"]
|
||||
|
||||
for x_entry in x_entry_list:
|
||||
for y_config in y_configs:
|
||||
y_name = y_config.get("name", "")
|
||||
if not y_name:
|
||||
raise ValueError(
|
||||
f"Name for y signal must be specified in plot: {plot_name}."
|
||||
)
|
||||
|
||||
y_entry_list = y_config.get("entry", [])
|
||||
if not y_entry_list:
|
||||
y_entry_list = (
|
||||
dev[y_name]._hints if hasattr(dev[y_name], "_hints") else [y_name]
|
||||
)
|
||||
|
||||
if not isinstance(y_entry_list, list):
|
||||
y_entry_list = [y_entry_list]
|
||||
|
||||
for y_entry in y_entry_list:
|
||||
key = (x_name, x_entry, y_name, y_entry)
|
||||
|
||||
data_x = msg["data"].get(x_name, {}).get(x_entry, {}).get("value", None)
|
||||
data_y = msg["data"].get(y_name, {}).get(y_entry, {}).get("value", None)
|
||||
|
||||
if data_x is None:
|
||||
raise ValueError(
|
||||
f"Incorrect entry '{x_entry}' specified for x in plot: {plot_name}, x name: {x_name}"
|
||||
)
|
||||
|
||||
if data_y is None:
|
||||
if hasattr(dev[y_name], "_hints"):
|
||||
raise ValueError(
|
||||
f"Incorrect entry '{y_entry}' specified for y in plot: {plot_name}, y name: {y_name}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"No hints available for y in plot: {plot_name}, and name '{y_name}' did not work as entry"
|
||||
)
|
||||
|
||||
if data_x is not None:
|
||||
self.data.setdefault(key, {}).setdefault("x", []).append(data_x)
|
||||
|
||||
if data_y is not None:
|
||||
self.data.setdefault(key, {}).setdefault("y", []).append(data_y)
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def save_settings_to_yaml(self):
|
||||
"""Save the current settings to a .yaml file using a file dialog."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
if not file_path.endswith(".yaml"):
|
||||
file_path += ".yaml"
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
yaml.dump(
|
||||
{"plot_settings": self.plot_settings, "plot_data": self.plot_data}, file
|
||||
)
|
||||
print(f"Settings saved to {file_path}")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while saving the settings to {file_path}: {e}")
|
||||
|
||||
def load_settings_from_yaml(self):
|
||||
"""Load settings from a .yaml file using a file dialog and update the current settings."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Load Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
self.plot_settings = config.get("plot_settings", {})
|
||||
self.plot_data = config.get("plot_data", {})
|
||||
# Reinitialize the UI and plots
|
||||
# TODO implement, change background works only before loading .ui file
|
||||
# self.init_plot_background(self.plot_settings["background_color"])
|
||||
self.init_ui(self.plot_settings["num_columns"])
|
||||
self.init_curves()
|
||||
print(f"Settings loaded from {file_path}")
|
||||
except FileNotFoundError:
|
||||
print(f"The file {file_path} was not found.")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while loading the settings from {file_path}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import yaml
|
||||
import argparse
|
||||
|
||||
from bec_widgets import ctrl_c
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
parser = argparse.ArgumentParser(description="Plotting App")
|
||||
parser.add_argument(
|
||||
"--config", "-c", help="Path to the .yaml configuration file", default="config_example.yaml"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
with open(args.config, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
plot_settings = config.get("plot_settings", {})
|
||||
plot_data = config.get("plot_data", {})
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"The file {args.config} was not found.")
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
print(f"An error occurred while loading the config file: {e}")
|
||||
exit(1)
|
||||
|
||||
# BECclient global variables
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
|
||||
app = QApplication([])
|
||||
plotApp = PlotApp(plot_settings=plot_settings, plot_data=plot_data)
|
||||
|
||||
# Connecting signals from bec_dispatcher
|
||||
bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
ctrl_c.setup(app)
|
||||
|
||||
window = plotApp
|
||||
window.show()
|
||||
app.exec_()
|
||||
115
bec_widgets/examples/extreme/extreme.ui
Normal file
115
bec_widgets/examples/extreme/extreme.ui
Normal file
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MultiWindow</class>
|
||||
<widget class="QWidget" name="MultiWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1248</width>
|
||||
<height>564</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MultiWindow</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="GraphicsLayoutWidget" name="glw"/>
|
||||
<widget class="QWidget" name="">
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="1" column="0">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="0" colspan="3">
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Cursor</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QTableWidget" name="tableWidget_crosshair">
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Moved</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Clicked</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Color</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Number of Columns:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_N_columns">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>3</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="pushButton_load">
|
||||
<property name="text">
|
||||
<string>Load Config</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="pushButton_save">
|
||||
<property name="text">
|
||||
<string>Save Config</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
768
bec_widgets/examples/motor_movement/motor_controller.ui
Normal file
768
bec_widgets/examples/motor_movement/motor_controller.ui
Normal file
@@ -0,0 +1,768 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1129</width>
|
||||
<height>550</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Controller</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout" stretch="8,5,8">
|
||||
<item>
|
||||
<widget class="GraphicsLayoutWidget" name="glw">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QFrame" name="Controls">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>221</width>
|
||||
<height>471</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorSelection">
|
||||
<property name="title">
|
||||
<string>Motor Selection</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="text">
|
||||
<string>Motor Y</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_x"/>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_y"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Motor X</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="2">
|
||||
<widget class="QPushButton" name="pushButton_connecMotors">
|
||||
<property name="text">
|
||||
<string>Connect Motors</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>18</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl">
|
||||
<property name="title">
|
||||
<string>Motor Relative</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_enableArrows">
|
||||
<property name="text">
|
||||
<string>Move with arrow keys</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_up">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::UpArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_down">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::DownArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QToolButton" name="toolButton_left">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::LeftArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QToolButton" name="toolButton_right">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::RightArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step">
|
||||
<property name="minimum">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>13</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl_absolute">
|
||||
<property name="title">
|
||||
<string>Move Absolute</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPushButton" name="pushButton_go_absolute">
|
||||
<property name="text">
|
||||
<string>Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_stop">
|
||||
<property name="text">
|
||||
<string>Stop Movement</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget_tables">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab_coordinates">
|
||||
<attribute name="title">
|
||||
<string>Coordinates</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QTableWidget" name="tableWidget_coordinates">
|
||||
<property name="selectionMode">
|
||||
<enum>QAbstractItemView::MultiSelection</enum>
|
||||
</property>
|
||||
<property name="selectionBehavior">
|
||||
<enum>QAbstractItemView::SelectRows</enum>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Tag</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Show</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Move</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_settings">
|
||||
<attribute name="title">
|
||||
<string>Settings</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorLimits">
|
||||
<property name="title">
|
||||
<string>Motor Limits</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="3" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_y_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>-1000</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>-100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="pushButton_updateLimits">
|
||||
<property name="text">
|
||||
<string>Update</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_x_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>-1000</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_Y_max">
|
||||
<property name="text">
|
||||
<string>+ Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_y_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>-1000</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QSpinBox" name="spinBox_x_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>-1000</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>-100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QLabel" name="label_Y_min">
|
||||
<property name="text">
|
||||
<string>- Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_X_min">
|
||||
<property name="text">
|
||||
<string>- X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_X_max">
|
||||
<property name="text">
|
||||
<string>+ X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Motor Config</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<item row="3" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_3">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_6">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_9">
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Speed</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_speed_x">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_update_frequency_x">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>500</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_speed_y">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLabel" name="label_10">
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="label_15">
|
||||
<property name="text">
|
||||
<string>Max Points</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_num_dim_points">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Update Frequency</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox_2">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Tolerance</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_update_frequency_y">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>500</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="pushButton_update_config">
|
||||
<property name="text">
|
||||
<string>Update</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Decimal Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="0">
|
||||
<widget class="QLabel" name="label_16">
|
||||
<property name="text">
|
||||
<string>N dim</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_max_points">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>100</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>10000</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>100</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>5000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="0">
|
||||
<widget class="QLabel" name="label_11">
|
||||
<property name="text">
|
||||
<string>Scatter Size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_scatter_size">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>15</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>5</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_queue">
|
||||
<attribute name="title">
|
||||
<string>Queue</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_5">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Reset Queue</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="tableWidget_2">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>queueID</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>scanID</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>is_scan</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>type</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>scan_number</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>IQ status</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
673
bec_widgets/examples/motor_movement/motor_example.py
Normal file
673
bec_widgets/examples/motor_movement/motor_example.py
Normal file
@@ -0,0 +1,673 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from enum import Enum
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtGui
|
||||
from PyQt5.QtCore import QThread, pyqtSlot
|
||||
from PyQt5.QtCore import pyqtSignal, Qt
|
||||
from PyQt5.QtWidgets import QApplication, QWidget
|
||||
from pyqtgraph.Qt import QtWidgets, uic
|
||||
|
||||
from PyQt5.QtGui import QKeySequence
|
||||
from PyQt5.QtWidgets import QShortcut
|
||||
|
||||
from bec_lib.core import MessageEndpoints, BECMessage
|
||||
|
||||
|
||||
# TODO - General features
|
||||
# - setting motor acceleration
|
||||
# - updating motor precision
|
||||
# - put motor status (moving, stopped, etc)
|
||||
# - add spinBox for motor scatter size
|
||||
# - add mouse interactions with the plot -> click to select coordinates, double click to move?
|
||||
# - adjust right click actions
|
||||
# - implement logic to check if motor actually has limits
|
||||
|
||||
|
||||
class MotorApp(QWidget):
|
||||
coordinates_updated = pyqtSignal(float, float)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "motor_controller.ui"), self)
|
||||
|
||||
# Motor Control Thread
|
||||
self.motor_thread = MotorControl()
|
||||
|
||||
self.motor_x, self.motor_y = None, None
|
||||
self.limit_x, self.limit_y = None, None
|
||||
|
||||
# Coordinates tracking
|
||||
self.motor_positions = np.array([])
|
||||
self.max_points = 5000 # Maximum number of points to keep
|
||||
self.num_dim_points = 15 # Number of points to dim gradually
|
||||
self.scatter_size = 5
|
||||
|
||||
# QThread for motor movement + signals
|
||||
self.motor_thread.motors_loaded.connect(self.get_available_motors)
|
||||
self.motor_thread.motors_selected.connect(self.get_selected_motors)
|
||||
self.motor_thread.limits_retrieved.connect(self.update_limits)
|
||||
self.motor_thread.speed_retrieved.connect(self.update_speed)
|
||||
self.motor_thread.update_frequency_retrieved.connect(self.update_update_frequency)
|
||||
|
||||
# UI
|
||||
self.init_ui()
|
||||
self.tag_N = 1 # position label for saved coordinates
|
||||
|
||||
# Get all motors available
|
||||
self.motor_thread.retrieve_all_motors()
|
||||
|
||||
def connect_motor(self, motor_x_name: str, motor_y_name: str):
|
||||
self.motor_thread.connect_motors(motor_x_name, motor_y_name)
|
||||
self.motor_thread.retrieve_motor_limits(self.motor_x, self.motor_y)
|
||||
self.motor_thread.retrieve_motor_speed(self.motor_x, self.motor_y)
|
||||
self.motor_thread.retrieve_motor_update_frequency(self.motor_x, self.motor_y)
|
||||
self.init_motor_map()
|
||||
|
||||
self.motorControl.setEnabled(True)
|
||||
self.motorControl_absolute.setEnabled(True)
|
||||
self.tabWidget_tables.setTabEnabled(1, True)
|
||||
|
||||
self.generate_table_coordinate(
|
||||
self.tableWidget_coordinates,
|
||||
self.motor_thread.retrieve_coordinates(),
|
||||
tag=f"{motor_x_name},{motor_y_name}",
|
||||
precision=0,
|
||||
)
|
||||
|
||||
@pyqtSlot(object, object)
|
||||
def get_selected_motors(self, motor_x, motor_y):
|
||||
self.motor_x, self.motor_y = motor_x, motor_y
|
||||
|
||||
@pyqtSlot(list, list)
|
||||
def get_available_motors(self, motors_x, motors_y):
|
||||
self.comboBox_motor_x.addItems(motors_x)
|
||||
self.comboBox_motor_y.addItems(motors_y)
|
||||
|
||||
@pyqtSlot(list, list)
|
||||
def update_limits(self, x_limits: list, y_limits: list) -> None:
|
||||
self.limit_x = x_limits
|
||||
self.limit_y = y_limits
|
||||
self.spinBox_x_min.setValue(self.limit_x[0])
|
||||
self.spinBox_x_max.setValue(self.limit_x[1])
|
||||
self.spinBox_y_min.setValue(self.limit_y[0])
|
||||
self.spinBox_y_max.setValue(self.limit_y[1])
|
||||
|
||||
for spinBox in (
|
||||
self.spinBox_x_min,
|
||||
self.spinBox_x_max,
|
||||
self.spinBox_y_min,
|
||||
self.spinBox_y_max,
|
||||
):
|
||||
spinBox.setStyleSheet("")
|
||||
|
||||
# TODO - names can be get from MotorController
|
||||
self.label_Y_max.setText(f"+ ({self.motor_y.name})")
|
||||
self.label_Y_min.setText(f"- ({self.motor_y.name})")
|
||||
self.label_X_max.setText(f"+ ({self.motor_x.name})")
|
||||
self.label_X_min.setText(f"- ({self.motor_x.name})")
|
||||
|
||||
self.init_motor_map() # reinitialize the map with the new limits
|
||||
|
||||
@pyqtSlot(int, int)
|
||||
def update_speed(self, speed_x, speed_y):
|
||||
self.spinBox_speed_x.setValue(speed_x)
|
||||
self.spinBox_speed_y.setValue(speed_y)
|
||||
for spinBox in (self.spinBox_speed_x, self.spinBox_speed_y):
|
||||
spinBox.setStyleSheet("")
|
||||
|
||||
@pyqtSlot(int, int)
|
||||
def update_update_frequency(self, update_frequency_x, update_frequency_y):
|
||||
self.spinBox_update_frequency_x.setValue(update_frequency_x)
|
||||
self.spinBox_update_frequency_y.setValue(update_frequency_y)
|
||||
for spinBox in (self.spinBox_update_frequency_x, self.spinBox_update_frequency_y):
|
||||
spinBox.setStyleSheet("")
|
||||
|
||||
@pyqtSlot()
|
||||
def enable_motor_control(self):
|
||||
self.motorControl.setEnabled(True)
|
||||
|
||||
def enable_motor_controls(self, disable: bool) -> None:
|
||||
self.motorControl.setEnabled(disable)
|
||||
self.motorSelection.setEnabled(disable)
|
||||
|
||||
# Disable or enable all controls within the motorControl_absolute group box
|
||||
for widget in self.motorControl_absolute.findChildren(QtWidgets.QWidget):
|
||||
widget.setEnabled(disable)
|
||||
|
||||
# Enable the pushButton_stop if the motor is moving
|
||||
self.pushButton_stop.setEnabled(not disable)
|
||||
|
||||
def move_motor_absolute(self, x: float, y: float) -> None:
|
||||
self.enable_motor_controls(False)
|
||||
target_coordinates = (x, y)
|
||||
self.motor_thread.move_to_coordinates(target_coordinates)
|
||||
|
||||
def move_motor_relative(self, motor, value: float) -> None:
|
||||
self.enable_motor_controls(False)
|
||||
self.motor_thread.move_relative(motor, value)
|
||||
|
||||
def update_plot_setting(self, max_points, num_dim_points, scatter_size):
|
||||
self.max_points = max_points
|
||||
self.num_dim_points = num_dim_points
|
||||
self.scatter_size = scatter_size
|
||||
|
||||
for spinBox in (
|
||||
self.spinBox_max_points,
|
||||
self.spinBox_num_dim_points,
|
||||
self.spinBox_scatter_size,
|
||||
):
|
||||
spinBox.setStyleSheet("")
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Setup all ui elements"""
|
||||
# TODO can be separated to multiple functions
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
|
||||
self.label_coorditanes = self.glw.addLabel(f"Motor position: (X, Y)", row=0, col=0)
|
||||
self.plot_map = self.glw.addPlot(row=1, col=0)
|
||||
self.limit_map = pg.ImageItem()
|
||||
self.plot_map.addItem(self.limit_map)
|
||||
self.motor_map = pg.ScatterPlotItem(
|
||||
size=self.scatter_size, pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 255, 255)
|
||||
)
|
||||
self.plot_map.addItem(self.motor_map)
|
||||
self.plot_map.showGrid(x=True, y=True)
|
||||
|
||||
##########################
|
||||
# Motor General setting
|
||||
##########################
|
||||
|
||||
# TODO make function to update precision
|
||||
self.precision = 2 # self.spinBox_precision.value() # Define the decimal precision
|
||||
|
||||
##########################
|
||||
# Motor movements signals
|
||||
##########################
|
||||
|
||||
self.toolButton_right.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_x, self.spinBox_step.value())
|
||||
)
|
||||
self.toolButton_left.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_x, -self.spinBox_step.value())
|
||||
)
|
||||
self.toolButton_up.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_y, self.spinBox_step.value())
|
||||
)
|
||||
self.toolButton_down.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_y, -self.spinBox_step.value())
|
||||
)
|
||||
|
||||
# Switch between key shortcuts active
|
||||
self.checkBox_enableArrows.stateChanged.connect(self.update_arrow_key_shortcuts)
|
||||
self.update_arrow_key_shortcuts()
|
||||
|
||||
# Move to absolute coordinates
|
||||
self.pushButton_go_absolute.clicked.connect(
|
||||
lambda: self.move_motor_absolute(
|
||||
self.spinBox_absolute_x.value(), self.spinBox_absolute_y.value()
|
||||
)
|
||||
)
|
||||
self.pushButton_go_absolute.clicked.connect(self.save_absolute_coordinates)
|
||||
self.pushButton_go_absolute.setShortcut("Ctrl+G")
|
||||
self.pushButton_go_absolute.setToolTip("Ctrl+G")
|
||||
|
||||
self.motor_thread.move_finished.connect(lambda: self.enable_motor_controls(True))
|
||||
|
||||
# Stop Button
|
||||
self.pushButton_stop.clicked.connect(self.motor_thread.stop_movement)
|
||||
|
||||
##########################
|
||||
# Motor Configs
|
||||
##########################
|
||||
|
||||
# SpinBoxes - Motor Limits #TODO make spinboxes own limits updated, currently is [-1000, 1000]
|
||||
|
||||
# SpinBoxes change color to yellow before updated, limits are updated with update button
|
||||
self.spinBox_x_min.valueChanged.connect(lambda: self.param_changed(self.spinBox_x_min))
|
||||
self.spinBox_x_max.valueChanged.connect(lambda: self.param_changed(self.spinBox_x_max))
|
||||
self.spinBox_y_min.valueChanged.connect(lambda: self.param_changed(self.spinBox_y_min))
|
||||
self.spinBox_y_max.valueChanged.connect(lambda: self.param_changed(self.spinBox_y_max))
|
||||
|
||||
# SpinBoxes - Motor Speed
|
||||
self.spinBox_speed_x.valueChanged.connect(lambda: self.param_changed(self.spinBox_speed_x))
|
||||
self.spinBox_speed_y.valueChanged.connect(lambda: self.param_changed(self.spinBox_speed_y))
|
||||
|
||||
# SpinBoxes - Motor Update Frequency
|
||||
self.spinBox_update_frequency_x.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_update_frequency_x)
|
||||
)
|
||||
self.spinBox_update_frequency_y.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_update_frequency_y)
|
||||
)
|
||||
|
||||
# SpinBoxes - Max Points and N Dim Points
|
||||
self.spinBox_max_points.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_max_points)
|
||||
)
|
||||
self.spinBox_num_dim_points.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_num_dim_points)
|
||||
)
|
||||
self.spinBox_scatter_size.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_scatter_size)
|
||||
)
|
||||
|
||||
# Config updates
|
||||
self.pushButton_updateLimits.clicked.connect(
|
||||
lambda: self.update_all_motor_limits(
|
||||
x_limit=[self.spinBox_x_min.value(), self.spinBox_x_max.value()],
|
||||
y_limit=[self.spinBox_y_min.value(), self.spinBox_y_max.value()],
|
||||
)
|
||||
)
|
||||
|
||||
self.pushButton_update_config.clicked.connect(
|
||||
lambda: self.update_plot_setting(
|
||||
max_points=self.spinBox_max_points.value(),
|
||||
num_dim_points=self.spinBox_num_dim_points.value(),
|
||||
scatter_size=self.spinBox_scatter_size.value(),
|
||||
)
|
||||
)
|
||||
|
||||
self.pushButton_update_config.clicked.connect(
|
||||
lambda: self.update_all_config(
|
||||
speed=[self.spinBox_speed_x.value(), self.spinBox_speed_y.value()],
|
||||
update_frequency=[
|
||||
self.spinBox_update_frequency_x.value(),
|
||||
self.spinBox_update_frequency_y.value(),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# TODO map with floats as well -> or decide system for higher precision
|
||||
self.motor_thread.coordinates_updated.connect(
|
||||
lambda x, y: self.update_image_map(round(x, self.precision), round(y, self.precision))
|
||||
)
|
||||
|
||||
# Motor connections
|
||||
self.pushButton_connecMotors.clicked.connect(
|
||||
lambda: self.connect_motor(
|
||||
self.comboBox_motor_x.currentText(), self.comboBox_motor_y.currentText()
|
||||
)
|
||||
)
|
||||
|
||||
# Check if there are any motors connected
|
||||
if self.motor_x or self.motor_y is None:
|
||||
self.motorControl.setEnabled(False)
|
||||
self.motorControl_absolute.setEnabled(False)
|
||||
self.tabWidget_tables.setTabEnabled(1, False)
|
||||
|
||||
# Keyboard shortcuts
|
||||
delete_shortcut = QShortcut(QKeySequence("Delete"), self)
|
||||
backspace_shortcut = QShortcut(QKeySequence("Backspace"), self)
|
||||
delete_shortcut.activated.connect(self.delete_selected_row)
|
||||
backspace_shortcut.activated.connect(self.delete_selected_row)
|
||||
|
||||
def init_motor_map(self):
|
||||
# Get motor limits
|
||||
limit_x_min, limit_x_max = self.motor_thread.get_motor_limits(self.motor_x)
|
||||
limit_y_min, limit_y_max = self.motor_thread.get_motor_limits(self.motor_y)
|
||||
|
||||
self.offset_x = limit_x_min
|
||||
self.offset_y = limit_y_min
|
||||
|
||||
# Define the size of the image map based on the motor's limits
|
||||
map_width = limit_x_max - limit_x_min + 1
|
||||
map_height = limit_y_max - limit_y_min + 1
|
||||
|
||||
# Create an empty image map
|
||||
self.background_value = 15
|
||||
self.limit_map_data = np.full(
|
||||
(map_width, map_height), self.background_value, dtype=np.float32
|
||||
)
|
||||
self.limit_map.setImage(self.limit_map_data)
|
||||
|
||||
# Set the initial position on the map
|
||||
init_pos = self.motor_thread.retrieve_coordinates()
|
||||
self.motor_positions = np.array([init_pos])
|
||||
self.brushes = [pg.mkBrush(255, 255, 255, 255)]
|
||||
|
||||
self.motor_map.setData(pos=self.motor_positions, brush=self.brushes)
|
||||
|
||||
# Translate and scale the image item to match the motor coordinates
|
||||
self.tr = QtGui.QTransform()
|
||||
self.tr.translate(limit_x_min, limit_y_min)
|
||||
self.limit_map.setTransform(self.tr)
|
||||
|
||||
def update_image_map(self, x, y):
|
||||
# Update label
|
||||
self.label_coorditanes.setText(f"Motor position: ({x}, {y})")
|
||||
|
||||
# Add new point with full brightness
|
||||
new_pos = np.array([x, y])
|
||||
self.motor_positions = np.vstack((self.motor_positions, new_pos))
|
||||
|
||||
# If the number of points exceeds max_points, delete the oldest points
|
||||
if len(self.motor_positions) > self.max_points:
|
||||
self.motor_positions = self.motor_positions[-self.max_points :]
|
||||
|
||||
# Determine brushes based on position in the array
|
||||
self.brushes = [pg.mkBrush(50, 50, 50, 255)] * len(self.motor_positions)
|
||||
|
||||
# Calculate the decrement step based on self.num_dim_points
|
||||
decrement_step = (255 - 50) / self.num_dim_points
|
||||
|
||||
for i in range(1, min(self.num_dim_points + 1, len(self.motor_positions) + 1)):
|
||||
brightness = max(50, 255 - decrement_step * (i - 1))
|
||||
self.brushes[-i] = pg.mkBrush(brightness, brightness, brightness, 255)
|
||||
|
||||
self.brushes[-1] = pg.mkBrush(255, 255, 255, 255) # Newest point is always full brightness
|
||||
|
||||
self.motor_map.setData(pos=self.motor_positions, brush=self.brushes, size=self.scatter_size)
|
||||
|
||||
def update_all_motor_limits(self, x_limit: list = None, y_limit: list = None) -> None:
|
||||
self.motor_thread.update_all_motor_limits(x_limit=x_limit, y_limit=y_limit)
|
||||
|
||||
def update_all_config(self, speed: list = None, update_frequency: list = None) -> None:
|
||||
# TODO now only speed and update frequency
|
||||
self.motor_thread.update_all_config(speed=speed, update_frequency=update_frequency)
|
||||
|
||||
def update_arrow_key_shortcuts(self):
|
||||
if self.checkBox_enableArrows.isChecked():
|
||||
# Set the arrow key shortcuts for motor movement
|
||||
self.toolButton_right.setShortcut(Qt.Key_Right)
|
||||
self.toolButton_left.setShortcut(Qt.Key_Left)
|
||||
self.toolButton_up.setShortcut(Qt.Key_Up)
|
||||
self.toolButton_down.setShortcut(Qt.Key_Down)
|
||||
else:
|
||||
# Clear the shortcuts
|
||||
self.toolButton_right.setShortcut("")
|
||||
self.toolButton_left.setShortcut("")
|
||||
self.toolButton_up.setShortcut("")
|
||||
self.toolButton_down.setShortcut("")
|
||||
|
||||
def generate_table_coordinate(
|
||||
self, table: QtWidgets.QTableWidget, coordinates: tuple, tag: str = None, precision: int = 0
|
||||
) -> None:
|
||||
current_row_count = table.rowCount()
|
||||
|
||||
table.setRowCount(current_row_count + 1)
|
||||
|
||||
checkBox = QtWidgets.QCheckBox()
|
||||
checkBox.setChecked(True)
|
||||
button = QtWidgets.QPushButton("Go")
|
||||
|
||||
table.setItem(current_row_count, 0, QtWidgets.QTableWidgetItem(str(tag)))
|
||||
table.setCellWidget(current_row_count, 1, checkBox)
|
||||
table.setItem(
|
||||
current_row_count, 2, QtWidgets.QTableWidgetItem(str(f"{coordinates[0]:.{precision}f}"))
|
||||
)
|
||||
table.setItem(
|
||||
current_row_count, 3, QtWidgets.QTableWidgetItem(str(f"{coordinates[1]:.{precision}f}"))
|
||||
)
|
||||
table.setCellWidget(current_row_count, 4, button)
|
||||
|
||||
# hook signals of table
|
||||
button.clicked.connect(
|
||||
lambda: self.move_motor_absolute(
|
||||
float(table.item(current_row_count, 2).text()),
|
||||
float(table.item(current_row_count, 3).text()),
|
||||
)
|
||||
)
|
||||
table.resizeColumnsToContents()
|
||||
|
||||
def delete_selected_row(self):
|
||||
selected_rows = self.tableWidget_coordinates.selectionModel().selectedRows()
|
||||
|
||||
# If you allow multiple selections, you may want to loop through all selected rows
|
||||
for row in reversed(selected_rows): # Reverse to delete from the end
|
||||
self.tableWidget_coordinates.removeRow(row.row())
|
||||
|
||||
def save_absolute_coordinates(self):
|
||||
self.generate_table_coordinate(
|
||||
self.tableWidget_coordinates,
|
||||
(self.spinBox_absolute_x.value(), self.spinBox_absolute_y.value()),
|
||||
tag=f"Pos {self.tag_N}",
|
||||
precision=0,
|
||||
)
|
||||
|
||||
self.tag_N += 1
|
||||
|
||||
@staticmethod
|
||||
def param_changed(ui_element):
|
||||
ui_element.setStyleSheet("background-color: #FFA700;")
|
||||
|
||||
|
||||
class MotorActions(Enum):
|
||||
MOVE_TO_COORDINATES = "move_to_coordinates"
|
||||
MOVE_RELATIVE = "move_relative"
|
||||
|
||||
|
||||
class MotorControl(QThread):
|
||||
coordinates_updated = pyqtSignal(float, float) # Signal to emit current coordinates
|
||||
limits_retrieved = pyqtSignal(list, list) # Signal to emit current limits
|
||||
speed_retrieved = pyqtSignal(int, int) # Signal to emit current speed
|
||||
update_frequency_retrieved = pyqtSignal(int, int) # Signal to emit current update frequency
|
||||
move_finished = pyqtSignal() # Signal to emit when the move is finished
|
||||
motors_loaded = pyqtSignal(list, list) # Signal to emit when the motors are loaded
|
||||
motors_selected = pyqtSignal(object, object) # Signal to emit when the motors are selected
|
||||
# progress_updated = pyqtSignal(int) #TODO Signal to emit progress percentage
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.action = None
|
||||
self._initialize_motor()
|
||||
|
||||
def motor_by_string(self, motor_x_name: str, motor_y_name: str) -> tuple:
|
||||
motor_x_index = self.all_motors_names.index(motor_x_name)
|
||||
motor_y_index = self.all_motors_names.index(motor_y_name)
|
||||
|
||||
motor_x = self.all_motors[motor_x_index]
|
||||
motor_y = self.all_motors[motor_y_index]
|
||||
return motor_x, motor_y
|
||||
|
||||
def connect_motors(self, motor_x_name: str, motor_y_name: str) -> None:
|
||||
self.motor_x, self.motor_y = self.motor_by_string(motor_x_name, motor_y_name)
|
||||
|
||||
(self.current_x, self.current_y) = self.get_coordinates()
|
||||
|
||||
if self.motors_consumer is not None:
|
||||
self.motors_consumer.shutdown()
|
||||
|
||||
self.motors_consumer = client.connector.consumer(
|
||||
topics=[
|
||||
MessageEndpoints.device_readback(self.motor_x.name),
|
||||
MessageEndpoints.device_readback(self.motor_y.name),
|
||||
],
|
||||
cb=self._device_status_callback_motors,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.motors_consumer.start()
|
||||
|
||||
self.motors_selected.emit(self.motor_x, self.motor_y)
|
||||
|
||||
def get_all_motors(self) -> list:
|
||||
all_motors = client.device_manager.devices.acquisition_group("motor")
|
||||
return all_motors
|
||||
|
||||
def get_all_motors_names(self) -> list:
|
||||
all_motors = client.device_manager.devices.acquisition_group("motor")
|
||||
all_motors_names = [motor.name for motor in all_motors]
|
||||
return all_motors_names
|
||||
|
||||
def retrieve_all_motors(self):
|
||||
self.all_motors = self.get_all_motors()
|
||||
self.all_motors_names = self.get_all_motors_names()
|
||||
self.motors_loaded.emit(self.all_motors_names, self.all_motors_names)
|
||||
|
||||
return self.all_motors, self.all_motors_names
|
||||
|
||||
def get_coordinates(self) -> tuple:
|
||||
"""Get current motor position"""
|
||||
x = self.motor_x.read(cached=True)["value"]
|
||||
y = self.motor_y.read(cached=True)["value"]
|
||||
return x, y
|
||||
|
||||
def retrieve_coordinates(self) -> tuple:
|
||||
"""Get current motor position for export to main app"""
|
||||
return self.current_x, self.current_y
|
||||
|
||||
def get_motor_limits(self, motor) -> list:
|
||||
"""Get the limits of a motor"""
|
||||
return motor.limits
|
||||
|
||||
def get_motor_config(self, motor) -> dict:
|
||||
"""Get the configuration of a motor""" # TODO at this moment just for speed and update_frequency
|
||||
return motor.get_device_config()
|
||||
|
||||
def update_all_config(self, speed: list = None, update_frequency: list = None) -> None:
|
||||
# TODO now only speed and update frequency
|
||||
if speed is not None:
|
||||
self.motor_x.set_device_config({"speed": speed[0]})
|
||||
self.motor_y.set_device_config({"speed": speed[1]})
|
||||
|
||||
if update_frequency is not None:
|
||||
self.motor_x.set_device_config({"update_frequency": update_frequency[0]})
|
||||
self.motor_y.set_device_config({"update_frequency": update_frequency[1]})
|
||||
|
||||
self.retrieve_motor_speed(self.motor_x, self.motor_y)
|
||||
self.retrieve_motor_update_frequency(self.motor_x, self.motor_y)
|
||||
|
||||
def retrieve_motor_speed(
|
||||
self, motor_x, motor_y
|
||||
) -> None: # TODO can be migrated to some general config function
|
||||
"""Get the speed of a motor"""
|
||||
speed_x = motor_x.get_device_config()["speed"]
|
||||
speed_y = motor_y.get_device_config()["speed"]
|
||||
self.speed_retrieved.emit(int(speed_x), int(speed_y))
|
||||
|
||||
def retrieve_motor_update_frequency(
|
||||
self, motor_x, motor_y
|
||||
) -> None: # TODO can be migrated to some general config function
|
||||
"""Get the speed of a motor"""
|
||||
update_frequency_x = motor_x.get_device_config()["update_frequency"]
|
||||
update_frequency_y = motor_y.get_device_config()["update_frequency"]
|
||||
self.update_frequency_retrieved.emit(int(update_frequency_x), int(update_frequency_y))
|
||||
|
||||
def retrieve_motor_limits(self, motor_x, motor_y):
|
||||
limit_x = self.get_motor_limits(motor_x)
|
||||
limit_y = self.get_motor_limits(motor_y)
|
||||
self.limits_retrieved.emit(limit_x, limit_y)
|
||||
|
||||
def update_motor_limits(self, motor, low_limit=None, high_limit=None) -> None:
|
||||
current_low_limit, current_high_limit = self.get_motor_limits(motor)
|
||||
|
||||
# Check if the low limit has changed and is not None
|
||||
if low_limit is not None and low_limit != current_low_limit:
|
||||
motor.low_limit = low_limit
|
||||
|
||||
# Check if the high limit has changed and is not None
|
||||
if high_limit is not None and high_limit != current_high_limit:
|
||||
motor.high_limit = high_limit
|
||||
|
||||
def update_all_motor_limits(self, x_limit: list = None, y_limit: list = None) -> None:
|
||||
current_position = self.get_coordinates()
|
||||
|
||||
if x_limit is not None:
|
||||
if current_position[0] < x_limit[0] or current_position[0] > x_limit[1]:
|
||||
raise ValueError("Current motor position is outside the new limits (X)")
|
||||
else:
|
||||
self.update_motor_limits(self.motor_x, low_limit=x_limit[0], high_limit=x_limit[1])
|
||||
|
||||
if y_limit is not None:
|
||||
if current_position[1] < y_limit[0] or current_position[1] > y_limit[1]:
|
||||
raise ValueError("Current motor position is outside the new limits (Y)")
|
||||
else:
|
||||
self.update_motor_limits(self.motor_y, low_limit=y_limit[0], high_limit=y_limit[1])
|
||||
|
||||
self.retrieve_motor_limits(self.motor_x, self.motor_y)
|
||||
|
||||
def move_to_coordinates(self, target_coordinates: tuple):
|
||||
self.action = MotorActions.MOVE_TO_COORDINATES
|
||||
self.target_coordinates = target_coordinates
|
||||
self.start()
|
||||
|
||||
def move_relative(self, motor, value: float):
|
||||
self.action = MotorActions.MOVE_RELATIVE
|
||||
self.motor = motor
|
||||
self.value = value
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
if self.action == MotorActions.MOVE_TO_COORDINATES:
|
||||
self._move_motor_coordinate()
|
||||
elif self.action == MotorActions.MOVE_RELATIVE:
|
||||
self._move_motor_relative(self.motor, self.value)
|
||||
|
||||
def set_target_coordinates(self, target_coordinates: tuple) -> None:
|
||||
self.target_coordinates = target_coordinates
|
||||
|
||||
def _initialize_motor(self) -> None:
|
||||
self.motor_x, self.motor_y = None, None
|
||||
self.current_x, self.current_y = None, None
|
||||
|
||||
self.motors_consumer = None
|
||||
|
||||
# Get all available motors in the client
|
||||
self.all_motors = self.get_all_motors()
|
||||
self.all_motors_names = self.get_all_motors_names()
|
||||
self.retrieve_all_motors() # send motor list to GUI
|
||||
|
||||
self.target_coordinates = None
|
||||
|
||||
def _move_motor_coordinate(self) -> None:
|
||||
"""Move the motor to the specified coordinates"""
|
||||
status = scans.mv(
|
||||
self.motor_x,
|
||||
self.target_coordinates[0],
|
||||
self.motor_y,
|
||||
self.target_coordinates[1],
|
||||
relative=False,
|
||||
)
|
||||
|
||||
status.wait()
|
||||
self.move_finished.emit()
|
||||
|
||||
def _move_motor_relative(self, motor, value: float) -> None:
|
||||
status = scans.mv(motor, value, relative=True)
|
||||
|
||||
status.wait()
|
||||
self.move_finished.emit()
|
||||
|
||||
def stop_movement(self):
|
||||
queue.request_scan_abortion()
|
||||
queue.request_queue_reset()
|
||||
|
||||
@staticmethod
|
||||
def _device_status_callback_motors(msg, *, parent, **_kwargs) -> None:
|
||||
deviceMSG = BECMessage.DeviceMessage.loads(msg.value)
|
||||
if parent.motor_x.name in deviceMSG.content["signals"]:
|
||||
parent.current_x = deviceMSG.content["signals"][parent.motor_x.name]["value"]
|
||||
elif parent.motor_y.name in deviceMSG.content["signals"]:
|
||||
parent.current_y = deviceMSG.content["signals"][parent.motor_y.name]["value"]
|
||||
parent.coordinates_updated.emit(parent.current_x, parent.current_y)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from bec_lib import BECClient
|
||||
|
||||
# from bec_lib.core import ServiceConfig,RedisConnector
|
||||
|
||||
client = BECClient()
|
||||
# client.initialize(config=ServiceConfig(config_path="test_config.yaml"))
|
||||
|
||||
# Client initialization - by dispatcher
|
||||
# client = bec_dispatcher.client
|
||||
client.start()
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
|
||||
app = QApplication([])
|
||||
window = MotorApp()
|
||||
window.show()
|
||||
app.exec_()
|
||||
3
bec_widgets/examples/oneplot/config_gaussworker.yaml
Normal file
3
bec_widgets/examples/oneplot/config_gaussworker.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
x_value: "samx"
|
||||
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
|
||||
dap_worker: "gaussian_fit_worker_3"
|
||||
3
bec_widgets/examples/oneplot/config_noworker.yaml
Normal file
3
bec_widgets/examples/oneplot/config_noworker.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
x_value: "samx"
|
||||
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
|
||||
dap_worker: None
|
||||
276
bec_widgets/examples/oneplot/oneplot.py
Normal file
276
bec_widgets/examples/oneplot/oneplot.py
Normal file
@@ -0,0 +1,276 @@
|
||||
import os
|
||||
|
||||
import PyQt5.QtWidgets
|
||||
import numpy as np
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import QApplication, QWidget
|
||||
from PyQt5.QtWidgets import QTableWidgetItem
|
||||
from pyqtgraph import mkBrush, mkPen
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, uic
|
||||
|
||||
from bec_widgets.qt_utils import Crosshair
|
||||
from bec_lib.core import MessageEndpoints
|
||||
|
||||
# TODO implement:
|
||||
# - implement scanID database for visualizing previous scans
|
||||
# - multiple signals for different monitors
|
||||
# - change how dap is handled in bec_dispatcher to handle more workers
|
||||
|
||||
|
||||
class PlotApp(QWidget):
|
||||
"""
|
||||
Main class for the PlotApp used to plot two signals from the BEC.
|
||||
|
||||
Attributes:
|
||||
update_signal (pyqtSignal): Signal to trigger plot updates.
|
||||
update_dap_signal (pyqtSignal): Signal to trigger DAP updates.
|
||||
|
||||
Args:
|
||||
x_value (str): The x device/signal for plotting.
|
||||
y_values (list of str): List of y device/signals for plotting.
|
||||
dap_worker (str, optional): DAP process to specify. Set to None to disable.
|
||||
parent (QWidget, optional): Parent widget.
|
||||
"""
|
||||
|
||||
update_signal = pyqtSignal()
|
||||
update_dap_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, x_value, y_values, dap_worker=None, parent=None):
|
||||
super(PlotApp, self).__init__(parent)
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "oneplot.ui"), self)
|
||||
|
||||
self.x_value = x_value
|
||||
self.y_values = y_values
|
||||
self.dap_worker = dap_worker
|
||||
|
||||
self.scanID = None
|
||||
self.data_x = []
|
||||
self.data_y = []
|
||||
|
||||
self.dap_x = np.array([])
|
||||
self.dap_y = np.array([])
|
||||
|
||||
self.fit = None
|
||||
|
||||
self.init_ui()
|
||||
self.init_curves()
|
||||
self.hook_crosshair()
|
||||
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self.update_plot
|
||||
)
|
||||
self.proxy_update_fit = pg.SignalProxy(
|
||||
self.update_dap_signal, rateLimit=25, slot=self.update_fit_table
|
||||
)
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Initialize the UI components."""
|
||||
self.plot = pg.PlotItem()
|
||||
self.glw.addItem(self.plot)
|
||||
self.plot.setLabel("bottom", self.x_value)
|
||||
self.plot.setLabel("left", ", ".join(self.y_values))
|
||||
self.plot.addLegend()
|
||||
|
||||
def init_curves(self) -> None:
|
||||
"""Initialize curve data and properties."""
|
||||
self.plot.clear()
|
||||
|
||||
self.curves_data = []
|
||||
self.curves_dap = []
|
||||
|
||||
colors_y_values = PlotApp.golden_angle_color(colormap="CET-R2", num=len(self.y_values))
|
||||
# colors_y_daps = PlotApp.golden_angle_color(
|
||||
# colormap="CET-I2", num=len(self.dap_worker)
|
||||
# ) # TODO adapt for multiple dap_workers
|
||||
|
||||
# Initialize curves for y_values
|
||||
for ii, (signal, color) in enumerate(zip(self.y_values, colors_y_values)):
|
||||
pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
|
||||
brush_curve = mkBrush(color=color)
|
||||
curve_data = pg.PlotDataItem(
|
||||
symbolSize=5,
|
||||
symbolBrush=brush_curve,
|
||||
pen=pen_curve,
|
||||
skipFiniteCheck=True,
|
||||
name=f"{signal}",
|
||||
)
|
||||
self.curves_data.append(curve_data)
|
||||
self.plot.addItem(curve_data)
|
||||
|
||||
# Initialize curves for DAP if dap_worker is not None
|
||||
if self.dap_worker is not None:
|
||||
# for ii, (monitor, color) in enumerate(zip(self.dap_worker, colors_y_daps)):#TODO adapt for multiple dap_workers
|
||||
pen_dap = mkPen(color="#3b5998", width=2, style=QtCore.Qt.DashLine)
|
||||
curve_dap = pg.PlotDataItem(
|
||||
pen=pen_dap,
|
||||
skipFiniteCheck=True,
|
||||
symbolSize=5,
|
||||
name=f"{self.dap_worker}",
|
||||
)
|
||||
self.curves_dap.append(curve_dap)
|
||||
self.plot.addItem(curve_dap)
|
||||
|
||||
self.tableWidget_crosshair.setRowCount(len(self.y_values))
|
||||
self.tableWidget_crosshair.setVerticalHeaderLabels(self.y_values)
|
||||
self.hook_crosshair()
|
||||
|
||||
def hook_crosshair(self) -> None:
|
||||
"""Attach the crosshair to the plot."""
|
||||
self.crosshair_1d = Crosshair(self.plot, precision=3)
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(self.tableWidget_crosshair, x, y, column=0)
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.update_table(self.tableWidget_crosshair, x, y, column=1)
|
||||
)
|
||||
|
||||
def update_table(
|
||||
self, table_widget: PyQt5.QtWidgets.QTableWidget, x: float, y_values: list, column: int
|
||||
) -> None:
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_plot(self) -> None:
|
||||
"""Update the plot data."""
|
||||
for ii, curve in enumerate(self.curves_data):
|
||||
curve.setData(self.data_x, self.data_y[ii])
|
||||
|
||||
if self.dap_worker is not None:
|
||||
# for ii, curve in enumerate(self.curves_dap): #TODO adapt for multiple dap_workers
|
||||
# curve.setData(self.dap_x, self.dap_y[ii])
|
||||
self.curves_dap[0].setData(self.dap_x, self.dap_y)
|
||||
|
||||
def update_fit_table(self):
|
||||
"""Update the table for fit data."""
|
||||
|
||||
self.tableWidget_fit.setData(self.fit)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_dap_update(self, msg: dict, metadata: dict) -> None:
|
||||
"""
|
||||
Update DAP related data.
|
||||
|
||||
Args:
|
||||
msg (dict): Message received with data.
|
||||
metadata (dict): Metadata of the DAP.
|
||||
"""
|
||||
|
||||
# TODO adapt for multiple dap_workers
|
||||
self.dap_x = msg[self.dap_worker]["x"]
|
||||
self.dap_y = msg[self.dap_worker]["y"]
|
||||
|
||||
self.fit = metadata["fit_parameters"]
|
||||
|
||||
self.update_dap_signal.emit()
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Handle new scan segments.
|
||||
|
||||
Args:
|
||||
msg (dict): Message received with scan data.
|
||||
metadata (dict): Metadata of the scan.
|
||||
"""
|
||||
current_scanID = msg["scanID"]
|
||||
|
||||
if current_scanID != self.scanID:
|
||||
self.scanID = current_scanID
|
||||
self.data_x = []
|
||||
self.data_y = [[] for _ in self.y_values]
|
||||
self.init_curves()
|
||||
|
||||
dev_x = self.x_value
|
||||
data_x = msg["data"][dev_x][dev[dev_x]._hints[0]]["value"]
|
||||
self.data_x.append(data_x)
|
||||
|
||||
for ii, dev_y in enumerate(self.y_values):
|
||||
data_y = msg["data"][dev_y][dev[dev_y]._hints[0]]["value"]
|
||||
self.data_y[ii].append(data_y)
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = PlotApp.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import yaml
|
||||
from bec_widgets import ctrl_c
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
with open("config_noworker.yaml", "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
x_value = config["x_value"]
|
||||
y_values = config["y_values"]
|
||||
dap_worker = config["dap_worker"]
|
||||
|
||||
dap_worker = None if dap_worker == "None" else dap_worker
|
||||
|
||||
# BECclient global variables
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
|
||||
app = QApplication([])
|
||||
plotApp = PlotApp(x_value=x_value, y_values=y_values, dap_worker=dap_worker)
|
||||
|
||||
# Connecting signals from bec_dispatcher
|
||||
bec_dispatcher.connect_dap_slot(plotApp.on_dap_update, dap_worker)
|
||||
bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
ctrl_c.setup(app)
|
||||
|
||||
window = plotApp
|
||||
window.show()
|
||||
app.exec_()
|
||||
75
bec_widgets/examples/oneplot/oneplot.ui
Normal file
75
bec_widgets/examples/oneplot/oneplot.ui
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>547</width>
|
||||
<height>653</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,2">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout" stretch="2,1">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Cursor</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QTableWidget" name="tableWidget_crosshair">
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Moved</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Clicked</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Fit</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="TableWidget" name="tableWidget_fit"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="GraphicsLayoutWidget" name="glw"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>TableWidget</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
168
bec_widgets/examples/plotting/crosshair_example.py
Normal file
168
bec_widgets/examples/plotting/crosshair_example.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QSpinBox,
|
||||
)
|
||||
from pyqtgraph import mkPen
|
||||
from pyqtgraph.Qt import QtCore
|
||||
from bec_widgets.qt_utils import Crosshair
|
||||
|
||||
|
||||
class ExampleApp(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# Layout
|
||||
self.layout = QHBoxLayout()
|
||||
self.setLayout(self.layout)
|
||||
|
||||
##########################
|
||||
# 1D Plot
|
||||
##########################
|
||||
|
||||
# PlotWidget
|
||||
self.plot_widget_1d = pg.PlotWidget(title="1D PlotWidget with multiple curves")
|
||||
self.plot_item_1d = self.plot_widget_1d.getPlotItem()
|
||||
self.plot_item_1d.setLogMode(True, True)
|
||||
|
||||
# 1D Datasets
|
||||
self.x_data = np.linspace(0, 10, 1000)
|
||||
|
||||
def gauss(x, mu, sigma):
|
||||
return (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
|
||||
|
||||
# same convention as in line_plot.py
|
||||
self.y_value_list = [
|
||||
gauss(self.x_data, 1, 1),
|
||||
gauss(self.x_data, 1.5, 3),
|
||||
abs(np.sin(self.x_data)),
|
||||
abs(np.cos(self.x_data)),
|
||||
abs(np.sin(2 * self.x_data)),
|
||||
] # List of y-values for multiple curves
|
||||
|
||||
self.curve_names = ["Gauss(1,1)", "Gauss(1.5,3)", "Abs(Sine)", "Abs(Cosine)", "Abs(Sine2x)"]
|
||||
self.curves = []
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
self.plot_widget_2d = pg.PlotWidget(title="2D plot with crosshair and ROI square")
|
||||
self.data_2D = np.random.random((100, 200))
|
||||
self.plot_item_2d = self.plot_widget_2d.getPlotItem()
|
||||
self.image_item = pg.ImageItem(self.data_2D)
|
||||
self.plot_item_2d.addItem(self.image_item)
|
||||
|
||||
##########################
|
||||
# Table
|
||||
##########################
|
||||
self.table = QTableWidget(len(self.curve_names), 2)
|
||||
self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"])
|
||||
self.table.setVerticalHeaderLabels(self.curve_names)
|
||||
self.table.resizeColumnsToContents()
|
||||
|
||||
##########################
|
||||
# Spinbox for N curves
|
||||
##########################
|
||||
self.spin_box = QSpinBox()
|
||||
self.spin_box.setMinimum(0)
|
||||
self.spin_box.setMaximum(len(self.y_value_list))
|
||||
self.spin_box.setValue(2)
|
||||
self.spin_box.valueChanged.connect(lambda: self.update_curves(self.spin_box.value()))
|
||||
|
||||
##########################
|
||||
# Adding widgets to layout
|
||||
##########################
|
||||
|
||||
##### left side #####
|
||||
self.column1 = QVBoxLayout()
|
||||
self.layout.addLayout(self.column1)
|
||||
|
||||
# SpinBox
|
||||
self.spin_row = QHBoxLayout()
|
||||
self.column1.addLayout(self.spin_row)
|
||||
self.spin_row.addWidget(QLabel("Number of curves:"))
|
||||
self.spin_row.addWidget(self.spin_box)
|
||||
|
||||
# label
|
||||
self.clicked_label_1d = QLabel("Clicked Coordinates (1D):")
|
||||
self.column1.addWidget(self.clicked_label_1d)
|
||||
|
||||
# table
|
||||
self.column1.addWidget(self.table)
|
||||
|
||||
# 1D plot
|
||||
self.column1.addWidget(self.plot_widget_1d)
|
||||
|
||||
##### left side #####
|
||||
self.column2 = QVBoxLayout()
|
||||
self.layout.addLayout(self.column2)
|
||||
|
||||
# labels
|
||||
self.clicked_label_2d = QLabel("Clicked Coordinates (2D):")
|
||||
self.moved_label_2d = QLabel("Moved Coordinates (2D):")
|
||||
self.column2.addWidget(self.clicked_label_2d)
|
||||
self.column2.addWidget(self.moved_label_2d)
|
||||
|
||||
# 2D plot
|
||||
self.column2.addWidget(self.plot_widget_2d)
|
||||
|
||||
self.update_curves(2) # just Gaussian curves
|
||||
|
||||
def hook_crosshair(self):
|
||||
self.crosshair_1d = Crosshair(self.plot_item_1d, precision=10)
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(self.table, x, y, column=0)
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.update_table(self.table, x, y, column=1)
|
||||
)
|
||||
# 2D
|
||||
self.crosshair_2d = Crosshair(self.plot_item_2d)
|
||||
self.crosshair_2d.coordinatesChanged2D.connect(
|
||||
lambda x, y: self.moved_label_2d.setText(f"Mouse Moved Coordinates (2D): x={x}, y={y}")
|
||||
)
|
||||
self.crosshair_2d.coordinatesClicked2D.connect(
|
||||
lambda x, y: self.clicked_label_2d.setText(f"Clicked Coordinates (2D): x={x}, y={y}")
|
||||
)
|
||||
|
||||
def update_table(self, table_widget, x, y_values, column):
|
||||
"""Update the table with the new coordinates"""
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_curves(self, num_curves):
|
||||
"""Update the number of curves"""
|
||||
|
||||
self.plot_item_1d.clear()
|
||||
|
||||
# Curves
|
||||
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
|
||||
self.plot_item_1d.addLegend()
|
||||
self.curves = []
|
||||
|
||||
y_value_list = self.y_value_list[:num_curves]
|
||||
|
||||
for ii, y_value in enumerate(y_value_list):
|
||||
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
curve = pg.PlotDataItem(
|
||||
self.x_data, y_value, pen=pen, skipFiniteCheck=True, name=self.curve_names[ii]
|
||||
)
|
||||
self.plot_item_1d.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
|
||||
self.hook_crosshair()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
window = ExampleApp()
|
||||
window.show()
|
||||
app.exec_()
|
||||
408
bec_widgets/line_plot.py
Normal file
408
bec_widgets/line_plot.py
Normal file
@@ -0,0 +1,408 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.core import BECMessage
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtWidgets import QCheckBox, QTableWidgetItem
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets, uic
|
||||
from pyqtgraph.Qt.QtCore import pyqtSignal
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
from bec_lib.core.redis_connector import MessageObject, RedisConnector
|
||||
|
||||
from qt_utils import Crosshair
|
||||
|
||||
client = bec_dispatcher.client
|
||||
|
||||
|
||||
class BasicPlot(QtWidgets.QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
roi_signal = pyqtSignal(tuple)
|
||||
|
||||
def __init__(self, name="", y_value_list=["gauss_bpm"]) -> None:
|
||||
"""
|
||||
Basic plot widget for displaying scan data.
|
||||
|
||||
Args:
|
||||
name (str, optional): Name of the plot. Defaults to "".
|
||||
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
|
||||
"""
|
||||
|
||||
super(BasicPlot, self).__init__()
|
||||
# Set style for pyqtgraph plots
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
|
||||
|
||||
self._idle_time = 100
|
||||
self.producer = RedisConnector(["localhost:6379"]).producer()
|
||||
|
||||
self.y_value_list = y_value_list
|
||||
self.previous_y_value_list = None
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
|
||||
self.plotter_scan_id = None
|
||||
|
||||
self._current_proj = None
|
||||
self._current_metadata_ep = "px_stream/projection_{}/metadata"
|
||||
|
||||
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
|
||||
|
||||
self.data_retriever = threading.Thread(target=self.on_projection, daemon=True)
|
||||
self.data_retriever.start()
|
||||
|
||||
# self.comboBox.currentIndexChanged.connect(lambda : print(f'current comboText: {self.comboBox.currentText()}'))
|
||||
# self.comboBox.currentIndexChanged.connect(lambda: print(f'current comboIndex: {self.comboBox.currentIndex()}'))
|
||||
#
|
||||
# self.doubleSpinBox.valueChanged.connect(lambda : print('Spin Changed'))
|
||||
|
||||
# self.splitterH_main.setSizes([1, 1])
|
||||
|
||||
##########################
|
||||
# UI
|
||||
##########################
|
||||
self.init_ui()
|
||||
self.init_curves()
|
||||
self.hook_crosshair()
|
||||
self.pushButton_generate.clicked.connect(self.generate_data)
|
||||
|
||||
def init_ui(self):
|
||||
"""Setup all ui elements"""
|
||||
##########################
|
||||
# 1D Plot
|
||||
##########################
|
||||
|
||||
# LabelItem for ROI
|
||||
self.label_plot = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot)
|
||||
self.label_plot.setText("ROI region")
|
||||
|
||||
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
|
||||
self.roi_selector = pg.LinearRegionItem([-1, 1])
|
||||
|
||||
self.glw_plot.nextRow() # TODO update of cursor
|
||||
self.label_plot_moved = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot_moved)
|
||||
self.label_plot_moved.setText("Actual coordinates (X, Y)")
|
||||
|
||||
# Label for coordinates clicked
|
||||
self.glw_plot.nextRow()
|
||||
self.label_plot_clicked = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot_clicked)
|
||||
self.label_plot_clicked.setText("Clicked coordinates (X, Y)")
|
||||
|
||||
# 1D PlotItem
|
||||
self.glw_plot.nextRow()
|
||||
self.plot = pg.PlotItem()
|
||||
self.plot.setLogMode(True, True)
|
||||
self.glw_plot.addItem(self.plot)
|
||||
self.plot.addLegend()
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
|
||||
# Label for coordinates moved
|
||||
self.label_image_moved = pg.LabelItem(justify="center")
|
||||
self.glw_image.addItem(self.label_image_moved)
|
||||
self.label_image_moved.setText("Actual coordinates (X, Y)")
|
||||
|
||||
# Label for coordinates clicked
|
||||
self.glw_image.nextRow()
|
||||
self.label_image_clicked = pg.LabelItem(justify="center")
|
||||
self.glw_image.addItem(self.label_image_clicked)
|
||||
self.label_image_clicked.setText("Clicked coordinates (X, Y)")
|
||||
|
||||
# TODO try to lock aspect ratio with view
|
||||
|
||||
# # Create a window
|
||||
# win = pg.GraphicsLayoutWidget()
|
||||
# win.show()
|
||||
#
|
||||
# # Create a ViewBox
|
||||
# view = win.addViewBox()
|
||||
#
|
||||
# # Lock the aspect ratio
|
||||
# view.setAspectLocked(True)
|
||||
|
||||
# # Create an ImageItem
|
||||
# image_item = pg.ImageItem(np.random.random((100, 100)))
|
||||
#
|
||||
# # Add the ImageItem to the ViewBox
|
||||
# view.addItem(image_item)
|
||||
|
||||
# 2D ImageItem
|
||||
self.glw_image.nextRow()
|
||||
self.plot_image = pg.PlotItem()
|
||||
self.glw_image.addItem(self.plot_image)
|
||||
|
||||
def init_curves(self):
|
||||
# init of 1D plot
|
||||
self.plot.clear()
|
||||
|
||||
self.curves = []
|
||||
self.pens = []
|
||||
self.brushs = []
|
||||
|
||||
self.color_list = BasicPlot.golden_angle_color(
|
||||
colormap="CET-R2", num=len(self.y_value_list)
|
||||
)
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
pen = mkPen(color=self.color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
brush = mkBrush(color=self.color_list[ii])
|
||||
curve = pg.PlotDataItem(symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value)
|
||||
self.plot.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
self.pens.append(pen)
|
||||
self.brushs.append(brush)
|
||||
|
||||
# check if roi selector is in the plot
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
# init of 2D plot
|
||||
self.plot_image.clear()
|
||||
|
||||
self.img = pg.ImageItem()
|
||||
self.plot_image.addItem(self.img)
|
||||
|
||||
# hooking signals
|
||||
self.hook_crosshair()
|
||||
self.init_table()
|
||||
|
||||
def splitter_sizes(self):
|
||||
...
|
||||
|
||||
def hook_crosshair(self):
|
||||
self.crosshair_1d = Crosshair(self.plot, precision=4)
|
||||
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.label_plot_moved.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.label_plot_clicked.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(table_widget=self.cursor_table, x=x, y_values=y)
|
||||
)
|
||||
|
||||
self.crosshair_2D = Crosshair(self.plot_image)
|
||||
|
||||
self.crosshair_2D.coordinatesChanged2D.connect(
|
||||
lambda x, y: self.label_image_moved.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
self.crosshair_2D.coordinatesClicked2D.connect(
|
||||
lambda x, y: self.label_image_clicked.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
|
||||
# ROI
|
||||
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
|
||||
|
||||
def generate_data(self):
|
||||
def gauss(x, mu, sigma):
|
||||
return (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
|
||||
|
||||
self.plotter_data_x = np.linspace(0, 10, 1000)
|
||||
self.plotter_data_y = [
|
||||
gauss(self.plotter_data_x, 1, 1),
|
||||
gauss(self.plotter_data_x, 1.5, 3),
|
||||
np.sin(self.plotter_data_x),
|
||||
np.cos(self.plotter_data_x),
|
||||
np.sin(2 * self.plotter_data_x),
|
||||
] # List of y-values for multiple curves
|
||||
self.y_value_list = ["Gauss (1,1)", "Gauss (1.5,3)"] # ["Sine"]#, "Cosine", "Sine2x"]
|
||||
|
||||
# Curves
|
||||
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
|
||||
|
||||
self.init_curves()
|
||||
|
||||
for ii in range(len(self.y_value_list)):
|
||||
self.curves[ii].setData(self.plotter_data_x, self.plotter_data_y[ii])
|
||||
|
||||
self.data_2D = np.random.random((150, 30))
|
||||
self.img.setImage(self.data_2D)
|
||||
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
def get_roi_region(self):
|
||||
"""For testing purpose now, get roi region and print it to self.label as tuple"""
|
||||
region = self.roi_selector.getRegion()
|
||||
self.label_plot.setText(f"x = {(10 ** region[0]):.4f}, y ={(10 ** region[1]):.4f}")
|
||||
return_dict = {
|
||||
"horiz_roi": [
|
||||
np.where(self.plotter_data_x[0] > 10 ** region[0])[0][0],
|
||||
np.where(self.plotter_data_x[0] < 10 ** region[1])[0][-1],
|
||||
]
|
||||
}
|
||||
msg = BECMessage.DeviceMessage(signals=return_dict).dumps()
|
||||
self.producer.set_and_publish("px_stream/gui_event", msg=msg)
|
||||
self.roi_signal.emit(region)
|
||||
|
||||
def init_table(self):
|
||||
# Init number of rows in table according to n of devices
|
||||
self.cursor_table.setRowCount(len(self.y_value_list))
|
||||
# self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"]) #TODO can be dynamic
|
||||
self.cursor_table.setVerticalHeaderLabels(self.y_value_list)
|
||||
self.cursor_table.resizeColumnsToContents()
|
||||
|
||||
def update_table(self, table_widget, x, y_values):
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, 1, QTableWidgetItem(str(x)))
|
||||
table_widget.setItem(i, 2, QTableWidgetItem(str(y)))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update(self):
|
||||
"""Update the plot with the new data."""
|
||||
|
||||
# check if QTable was initialised and if list of devices was changed
|
||||
# if self.y_value_list != self.previous_y_value_list:
|
||||
# self.setup_cursor_table()
|
||||
# self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
|
||||
|
||||
self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
|
||||
|
||||
@staticmethod
|
||||
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
|
||||
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
|
||||
"""Removes a curve from the given plot by the specified name.
|
||||
|
||||
Args:
|
||||
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
|
||||
name (str): The name of the curve to remove.
|
||||
"""
|
||||
# if checkbox.isChecked():
|
||||
for item in plot.items:
|
||||
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
|
||||
plot.removeItem(item)
|
||||
return
|
||||
|
||||
# else:
|
||||
# return
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = BasicPlot.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
|
||||
def on_projection(self):
|
||||
while True:
|
||||
if self._current_proj is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
endpoint = f"px_stream/projection_{self._current_proj}/data"
|
||||
msgs = client.producer.lrange(topic=endpoint, start=-1, end=-1)
|
||||
data = [BECMessage.DeviceMessage.loads(msg) for msg in msgs]
|
||||
if not data:
|
||||
continue
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
self.plotter_data_y = [
|
||||
np.sum(
|
||||
np.sum(data[-1].content["signals"]["data"] * self._current_norm, axis=1)
|
||||
/ np.sum(self._current_norm, axis=0),
|
||||
axis=0,
|
||||
).squeeze()
|
||||
]
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_dap_update(self, data: dict, metadata: dict):
|
||||
self.img.setImage(data["z"])
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def new_proj(self, data):
|
||||
proj_nr = data["proj_nr"]
|
||||
endpoint = f"px_stream/projection_{proj_nr}/metadata"
|
||||
msg_raw = client.producer.get(topic=endpoint)
|
||||
msg = BECMessage.DeviceMessage.loads(msg_raw)
|
||||
self._current_q = msg.content["signals"]["q"]
|
||||
self._current_norm = msg.content["signals"]["norm_sum"]
|
||||
self._current_metadata = msg.content["signals"]["metadata"]
|
||||
|
||||
self.plotter_data_x = [self._current_q]
|
||||
self._current_proj = proj_nr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
from bec_widgets import ctrl_c
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--signals",
|
||||
help="specify recorded signals",
|
||||
nargs="+",
|
||||
default=["gauss_bpm"],
|
||||
)
|
||||
# default = ["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
|
||||
# dispatcher = bec_dispatcher
|
||||
value = parser.parse_args()
|
||||
print(f"Plotting signals for: {', '.join(value.signals)}")
|
||||
client = bec_dispatcher.client
|
||||
# client.start()
|
||||
app = QtWidgets.QApplication([])
|
||||
ctrl_c.setup(app)
|
||||
plot = BasicPlot(y_value_list=value.signals)
|
||||
# bec_dispatcher.connect(plot)
|
||||
bec_dispatcher.connect_proj_id(plot.new_proj)
|
||||
bec_dispatcher.connect_dap_slot(plot.on_dap_update, "px_dap_worker")
|
||||
plot.roi_signal.connect(lambda x: print(f"signal from ROI {x}"))
|
||||
plot.roi_signal.connect(lambda x: bec_dispatcher.getStuff(x))
|
||||
plot.show()
|
||||
# client.callbacks.register("scan_segment", plot, sync=False)
|
||||
app.exec_()
|
||||
155
bec_widgets/line_plot.ui
Normal file
155
bec_widgets/line_plot.ui
Normal file
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>845</width>
|
||||
<height>635</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Line Plot</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QSplitter" name="splitter_plot">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<widget class="GraphicsLayoutWidget" name="glw_plot"/>
|
||||
<widget class="GraphicsLayoutWidget" name="glw_image"/>
|
||||
</widget>
|
||||
<widget class="QWidget" name="">
|
||||
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,1,1,15">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_generate">
|
||||
<property name="text">
|
||||
<string>Generate 1D and 2D data without stream</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>1st angle of azimutal segment (deg)</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>360.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.250000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f1amp</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f2amp</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f2 phase</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="spinBox_precision">
|
||||
<property name="value">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="cursor_table">
|
||||
<property name="textElideMode">
|
||||
<enum>Qt::ElideMiddle</enum>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Display</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
360
bec_widgets/line_plot_legacy.py
Normal file
360
bec_widgets/line_plot_legacy.py
Normal file
@@ -0,0 +1,360 @@
|
||||
import os
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtWidgets import QTableWidgetItem, QCheckBox
|
||||
|
||||
from bec_lib import BECClient
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets, uic
|
||||
from pyqtgraph.Qt.QtCore import pyqtSignal
|
||||
|
||||
|
||||
class BasicPlot(QtWidgets.QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
roi_signal = pyqtSignal(tuple)
|
||||
|
||||
def __init__(self, name="", y_value_list=["gauss_bpm"]) -> None:
|
||||
"""
|
||||
Basic plot widget for displaying scan data.
|
||||
|
||||
Args:
|
||||
name (str, optional): Name of the plot. Defaults to "".
|
||||
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
|
||||
"""
|
||||
|
||||
super(BasicPlot, self).__init__()
|
||||
# Set style for pyqtgraph plots
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
|
||||
|
||||
# Set splitter distribution of widgets
|
||||
self.splitter.setSizes([5, 2])
|
||||
|
||||
self._idle_time = 100
|
||||
self.title = ""
|
||||
self.label_bottom = ""
|
||||
self.label_left = ""
|
||||
|
||||
self.scan_motors = []
|
||||
self.y_value_list = y_value_list
|
||||
self.previous_y_value_list = None
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
self.curves = []
|
||||
self.pens = []
|
||||
self.brushs = []
|
||||
|
||||
self.plotter_scan_id = None
|
||||
|
||||
# TODO to be moved to utils function
|
||||
plotstyles = {
|
||||
"symbol": "o",
|
||||
"symbolSize": 10,
|
||||
}
|
||||
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
|
||||
color_list = BasicPlot.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
|
||||
|
||||
# setup plots - GraphicsLayoutWidget
|
||||
# LabelItem
|
||||
self.label = pg.LabelItem(justify="center")
|
||||
self.glw.addItem(self.label)
|
||||
self.label.setText("test label")
|
||||
|
||||
# PlotItem - main window
|
||||
self.glw.nextRow()
|
||||
self.plot = pg.PlotItem()
|
||||
self.glw.addItem(self.plot)
|
||||
self.plot.addLegend()
|
||||
|
||||
# PlotItem - ROI window - disabled for now #TODO add 2D plot for ROI and 1D plot for mouse click
|
||||
# self.glw.nextRow()
|
||||
# self.plot_roi = pg.PlotItem()
|
||||
# self.glw.addItem(self.plot_roi)
|
||||
|
||||
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
|
||||
self.roi_selector = pg.LinearRegionItem([-1, 1])
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
brush = mkBrush(color=color_list[ii])
|
||||
curve = pg.PlotDataItem(
|
||||
**plotstyles, symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value
|
||||
)
|
||||
self.plot.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
self.pens.append(pen)
|
||||
self.brushs.append(brush)
|
||||
|
||||
self.crosshair_v = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.crosshair_h = pg.InfiniteLine(angle=0, movable=False)
|
||||
self.plot.addItem(self.crosshair_v, ignoreBounds=True)
|
||||
self.plot.addItem(self.crosshair_h, ignoreBounds=True)
|
||||
|
||||
# Add textItems
|
||||
self.add_text_items()
|
||||
|
||||
# Manage signals
|
||||
self.proxy = pg.SignalProxy(
|
||||
self.plot.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
|
||||
)
|
||||
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
|
||||
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
|
||||
self.pushButton_debug.clicked.connect(self.debug)
|
||||
|
||||
def debug(self):
|
||||
"""
|
||||
Debug button just for quick testing
|
||||
"""
|
||||
|
||||
def get_roi_region(self):
|
||||
"""For testing purpose now, get roi region and print it to self.label as tuple"""
|
||||
region = self.roi_selector.getRegion()
|
||||
self.label.setText(f"x = {region[0]:.4f}, y ={region[1]:.4f}")
|
||||
self.roi_signal.emit(region)
|
||||
|
||||
def add_text_items(self): # TODO probably can be removed
|
||||
"""Add text items to the plot"""
|
||||
|
||||
# self.mouse_box_data.setText("Mouse cursor")
|
||||
# TODO Via StyleSheet, one may set the color of the full QLabel
|
||||
# self.mouse_box_data.setStyleSheet(f"QLabel {{color : rgba{self.pens[0].color().getRgb()}}}")
|
||||
|
||||
def mouse_moved(self, event: tuple) -> None:
|
||||
"""
|
||||
Update the mouse table with the current mouse position and the corresponding data.
|
||||
|
||||
Args:
|
||||
event (tuple): Mouse event containing the position of the mouse cursor.
|
||||
The position is stored in first entry as horizontal, vertical pixel.
|
||||
"""
|
||||
pos = event[0]
|
||||
if not self.plot.sceneBoundingRect().contains(pos):
|
||||
return
|
||||
mousePoint = self.plot.vb.mapSceneToView(pos)
|
||||
self.crosshair_v.setPos(mousePoint.x())
|
||||
self.crosshair_h.setPos(mousePoint.y())
|
||||
if not self.plotter_data_x:
|
||||
return
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
closest_point = self.closest_x_y_value(
|
||||
mousePoint.x(), self.plotter_data_x, self.plotter_data_y[ii]
|
||||
)
|
||||
# TODO fix text wobble in plot, see plot when it crosses 0
|
||||
x_data = f"{closest_point[0]:.{self.precision}f}"
|
||||
y_data = f"{closest_point[1]:.{self.precision}f}"
|
||||
|
||||
# Write coordinate to QTable
|
||||
self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
|
||||
self.mouse_table.setItem(ii, 2, QTableWidgetItem(str(x_data)))
|
||||
self.mouse_table.setItem(ii, 3, QTableWidgetItem(str(y_data)))
|
||||
|
||||
self.mouse_table.resizeColumnsToContents()
|
||||
|
||||
def closest_x_y_value(self, input_value, list_x, list_y) -> tuple:
|
||||
"""
|
||||
Find the closest x and y value to the input value.
|
||||
|
||||
Args:
|
||||
input_value (float): Input value
|
||||
list_x (list): List of x values
|
||||
list_y (list): List of y values
|
||||
|
||||
Returns:
|
||||
tuple: Closest x and y value
|
||||
"""
|
||||
arr = np.asarray(list_x)
|
||||
i = (np.abs(arr - input_value)).argmin()
|
||||
return list_x[i], list_y[i]
|
||||
|
||||
def update(self):
|
||||
"""Update the plot with the new data."""
|
||||
# check if roi selector is in the plot
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
# check if QTable was initialised and if list of devices was changed
|
||||
if self.y_value_list != self.previous_y_value_list:
|
||||
self.setup_cursor_table()
|
||||
self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
|
||||
|
||||
if len(self.plotter_data_x) <= 1:
|
||||
return
|
||||
self.plot.setLabel("bottom", self.label_bottom)
|
||||
self.plot.setLabel("left", self.label_left)
|
||||
for ii in range(len(self.y_value_list)):
|
||||
self.curves[ii].setData(self.plotter_data_x, self.plotter_data_y[ii])
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, data: dict, metadata: dict) -> None:
|
||||
"""Update function that is called during the scan callback. To avoid
|
||||
too many renderings, the GUI is only processing events every <_idle_time> ms.
|
||||
|
||||
Args:
|
||||
data (dict): Dictionary containing a new scan segment
|
||||
metadata (dict): Scan metadata
|
||||
|
||||
"""
|
||||
if metadata["scanID"] != self.plotter_scan_id:
|
||||
self.plotter_scan_id = metadata["scanID"]
|
||||
self._reset_plot_data()
|
||||
|
||||
self.title = f"Scan {metadata['scan_number']}"
|
||||
|
||||
self.scan_motors = scan_motors = metadata.get("scan_report_devices")
|
||||
# client = BECClient()
|
||||
remove_y_value_index = [
|
||||
index
|
||||
for index, y_value in enumerate(self.y_value_list)
|
||||
if y_value not in client.device_manager.devices
|
||||
]
|
||||
if remove_y_value_index:
|
||||
for ii in sorted(remove_y_value_index, reverse=True):
|
||||
# TODO Use bec warning message??? to be discussed with Klaus
|
||||
warnings.warn(
|
||||
f"Warning: no matching signal for {self.y_value_list[ii]} found in list of devices. Removing from plot."
|
||||
)
|
||||
self.remove_curve_by_name(self.plot, self.y_value_list[ii])
|
||||
self.y_value_list.pop(ii)
|
||||
|
||||
self.precision = client.device_manager.devices[scan_motors[0]]._info["describe"][
|
||||
scan_motors[0]
|
||||
]["precision"]
|
||||
# TODO after update of bec_lib, this will be new way to access data
|
||||
# self.precision = client.device_manager.devices[scan_motors[0]].precision
|
||||
|
||||
x = data["data"][scan_motors[0]][scan_motors[0]]["value"]
|
||||
self.plotter_data_x.append(x)
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
y = data["data"][y_value][y_value]["value"]
|
||||
self.plotter_data_y[ii].append(y)
|
||||
self.label_bottom = scan_motors[0]
|
||||
self.label_left = f"{', '.join(self.y_value_list)}"
|
||||
|
||||
# print(f'metadata scan N{metadata["scan_number"]}') #TODO put as label on top of plot
|
||||
# print(f'Data point = {data["point_id"]}') #TODO can be used for progress bar
|
||||
|
||||
if len(self.plotter_data_x) <= 1:
|
||||
return
|
||||
self.update_signal.emit()
|
||||
|
||||
def _reset_plot_data(self):
|
||||
"""Reset the plot data."""
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
for ii in range(len(self.y_value_list)):
|
||||
self.curves[ii].setData([], [])
|
||||
self.plotter_data_y.append([])
|
||||
|
||||
def setup_cursor_table(self):
|
||||
"""QTable formatting according to N of devices displayed in plot."""
|
||||
|
||||
# Init number of rows in table according to n of devices
|
||||
self.mouse_table.setRowCount(len(self.y_value_list))
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setChecked(True)
|
||||
# TODO just for testing, will be replaced by removing/adding curve
|
||||
checkbox.stateChanged.connect(lambda: print("status Changed"))
|
||||
# checkbox.stateChanged.connect(lambda: self.remove_curve_by_name(plot=self.plot, checkbox=checkbox, name=y_value))
|
||||
self.mouse_table.setCellWidget(ii, 0, checkbox)
|
||||
self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
|
||||
|
||||
self.mouse_table.resizeColumnsToContents()
|
||||
|
||||
@staticmethod
|
||||
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
|
||||
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
|
||||
"""Removes a curve from the given plot by the specified name.
|
||||
|
||||
Args:
|
||||
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
|
||||
name (str): The name of the curve to remove.
|
||||
"""
|
||||
# if checkbox.isChecked():
|
||||
for item in plot.items:
|
||||
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
|
||||
plot.removeItem(item)
|
||||
return
|
||||
|
||||
# else:
|
||||
# return
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = BasicPlot.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
from bec_widgets import ctrl_c
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--signals",
|
||||
help="specify recorded signals",
|
||||
nargs="+",
|
||||
default=["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
|
||||
)
|
||||
value = parser.parse_args()
|
||||
print(f"Plotting signals for: {', '.join(value.signals)}")
|
||||
client = bec_dispatcher.client
|
||||
# client.start()
|
||||
app = QtWidgets.QApplication([])
|
||||
ctrl_c.setup(app)
|
||||
plot = BasicPlot(y_value_list=value.signals)
|
||||
bec_dispatcher.connect(plot)
|
||||
plot.show()
|
||||
# client.callbacks.register("scan_segment", plot, sync=False)
|
||||
app.exec_()
|
||||
2
bec_widgets/qt_utils/__init__.py
Normal file
2
bec_widgets/qt_utils/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .crosshair import Crosshair
|
||||
from .colors import Colors
|
||||
50
bec_widgets/qt_utils/colors.py
Normal file
50
bec_widgets/qt_utils/colors.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import mkColor
|
||||
|
||||
|
||||
class Colors:
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = Colors.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
227
bec_widgets/qt_utils/crosshair.py
Normal file
227
bec_widgets/qt_utils/crosshair.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
|
||||
|
||||
class Crosshair(QObject):
|
||||
# Signal for 1D plot
|
||||
coordinatesChanged1D = pyqtSignal(float, list)
|
||||
coordinatesClicked1D = pyqtSignal(float, list)
|
||||
# Signal for 2D plot
|
||||
coordinatesChanged2D = pyqtSignal(float, float)
|
||||
coordinatesClicked2D = pyqtSignal(float, float)
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = None, parent=None):
|
||||
"""
|
||||
Crosshair for 1D and 2D plots.
|
||||
Args:
|
||||
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
|
||||
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
|
||||
parent (QObject, optional): Parent object for the QObject. Defaults to None.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.is_log_y = None
|
||||
self.is_log_x = None
|
||||
self.plot_item = plot_item
|
||||
self.precision = precision
|
||||
self.v_line = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.h_line = pg.InfiniteLine(angle=0, movable=False)
|
||||
self.plot_item.addItem(self.v_line, ignoreBounds=True)
|
||||
self.plot_item.addItem(self.h_line, ignoreBounds=True)
|
||||
self.proxy = pg.SignalProxy(
|
||||
self.plot_item.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
|
||||
)
|
||||
self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked)
|
||||
|
||||
# Initialize markers
|
||||
self.marker_moved_1d = []
|
||||
self.marker_clicked_1d = []
|
||||
self.marker_2d = None
|
||||
self.update_markers()
|
||||
|
||||
def update_markers(self):
|
||||
"""Update the markers for the crosshair, creating new ones if necessary."""
|
||||
|
||||
# Clear existing markers
|
||||
for marker in self.marker_moved_1d + self.marker_clicked_1d:
|
||||
self.plot_item.removeItem(marker)
|
||||
if self.marker_2d:
|
||||
self.plot_item.removeItem(self.marker_2d)
|
||||
|
||||
# Create new markers
|
||||
self.marker_moved_1d = []
|
||||
self.marker_clicked_1d = []
|
||||
self.marker_2d = None
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem): # 1D plot
|
||||
pen = item.opts["pen"]
|
||||
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
|
||||
marker_moved = pg.ScatterPlotItem(
|
||||
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
|
||||
)
|
||||
marker_clicked = pg.ScatterPlotItem(
|
||||
size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color)
|
||||
)
|
||||
self.marker_moved_1d.append(marker_moved)
|
||||
self.plot_item.addItem(marker_moved)
|
||||
# Create glowing effect markers for clicked events
|
||||
marker_clicked_list = []
|
||||
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
|
||||
marker_clicked = pg.ScatterPlotItem(
|
||||
size=size,
|
||||
pen=pg.mkPen(None),
|
||||
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
|
||||
)
|
||||
marker_clicked_list.append(marker_clicked)
|
||||
self.plot_item.addItem(marker_clicked)
|
||||
|
||||
self.marker_clicked_1d.append(marker_clicked_list)
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
self.marker_2d = pg.ROI(
|
||||
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
self.plot_item.addItem(self.marker_2d)
|
||||
|
||||
def snap_to_data(self, x, y) -> tuple:
|
||||
"""
|
||||
Finds the nearest data points to the given x and y coordinates.
|
||||
|
||||
Args:
|
||||
x: The x-coordinate
|
||||
y: The y-coordinate
|
||||
|
||||
Returns:
|
||||
tuple: The nearest x and y values
|
||||
"""
|
||||
y_values_1d = []
|
||||
x_values_1d = []
|
||||
image_2d = None
|
||||
|
||||
# Iterate through items in the plot
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem): # 1D plot
|
||||
x_data, y_data = item.xData, item.yData
|
||||
if x_data is not None and y_data is not None:
|
||||
if self.is_log_x:
|
||||
min_x_data = np.min(x_data[x_data > 0])
|
||||
else:
|
||||
min_x_data = np.min(x_data)
|
||||
max_x_data = np.max(x_data)
|
||||
if x < min_x_data or x > max_x_data:
|
||||
return None, None
|
||||
closest_x, closest_y = self.closest_x_y_value(x, x_data, y_data)
|
||||
y_values_1d.append(closest_y)
|
||||
x_values_1d.append(closest_x)
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
image_2d = item.image
|
||||
|
||||
# Handle 1D plot
|
||||
if y_values_1d:
|
||||
if all(v is None for v in x_values_1d) or all(v is None for v in y_values_1d):
|
||||
return None, None
|
||||
closest_x = min(x_values_1d, key=lambda xi: abs(xi - x)) # Snap x to closest data point
|
||||
return closest_x, y_values_1d
|
||||
|
||||
# Handle 2D plot
|
||||
if image_2d is not None:
|
||||
x_idx = int(np.clip(x, 0, image_2d.shape[0] - 1))
|
||||
y_idx = int(np.clip(y, 0, image_2d.shape[1] - 1))
|
||||
return x_idx, y_idx
|
||||
|
||||
return None, None
|
||||
|
||||
def closest_x_y_value(self, input_value: float, list_x: list, list_y: list) -> tuple:
|
||||
"""
|
||||
Find the closest x and y value to the input value.
|
||||
|
||||
Args:
|
||||
input_value (float): Input value
|
||||
list_x (list): List of x values
|
||||
list_y (list): List of y values
|
||||
|
||||
Returns:
|
||||
tuple: Closest x and y value
|
||||
"""
|
||||
arr = np.asarray(list_x)
|
||||
i = (np.abs(arr - input_value)).argmin()
|
||||
return list_x[i], list_y[i]
|
||||
|
||||
def mouse_moved(self, event):
|
||||
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
|
||||
Args:
|
||||
event: The mouse moved event
|
||||
"""
|
||||
self.check_log()
|
||||
pos = event[0]
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(pos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(pos)
|
||||
self.v_line.setPos(mouse_point.x())
|
||||
self.h_line.setPos(mouse_point.y())
|
||||
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
if self.is_log_x:
|
||||
x = 10**x
|
||||
if self.is_log_y:
|
||||
y = 10**y
|
||||
x, y_values = self.snap_to_data(x, y)
|
||||
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
self.coordinatesChanged1D.emit(
|
||||
round(x, self.precision),
|
||||
[round(y_val, self.precision) for y_val in y_values],
|
||||
)
|
||||
for i, y_val in enumerate(y_values):
|
||||
self.marker_moved_1d[i].setData(
|
||||
[x if not self.is_log_x else np.log10(x)],
|
||||
[y_val if not self.is_log_y else np.log10(y_val)],
|
||||
)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
self.coordinatesChanged2D.emit(x, y_values)
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
|
||||
Args:
|
||||
event: The mouse clicked event
|
||||
"""
|
||||
self.check_log()
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
|
||||
if self.is_log_x:
|
||||
x = 10**x
|
||||
if self.is_log_y:
|
||||
y = 10**y
|
||||
x, y_values = self.snap_to_data(x, y)
|
||||
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
self.coordinatesClicked1D.emit(
|
||||
round(x, self.precision),
|
||||
[round(y_val, self.precision) for y_val in y_values],
|
||||
)
|
||||
for i, y_val in enumerate(y_values):
|
||||
for marker in self.marker_clicked_1d[i]:
|
||||
marker.setData(
|
||||
[x if not self.is_log_x else np.log10(x)],
|
||||
[y_val if not self.is_log_y else np.log10(y_val)],
|
||||
)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
self.coordinatesClicked2D.emit(x, y_values)
|
||||
self.marker_2d.setPos([x, y_values])
|
||||
|
||||
def check_log(self):
|
||||
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
|
||||
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
|
||||
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
|
||||
0
bec_widgets/qtdesigner_plugins/__init__.py
Normal file
0
bec_widgets/qtdesigner_plugins/__init__.py
Normal file
56
bec_widgets/qtdesigner_plugins/scan2d_plot_plugin.py
Normal file
56
bec_widgets/qtdesigner_plugins/scan2d_plot_plugin.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from PyQt5.QtGui import QIcon
|
||||
|
||||
from bec_widgets.scan2d_plot import BECScanPlot2D
|
||||
|
||||
|
||||
class BECScanPlot2DPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, formEditor):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
def createWidget(self, parent):
|
||||
return BECScanPlot2D(parent)
|
||||
|
||||
def name(self):
|
||||
return "BECScanPlot2D"
|
||||
|
||||
def group(self):
|
||||
return "BEC widgets"
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def toolTip(self):
|
||||
return "BEC plot for 2D scans"
|
||||
|
||||
def whatsThis(self):
|
||||
return "BEC plot for 2D scans"
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def domXml(self):
|
||||
return (
|
||||
'<widget class="BECScanPlot2D" name="BECScanPlot2D">\n'
|
||||
' <property name="toolTip" >\n'
|
||||
" <string>BEC plot for 2D scans</string>\n"
|
||||
" </property>\n"
|
||||
' <property name="whatsThis" >\n'
|
||||
" <string>BEC plot for 2D scans in Python using PyQt.</string>\n"
|
||||
" </property>\n"
|
||||
"</widget>\n"
|
||||
)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan2d_plot"
|
||||
@@ -1,11 +1,12 @@
|
||||
from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from PyQt5.QtGui import QIcon
|
||||
from scan_plot import BECScanPlot
|
||||
|
||||
from bec_widgets.scan_plot import BECScanPlot
|
||||
|
||||
|
||||
class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
def __init__(self, parent=None):
|
||||
super(BECScanPlotPlugin, self).__init__(parent)
|
||||
super().__init__(parent)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
Add/modify the path in the following variable to make the plugin avaiable in Qt Designer:
|
||||
```
|
||||
$ export PYQTDESIGNERPATH=/<path to repo>/bec/bec_qtplugin:$PYQTDESIGNERPATH
|
||||
$ export PYQTDESIGNERPATH=/<path to repo>/bec_widgets/qtdesigner_plugins
|
||||
```
|
||||
|
||||
It can be done when activating a conda environment (run with the corresponding env already activated):
|
||||
```
|
||||
$ conda env config vars set PYQTDESIGNERPATH=/<path to repo>/bec_widgets/qtdesigner_plugins
|
||||
```
|
||||
|
||||
All the available conda-forge `pyqt >=5.15` packages don't seem to support loading Qt Designer
|
||||
python plugins at the time of writing. Use `pyqt =5.12` to solve the issue for now.
|
||||
|
||||
140
bec_widgets/scan2d_plot.py
Normal file
140
bec_widgets/scan2d_plot.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.core.logger import bec_logger
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSlot
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
pg.setConfigOptions(background="w", foreground="k", antialias=True)
|
||||
|
||||
|
||||
class BECScanPlot2D(pg.GraphicsView):
|
||||
def __init__(self, parent=None, background="default"):
|
||||
super().__init__(parent, background)
|
||||
bec_dispatcher.connect(self)
|
||||
|
||||
self._x_channel = ""
|
||||
self._y_channel = ""
|
||||
self._z_channel = ""
|
||||
|
||||
self._xpos = []
|
||||
self._ypos = []
|
||||
|
||||
self._x_ind = None
|
||||
self._y_ind = None
|
||||
|
||||
self.plot_item = pg.PlotItem()
|
||||
self.setCentralItem(self.plot_item)
|
||||
self.plot_item.setAspectLocked(True)
|
||||
|
||||
self.imageItem = pg.ImageItem()
|
||||
self.plot_item.addItem(self.imageItem)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_new_scan(self, _scan_segment, metadata):
|
||||
# TODO: Do we reset in case of a scan type change?
|
||||
self.imageItem.clear()
|
||||
|
||||
# TODO: better to check the number of coordinates in metadata["positions"]?
|
||||
if metadata["scan_name"] != "grid_scan":
|
||||
return
|
||||
|
||||
positions = [sorted(set(pos)) for pos in zip(*metadata["positions"])]
|
||||
|
||||
motors = metadata["scan_motors"]
|
||||
if self.x_channel and self.y_channel:
|
||||
self._x_ind = motors.index(self.x_channel) if self.x_channel in motors else None
|
||||
self._y_ind = motors.index(self.y_channel) if self.y_channel in motors else None
|
||||
elif not self.x_channel and not self.y_channel:
|
||||
# Plot the first and second motors along x and y axes respectively
|
||||
self._x_ind = 0
|
||||
self._y_ind = 1
|
||||
else:
|
||||
logger.warning(
|
||||
f"X and Y channels should be either both empty or both set in {self.objectName()}"
|
||||
)
|
||||
|
||||
if self._x_ind is None or self._y_ind is None:
|
||||
return
|
||||
|
||||
xpos = positions[self._x_ind]
|
||||
ypos = positions[self._y_ind]
|
||||
|
||||
self._xpos = xpos
|
||||
self._ypos = ypos
|
||||
|
||||
self.imageItem.setImage(np.zeros(shape=(len(xpos), len(ypos))))
|
||||
|
||||
w = max(xpos) - min(xpos)
|
||||
h = max(ypos) - min(ypos)
|
||||
w_pix = w / (len(xpos) - 1)
|
||||
h_pix = h / (len(ypos) - 1)
|
||||
self.imageItem.setRect(min(xpos) - w_pix / 2, min(ypos) - h_pix / 2, w + w_pix, h + h_pix)
|
||||
|
||||
self.plot_item.setLabel("bottom", motors[self._x_ind])
|
||||
self.plot_item.setLabel("left", motors[self._y_ind])
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, scan_segment, metadata):
|
||||
if not self.z_channel or metadata["scan_name"] != "grid_scan":
|
||||
return
|
||||
|
||||
if self._x_ind is None or self._y_ind is None:
|
||||
return
|
||||
|
||||
point_coord = metadata["positions"][scan_segment["point_id"]]
|
||||
|
||||
x_coord_ind = self._xpos.index(point_coord[self._x_ind])
|
||||
y_coord_ind = self._ypos.index(point_coord[self._y_ind])
|
||||
|
||||
data = scan_segment["data"]
|
||||
z_new = data[self.z_channel][self.z_channel]["value"]
|
||||
|
||||
image = self.imageItem.image
|
||||
image[x_coord_ind, y_coord_ind] = z_new
|
||||
self.imageItem.setImage()
|
||||
|
||||
@pyqtProperty(str)
|
||||
def x_channel(self):
|
||||
return self._x_channel
|
||||
|
||||
@x_channel.setter
|
||||
def x_channel(self, new_val):
|
||||
self._x_channel = new_val
|
||||
self.plot_item.setLabel("bottom", new_val)
|
||||
|
||||
@pyqtProperty(str)
|
||||
def y_channel(self):
|
||||
return self._y_channel
|
||||
|
||||
@y_channel.setter
|
||||
def y_channel(self, new_val):
|
||||
self._y_channel = new_val
|
||||
self.plot_item.setLabel("left", new_val)
|
||||
|
||||
@pyqtProperty(str)
|
||||
def z_channel(self):
|
||||
return self._z_channel
|
||||
|
||||
@z_channel.setter
|
||||
def z_channel(self, new_val):
|
||||
self._z_channel = new_val
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
plot = BECScanPlot2D()
|
||||
# If x_channel and y_channel are both omitted, they will be inferred from each running grid scan
|
||||
plot.z_channel = "bpm3y"
|
||||
|
||||
plot.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,9 +1,10 @@
|
||||
import itertools
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.core.logger import bec_logger
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSlot
|
||||
|
||||
from bec_lib.core.logger import bec_logger
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -12,9 +13,13 @@ pg.setConfigOptions(background="w", foreground="k", antialias=True)
|
||||
COLORS = ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a"]
|
||||
|
||||
|
||||
class BECScanPlot(pg.PlotWidget):
|
||||
class BECScanPlot(pg.GraphicsView):
|
||||
def __init__(self, parent=None, background="default"):
|
||||
super().__init__(parent, background)
|
||||
bec_dispatcher.connect(self)
|
||||
|
||||
self.view = pg.PlotItem()
|
||||
self.setCentralItem(self.view)
|
||||
|
||||
self._x_channel = ""
|
||||
self._y_channel_list = []
|
||||
@@ -22,36 +27,18 @@ class BECScanPlot(pg.PlotWidget):
|
||||
self.scan_curves = {}
|
||||
self.dap_curves = {}
|
||||
|
||||
def initialize(self):
|
||||
plot_item = self.getPlotItem()
|
||||
plot_item.addLegend()
|
||||
colors = itertools.cycle(COLORS)
|
||||
|
||||
for y_chan in self.y_channel_list:
|
||||
if y_chan.startswith("dap."):
|
||||
y_chan = y_chan.partition("dap.")[-1]
|
||||
curves = self.dap_curves
|
||||
else:
|
||||
curves = self.scan_curves
|
||||
|
||||
curves[y_chan] = plot_item.plot(
|
||||
x=[], y=[], pen=pg.mkPen(color=next(colors), width=2), name=y_chan
|
||||
)
|
||||
|
||||
plot_item.setLabel("bottom", self._x_channel)
|
||||
if len(self.scan_curves) == 1:
|
||||
plot_item.setLabel("left", next(iter(self.scan_curves)))
|
||||
|
||||
@pyqtSlot()
|
||||
def clearData(self):
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_new_scan(self, _scan_segment, _metadata):
|
||||
for plot_curve in {**self.scan_curves, **self.dap_curves}.values():
|
||||
plot_curve.setData(x=[], y=[])
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def redraw_scan(self, data):
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, scan_segment, _metadata):
|
||||
if not self.x_channel:
|
||||
return
|
||||
|
||||
data = scan_segment["data"]
|
||||
|
||||
if self.x_channel not in data:
|
||||
logger.warning(f"Unknown channel `{self.x_channel}` for X data in {self.objectName()}")
|
||||
return
|
||||
@@ -74,8 +61,8 @@ class BECScanPlot(pg.PlotWidget):
|
||||
|
||||
plot_curve.setData(x=[*x, x_new], y=[*y, y_new])
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def redraw_dap(self, data):
|
||||
@pyqtSlot(dict, dict)
|
||||
def redraw_dap(self, data, _metadata):
|
||||
for chan, plot_curve in self.dap_curves.items():
|
||||
if not chan:
|
||||
continue
|
||||
@@ -95,8 +82,35 @@ class BECScanPlot(pg.PlotWidget):
|
||||
|
||||
@y_channel_list.setter
|
||||
def y_channel_list(self, new_list):
|
||||
# TODO: do we want to care about dap/not dap here?
|
||||
chan_removed = [chan for chan in self._y_channel_list if chan not in new_list]
|
||||
if chan_removed and chan_removed[0].startswith("dap."):
|
||||
chan_removed = chan_removed[0].partition("dap.")[-1]
|
||||
bec_dispatcher.disconnect_dap_slot(self.redraw_dap, chan_removed)
|
||||
|
||||
self._y_channel_list = new_list
|
||||
|
||||
# Prepare plot for a potentially different list of y channels
|
||||
self.view.clear()
|
||||
|
||||
self.view.addLegend()
|
||||
colors = itertools.cycle(COLORS)
|
||||
|
||||
for y_chan in new_list:
|
||||
if y_chan.startswith("dap."):
|
||||
y_chan = y_chan.partition("dap.")[-1]
|
||||
curves = self.dap_curves
|
||||
bec_dispatcher.connect_dap_slot(self.redraw_dap, y_chan)
|
||||
else:
|
||||
curves = self.scan_curves
|
||||
|
||||
curves[y_chan] = self.view.plot(
|
||||
x=[], y=[], pen=pg.mkPen(color=next(colors), width=2), name=y_chan
|
||||
)
|
||||
|
||||
if len(new_list) == 1:
|
||||
self.view.setLabel("left", new_list[0])
|
||||
|
||||
@pyqtProperty(str)
|
||||
def x_channel(self):
|
||||
return self._x_channel
|
||||
@@ -104,3 +118,20 @@ class BECScanPlot(pg.PlotWidget):
|
||||
@x_channel.setter
|
||||
def x_channel(self, new_val):
|
||||
self._x_channel = new_val
|
||||
self.view.setLabel("bottom", new_val)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
plot = BECScanPlot()
|
||||
plot.x_channel = "samx"
|
||||
plot.y_channel_list = ["bpm3y", "bpm6y"]
|
||||
|
||||
plot.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
0
bec_widgets/widgets/__init__.py
Normal file
0
bec_widgets/widgets/__init__.py
Normal file
@@ -1,11 +1,11 @@
|
||||
[metadata]
|
||||
name = bec_lib
|
||||
description = BEC library
|
||||
name = bec_widgets
|
||||
description = BEC Widgets
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
url = https://gitlab.psi.ch/bec/bec
|
||||
url = https://gitlab.psi.ch/bec/bec-widgets
|
||||
project_urls =
|
||||
Bug Tracker = https://gitlab.psi.ch/bec/bec/issues
|
||||
Bug Tracker = https://gitlab.psi.ch/bec/bec-widgets/issues
|
||||
classifiers =
|
||||
Programming Language :: Python :: 3
|
||||
Development Status :: 3 - Alpha
|
||||
|
||||
9
setup.py
9
setup.py
@@ -1,13 +1,10 @@
|
||||
from setuptools import setup
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.11.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
setup(
|
||||
install_requires=[
|
||||
"pyqt5",
|
||||
"pyqtgraph",
|
||||
],
|
||||
extras_require={"dev": ["pytest", "pytest-random-order", "coverage", "pytest-qt"]},
|
||||
install_requires=["pyqt5", "pyqtgraph", "bec_lib"],
|
||||
extras_require={"dev": ["pytest", "pytest-random-order", "coverage", "pytest-qt", "black"]},
|
||||
version=__version__,
|
||||
)
|
||||
|
||||
152
tests/test_basic_plot.py
Normal file
152
tests/test_basic_plot.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
from pytestqt import qtbot
|
||||
|
||||
from bec_widgets import basic_plot
|
||||
|
||||
|
||||
def test_basic_plot_emits_no_signal(qtbot):
|
||||
"""Test LinePlot emits no signal when only one data entry is present."""
|
||||
|
||||
y_value_list = ["y1", "y2"]
|
||||
plot = basic_plot.BasicPlot(y_value_list=y_value_list)
|
||||
data = {
|
||||
"data": {
|
||||
"x": {"x": {"value": 1}},
|
||||
"y1": {"y1": {"value": 1}},
|
||||
"y2": {"y2": {"value": 3}},
|
||||
}
|
||||
}
|
||||
metadata = {"scanID": "test", "scan_number": 1, "scan_report_devices": ["x"]}
|
||||
with mock.patch("bec_widgets.basic_plot.client") as mock_client:
|
||||
with mock.patch.object(plot, "update_signal") as mock_update_signal:
|
||||
plot.on_scan_segment(data=data, metadata=metadata)
|
||||
mock_update_signal.emit.assert_not_called()
|
||||
|
||||
|
||||
def test_basic_plot_emits_signal(qtbot):
|
||||
"""Test LinePlot emits signal."""
|
||||
|
||||
y_value_list = ["y1", "y2"]
|
||||
plot = basic_plot.BasicPlot(y_value_list=y_value_list)
|
||||
data = {
|
||||
"data": {
|
||||
"x": {"x": {"value": 1}},
|
||||
"y1": {"y1": {"value": 1}},
|
||||
"y2": {"y2": {"value": 3}},
|
||||
}
|
||||
}
|
||||
plotter_data_y = [[1, 1], [3, 3]]
|
||||
metadata = {"scanID": "test", "scan_number": 1, "scan_report_devices": ["x"]}
|
||||
with mock.patch("bec_widgets.basic_plot.client") as mock_client:
|
||||
# mock_client.device_manager.devices.keys.return_value = ["y1"]
|
||||
with mock.patch.object(plot, "update_signal") as mock_update_signal:
|
||||
mock_update_signal.emit()
|
||||
plot.on_scan_segment(data=data, metadata=metadata)
|
||||
plot.on_scan_segment(data=data, metadata=metadata)
|
||||
mock_update_signal.emit.assert_called()
|
||||
# TODO allow mock_client to create return values for device_manager_devices
|
||||
# assert plot.plotter_data_y == plotter_data_y
|
||||
|
||||
|
||||
def test_basic_plot_raise_warning_wrong_signal_request(qtbot):
|
||||
"""Test LinePlot raises warning and skips signal when entry not present in data."""
|
||||
|
||||
y_value_list = ["y1", "y22"]
|
||||
plot = basic_plot.BasicPlot(y_value_list=y_value_list)
|
||||
data = {
|
||||
"data": {
|
||||
"x": {"x": {"value": [1, 2, 3, 4, 5]}},
|
||||
"y1": {"y1": {"value": [1, 2, 3, 4, 5]}},
|
||||
"y2": {"y2": {"value": [1, 2, 3, 4, 5]}},
|
||||
}
|
||||
}
|
||||
metadata = {"scanID": "test", "scan_number": 1, "scan_report_devices": ["x"]}
|
||||
with mock.patch("bec_widgets.basic_plot.client") as mock_client:
|
||||
# TODO fix mock_client
|
||||
mock_dict = {"y1": [1, 2]}
|
||||
mock_client.device_manager.devices.__contains__.side_effect = mock_dict.__contains__
|
||||
|
||||
# = {"y1": [1, 2]}
|
||||
with mock.patch.object(plot, "update_signal") as mock_update_signal:
|
||||
mock_update_signal.emit()
|
||||
plot.on_scan_segment(data=data, metadata=metadata)
|
||||
assert plot.y_value_list == ["y1"]
|
||||
|
||||
|
||||
# def test_basic_plot_update(qtbot):
|
||||
# """Test LinePlot update."""
|
||||
|
||||
# y_value_list = ["y1", "y2"]
|
||||
# plot = basic_plot.BasicPlot(y_value_list=y_value_list)
|
||||
# plot.label_bottom = "x"
|
||||
# plot.label_left = f"{', '.join(y_value_list)}"
|
||||
# plot.plotter_data_x = [1, 2, 3, 4, 5]
|
||||
# plot.plotter_data_y = [[1, 2, 3, 4, 5], [3, 4, 5, 6, 7]]
|
||||
# plot.update()
|
||||
|
||||
# assert all(plot.curves[0].getData()[0] == np.array([1, 2, 3, 4, 5]))
|
||||
# assert all(plot.curves[0].getData()[1] == np.array([1, 2, 3, 4, 5]))
|
||||
# assert all(plot.curves[1].getData()[1] == np.array([3, 4, 5, 6, 7]))
|
||||
|
||||
|
||||
# # TODO Outputting the wrong data, e.g. motor is not in list of devices
|
||||
# def test_basic_plot_update(qtbot):
|
||||
# """Test LinePlot update."""
|
||||
|
||||
# y_value_list = ["y1", "y2"]
|
||||
# plot = basic_plot.BasicPlot(y_value_list=y_value_list)
|
||||
# plot.label_bottom = "x"
|
||||
# plot.label_left = f"{', '.join(y_value_list)}"
|
||||
# plot.plotter_data_x = [1, 2, 3, 4, 5]
|
||||
# plot.plotter_data_y = [[1, 2, 3, 4, 5], [3, 4, 5, 6, 7]]
|
||||
# plot.update()
|
||||
|
||||
# assert all(plot.curves[0].getData()[0] == np.array([1, 2, 3, 4, 5]))
|
||||
# assert all(plot.curves[0].getData()[1] == np.array([1, 2, 3, 4, 5]))
|
||||
# assert all(plot.curves[1].getData()[1] == np.array([3, 4, 5, 6, 7]))
|
||||
|
||||
|
||||
# def test_basic_plot_mouse_moved(qtbot):
|
||||
# """Test LinePlot mouse_moved."""
|
||||
|
||||
# y_value_list = ["y1", "y2"]
|
||||
# plot = basic_plot.BasicPlot(y_value_list=y_value_list)
|
||||
# plot.plotter_data_x = [1, 2, 3, 4, 5]
|
||||
# plot.plotter_data_y = [[1, 2, 3, 4, 5], [3, 4, 5, 6, 7]]
|
||||
# plot.precision = 3
|
||||
# string_cap = 10
|
||||
# x_data = f"{3:.{plot.precision}f}"
|
||||
# y_data = f"{3:.{plot.precision}f}"
|
||||
# output_string = "".join(
|
||||
# [
|
||||
# "Mouse cursor",
|
||||
# "\n",
|
||||
# f"{y_value_list[0]}",
|
||||
# "\n",
|
||||
# f"X_data: {x_data:>{string_cap}}",
|
||||
# "\n",
|
||||
# f"Y_data: {y_data:>{string_cap}}",
|
||||
# ]
|
||||
# )
|
||||
# x_data = f"{3:.{plot.precision}f}"
|
||||
# y_data = f"{5:.{plot.precision}f}"
|
||||
# output_string = "".join(
|
||||
# [
|
||||
# output_string,
|
||||
# "\n",
|
||||
# f"{y_value_list[1]}",
|
||||
# "\n",
|
||||
# f"X_data: {x_data:>{string_cap}}",
|
||||
# "\n",
|
||||
# f"Y_data: {y_data:>{string_cap}}",
|
||||
# ]
|
||||
# )
|
||||
# with mock.patch.object(
|
||||
# plot, "plot"
|
||||
# ) as mock_plot: # TODO change test to simulate QTable instead of QLabel
|
||||
# mock_plot.sceneBoundingRect.contains.return_value = True
|
||||
# mock_plot.vb.mapSceneToView((20, 10)).x.return_value = 2.8
|
||||
# plot.mouse_moved((20, 10))
|
||||
# assert plot.mouse_box_data.text() == output_string
|
||||
149
tests/test_crosshair.py
Normal file
149
tests/test_crosshair.py
Normal file
@@ -0,0 +1,149 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import QPointF
|
||||
|
||||
from bec_widgets.qt_utils import Crosshair
|
||||
|
||||
|
||||
def test_mouse_moved_lines(qtbot):
|
||||
# Create a PlotWidget and add a PlotItem
|
||||
plot_widget = pg.PlotWidget(title="1D PlotWidget with multiple curves")
|
||||
plot_item = plot_widget.getPlotItem()
|
||||
plot_item.plot([1, 2, 3], [4, 5, 6])
|
||||
|
||||
# Create a Crosshair instance
|
||||
crosshair = Crosshair(plot_item=plot_item, precision=2)
|
||||
|
||||
# Connect the signals to slots that will store the emitted values
|
||||
emitted_values_1D = []
|
||||
crosshair.coordinatesChanged1D.connect(emitted_values_1D.append)
|
||||
|
||||
# Simulate a mouse moved event at a specific position
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
# Call the mouse_moved method
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
# Assert the expected behavior
|
||||
assert crosshair.v_line.pos().x() == 2
|
||||
assert crosshair.h_line.pos().y() == 5
|
||||
|
||||
|
||||
def test_mouse_moved_signals(qtbot):
|
||||
# Create a PlotWidget and add a PlotItem
|
||||
plot_widget = pg.PlotWidget(title="1D PlotWidget with multiple curves")
|
||||
plot_item = plot_widget.getPlotItem()
|
||||
plot_item.plot([1, 2, 3], [4, 5, 6])
|
||||
|
||||
# Create a Crosshair instance
|
||||
crosshair = Crosshair(plot_item=plot_item, precision=2)
|
||||
|
||||
# Create a slot that will store the emitted values as tuples
|
||||
emitted_values_1D = []
|
||||
|
||||
def slot(x, y_values):
|
||||
emitted_values_1D.append((x, y_values))
|
||||
|
||||
# Connect the signal to the custom slot
|
||||
crosshair.coordinatesChanged1D.connect(slot)
|
||||
|
||||
# Simulate a mouse moved event at a specific position
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
# Call the mouse_moved method
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
# Assert the expected behavior
|
||||
assert emitted_values_1D == [(2.0, [5.0])]
|
||||
|
||||
|
||||
def test_mouse_moved_signals_outside(qtbot):
|
||||
# Create a PlotWidget and add a PlotItem
|
||||
plot_widget = pg.PlotWidget(title="1D PlotWidget with multiple curves")
|
||||
plot_item = plot_widget.getPlotItem()
|
||||
plot_item.plot([1, 2, 3], [4, 5, 6])
|
||||
|
||||
# Create a Crosshair instance
|
||||
crosshair = Crosshair(plot_item=plot_item, precision=2)
|
||||
|
||||
# Create a slot that will store the emitted values as tuples
|
||||
emitted_values_1D = []
|
||||
|
||||
def slot(x, y_values):
|
||||
emitted_values_1D.append((x, y_values))
|
||||
|
||||
# Connect the signal to the custom slot
|
||||
crosshair.coordinatesChanged1D.connect(slot)
|
||||
|
||||
# Simulate a mouse moved event at a specific position
|
||||
pos_in_view = QPointF(22, 55)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
# Call the mouse_moved method
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
# Assert the expected behavior
|
||||
assert emitted_values_1D == []
|
||||
|
||||
|
||||
def test_mouse_moved_signals_2D(qtbot):
|
||||
# write similar test for 2D plot
|
||||
|
||||
# Create a PlotWidget and add a PlotItem
|
||||
plot_widget = pg.PlotWidget(title="2D plot with crosshair and ROI square")
|
||||
data_2D = np.random.random((100, 200))
|
||||
plot_item = plot_widget.getPlotItem()
|
||||
image_item = pg.ImageItem(data_2D)
|
||||
plot_item.addItem(image_item)
|
||||
# Create a Crosshair instance
|
||||
crosshair = Crosshair(plot_item=plot_item)
|
||||
# Create a slot that will store the emitted values as tuples
|
||||
emitted_values_2D = []
|
||||
|
||||
def slot(x, y):
|
||||
emitted_values_2D.append((x, y))
|
||||
|
||||
# Connect the signal to the custom slot
|
||||
crosshair.coordinatesChanged2D.connect(slot)
|
||||
# Simulate a mouse moved event at a specific position
|
||||
pos_in_view = QPointF(22.0, 55.0)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
# Call the mouse_moved method
|
||||
crosshair.mouse_moved(event_mock)
|
||||
# Assert the expected behavior
|
||||
assert emitted_values_2D == [(22.0, 55.0)]
|
||||
|
||||
|
||||
def test_mouse_moved_signals_2D_outside(qtbot):
|
||||
# write similar test for 2D plot
|
||||
|
||||
# Create a PlotWidget and add a PlotItem
|
||||
plot_widget = pg.PlotWidget(title="2D plot with crosshair and ROI square")
|
||||
data_2D = np.random.random((100, 200))
|
||||
plot_item = plot_widget.getPlotItem()
|
||||
image_item = pg.ImageItem(data_2D)
|
||||
plot_item.addItem(image_item)
|
||||
# Create a Crosshair instance
|
||||
crosshair = Crosshair(plot_item=plot_item, precision=2)
|
||||
# Create a slot that will store the emitted values as tuples
|
||||
emitted_values_2D = []
|
||||
|
||||
def slot(x, y):
|
||||
emitted_values_2D.append((x, y))
|
||||
|
||||
# Connect the signal to the custom slot
|
||||
crosshair.coordinatesChanged2D.connect(slot)
|
||||
# Simulate a mouse moved event at a specific position
|
||||
pos_in_view = QPointF(220.0, 555.0)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
# Call the mouse_moved method
|
||||
crosshair.mouse_moved(event_mock)
|
||||
# Assert the expected behavior
|
||||
assert emitted_values_2D == []
|
||||
89
tests/test_scan_plot.py
Normal file
89
tests/test_scan_plot.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from pytestqt import qtbot
|
||||
|
||||
from bec_widgets import scan_plot
|
||||
|
||||
|
||||
def test_scan_plot(qtbot):
|
||||
"""Test ScanPlot"""
|
||||
plot = scan_plot.BECScanPlot()
|
||||
qtbot.addWidget(plot)
|
||||
plot.show()
|
||||
qtbot.waitExposed(plot)
|
||||
|
||||
plot.x_channel = "x"
|
||||
plot.y_channel_list = ["y1", "y2"]
|
||||
|
||||
plot.on_scan_segment(
|
||||
{
|
||||
"data": {
|
||||
"x": {"x": {"value": 1}},
|
||||
"y1": {"y1": {"value": 1}},
|
||||
"y2": {"y2": {"value": 3}},
|
||||
}
|
||||
},
|
||||
{"scanID": "test", "scan_number": 1, "scan_report_devices": ["x"]},
|
||||
)
|
||||
plot.on_scan_segment(
|
||||
{
|
||||
"data": {
|
||||
"x": {"x": {"value": 2}},
|
||||
"y1": {"y1": {"value": 2}},
|
||||
"y2": {"y2": {"value": 4}},
|
||||
}
|
||||
},
|
||||
{"scanID": "test", "scan_number": 1, "scan_report_devices": ["x"]},
|
||||
)
|
||||
|
||||
assert all(plot.scan_curves["y1"].getData()[0] == [1, 2])
|
||||
assert all(plot.scan_curves["y2"].getData()[1] == [3, 4])
|
||||
|
||||
|
||||
def test_scan_plot_clears_data(qtbot):
|
||||
"""Test ScanPlot"""
|
||||
plot = scan_plot.BECScanPlot()
|
||||
qtbot.addWidget(plot)
|
||||
plot.show()
|
||||
qtbot.waitExposed(plot)
|
||||
|
||||
plot.x_channel = "x"
|
||||
plot.y_channel_list = ["y1", "y2"]
|
||||
|
||||
plot.on_scan_segment(
|
||||
{
|
||||
"data": {
|
||||
"x": {"x": {"value": 1}},
|
||||
"y1": {"y1": {"value": 1}},
|
||||
"y2": {"y2": {"value": 3}},
|
||||
}
|
||||
},
|
||||
{"scanID": "test", "scan_number": 1, "scan_report_devices": ["x"]},
|
||||
)
|
||||
plot.on_new_scan({}, {})
|
||||
plot.on_scan_segment(
|
||||
{
|
||||
"data": {
|
||||
"x": {"x": {"value": 2}},
|
||||
"y1": {"y1": {"value": 2}},
|
||||
"y2": {"y2": {"value": 4}},
|
||||
}
|
||||
},
|
||||
{"scanID": "test", "scan_number": 1, "scan_report_devices": ["x"]},
|
||||
)
|
||||
|
||||
assert all(plot.scan_curves["y1"].getData()[0] == [2])
|
||||
assert all(plot.scan_curves["y2"].getData()[1] == [4])
|
||||
|
||||
|
||||
def test_scan_plot_redraws_dap(qtbot):
|
||||
"""Test ScanPlot"""
|
||||
plot = scan_plot.BECScanPlot()
|
||||
qtbot.addWidget(plot)
|
||||
plot.show()
|
||||
qtbot.waitExposed(plot)
|
||||
|
||||
plot.y_channel_list = ["dap.y1", "dap.y2"]
|
||||
|
||||
plot.redraw_dap({"y1": {"x": [1], "y": [1]}, "y2": {"x": [2], "y": [2]}}, {})
|
||||
|
||||
assert all(plot.dap_curves["y1"].getData()[0] == [1])
|
||||
assert all(plot.dap_curves["y2"].getData()[1] == [2])
|
||||
Reference in New Issue
Block a user