diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index e8ba339..cd7be82 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -9,6 +9,7 @@ on: # Runs on all push events to master branch and any push related to a pull r
jobs:
coverage:
+ if: false # disabled for now
uses: pylhc/.github/.github/workflows/coverage.yml@master
with:
src-dir: omc3_gui
diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml
index e0afaa4..50b5449 100644
--- a/.github/workflows/cron.yml
+++ b/.github/workflows/cron.yml
@@ -8,4 +8,5 @@ on:
jobs:
tests:
+ if: false # disabled for now
uses: pylhc/.github/.github/workflows/cron.yml@master
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5970e25..69d776b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -12,4 +12,5 @@ on: # Runs on any push event to any branch except master (the coverage workflow
jobs:
tests:
+ if: false # disabled for now
uses: pylhc/.github/.github/workflows/tests.yml@master
diff --git a/.gitignore b/.gitignore
index e6caea5..18dd034 100644
--- a/.gitignore
+++ b/.gitignore
@@ -248,3 +248,6 @@ pyproject.toml*~
.cache
/doc/_build/*
*pycache*
+
+# testers
+tst_*
diff --git a/.zenodo.json b/.zenodo.json
index 9bd0203..43ddbd4 100644
--- a/.zenodo.json
+++ b/.zenodo.json
@@ -9,33 +9,10 @@
"affiliation": "CERN",
"orcid": "0000-0001-7864-5448"
},
- {
- "name": "Felix Soubelet",
- "affiliation": "University of Liverpool & CERN",
- "orcid": "0000-0001-8012-1440"
- },
- {
- "name": "Andreas Wegscheider",
- "affiliation": "CERN"
- },
{
"name": "Jaime Maria Coello De Portugal - Martinez Vazquez",
"affiliation": "CERN",
"orcid": "0000-0002-6899-3809"
- },
- {
- "name": "Maël Le Garrec",
- "affiliation": "CERN",
- "orcid": "0000-0002-8146-2340"
- },
- {
- "name": "Tobias Persson",
- "affiliation": "CERN"
- },
- {
- "name": "Rogelio Tomas Garcia",
- "affiliation": "CERN",
- "orcid": "0000-0002-9857-1703"
}
],
"title": "OMC3-GUI",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7b7135..6cffed1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,35 @@
# OMC3-GUI Changelog
+#### 2025-04-04 - v1.0.0 - Segment-by-Segment GUI
+
+- Maintenance:
+ - `pyproject.toml` used instead of `setup.py`.
+ - Deactivated testing workflows as there are no tests.
+ - Documentation
+
+- Initial release of the Segment-by-Segment GUI:
+ - Implements plotting for the Segment-by-Segment propagation of the
+ omc3 backend. Requires `omc3 >= 0.24.0`.
+ - Plotting for the SegmentDiffs of `AlphaPhase`, `BetaPhase`, `Dispersion`
+ and `Coupling`.
+ - Saving and loading of the optics measurements from `.json` files.
+ - Create virtual `copy` of the measurements.
+
+- General plotting functionality with `pyqtgraph`:
+ - `PlotWidget` to replicate the look-and-feel of the Java-GUI plots.
+ - `DualPlotWidget` to handle two plots in one widget (as often the case in our GUIs for two planes).
+ - Plotting for pandas Dataframes.
+ - Plotting for model elements as vertical lines.
+
+- General reusable Tools:
+ - Dataclass UI: Widgets to display and edit dataclasses in UI.
+ - Widgets, threads, item models, file dialogs
#### 2023-06-20 - v0.0.0 - Inital commit
-- `setup.py` and packaging functionality
-- Automated CI
- - Multiple versions of python
- - Accuracy tests
- - Unit tests
- - Release automation
+- `setup.py` and packaging functionality
+- Automated CI:
+ - Multiple versions of python
+ - Accuracy tests
+ - Unit tests
+ - Release automation
diff --git a/LICENSE b/LICENSE
index f288702..324bcbf 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,674 +1,21 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Use with the GNU Affero General Public License.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
-.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
+MIT License
+
+Copyright (c) 2025 pylhc/OMC-Team
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 402af60..0000000
--- a/Makefile
+++ /dev/null
@@ -1,71 +0,0 @@
-# Copyright pyLHC/OMC-team
-
-# Documentation for most of what you will see here can be found at the following links:
-# for the GNU make special targets: https://www.gnu.org/software/make/manual/html_node/Special-Targets.html
-# for python packaging: https://docs.python.org/3/distutils/introduction.html
-
-# ANSI escape sequences for bold, cyan, dark blue, end, pink and red.
-B = \033[1m
-C = \033[96m
-D = \033[34m
-E = \033[0m
-P = \033[95m
-R = \033[31m
-
-.PHONY : help archive clean doc install tests
-
-all: install
-
-help:
- @echo "Please use 'make $(R)$(E)' where $(R)$(E) is one of:"
- @echo " $(R) archive $(E) to create a tarball of the codebase for a specific release."
- @echo " $(R) clean $(E) to recursively remove build, run, and bitecode files/dirs."
- @echo " $(R) doc $(E) to build the documentation with `sphinx`."
- @echo " $(R) install $(E) to 'pip install' this package into your activated environment."
- @echo " $(R) tests $(E) to run tests with the the pytest package."
-
-archive:
- @echo "$(B)Creating tarball archive of this release.$(E)"
- @echo ""
- @python setup.py sdist
- @echo ""
- @echo "$(B)Your archive is in the $(C)dist/$(E) $(B)directory. Link it to your release.$(E)"
- @echo "To install from this archive, unpack it and run '$(D)python setup.py install$(E)' from within its directory."
- @echo "You should run $(R)make clean$(E) afterwards to get rid of the output files."
- @echo ""
-
-clean:
- @echo "Running setup clean."
- @python setup.py clean
- @echo "Cleaning up distutils remains."
- @rm -rf build
- @rm -rf dist
- @rm -rf .eggs
- @rm -rf omc3_gui.egg-info
- @echo "Cleaning up documentation build remains."
- @rm -rf doc_build
- @echo "Cleaning up bitecode files and python cache."
- @find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete
- @echo "Cleaning up pytest cache."
- @find . -type d -name '*.pytest_cache' -exec rm -rf {} + -o -type f -name '*.pytest_cache' -exec rm -rf {} + -o -type f -name 'stats.txt' -delete
- @echo "Cleaning up coverage reports."
- @find . -type f -name '.coverage' -exec rm -rf {} + -o -type f -name 'coverage.xml' -delete
- @echo "All cleaned up!\n"
-
-doc: clean
- @echo "$(B)Creating documentation build with Sphinx.$(E)"
- @python -m sphinx -b html doc ./doc_build -d ./doc_build
- @echo "Done! Documentation source is in the $(C)doc_build/$(E) directory."
-
-install: clean
- @echo "$(B)Installing this package to your active environment.$(E)"
- @pip install .
-
-tests: clean
- @pytest
- @make clean
-
-# Catch-all unknow targets without returning an error. This is a POSIX-compliant syntax.
-.DEFAULT:
- @echo "Make caught an invalid target! See help output below for available targets."
- @make help
\ No newline at end of file
diff --git a/README.md b/README.md
index 3e65693..31c94a1 100644
--- a/README.md
+++ b/README.md
@@ -40,9 +40,9 @@ Codes can then be run with either `python -m omc3_gui.SCRIPT --FLAG ARGUMENT` or
#### Main Scripts
Main scripts to be executed lie in the [`/omc3_gui`](omc3_gui) directory. These include:
-- `.py` Nothing here yet.
+- `sbs_gui.py` GUI for Segment-by-Segment analysis.
## License
-This project is licensed under the `GNU GENERAL PUBLIC LICENSE v3 License` - see the [LICENSE](LICENSE) file for details.
+This project is licensed under the `MIT License` - see the [LICENSE](LICENSE) file for details.
diff --git a/doc/Makefile b/doc/Makefile
index f0239f6..6a2f603 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -47,11 +47,6 @@ html:
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
-josch:
- $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) ~/Software/Documentation/omc3_gui-doc
- @echo
- @echo "Build finished. The HTML pages are in ~/Software/Documentation/omc3_gui-doc."
-
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
diff --git a/doc/_static/css/custom.css b/doc/_static/css/custom.css
index d9ad2df..97970f4 100644
--- a/doc/_static/css/custom.css
+++ b/doc/_static/css/custom.css
@@ -272,4 +272,24 @@ em.sig-param span.default_value {
padding-top: 10px;
padding-bottom: 5px;
}
-}
\ No newline at end of file
+}
+
+/* Custom styles for bibliography */
+.citation {
+ display: block!important;
+}
+
+.citation .label {
+ display: block;
+ font-weight: bold;
+ margin-bottom: 0.5em;
+}
+
+.citation p {
+ margin: 0;
+ padding-left: 1em;
+}
+
+.citation cite {
+ display: block;
+}
diff --git a/doc/bibliography.rst b/doc/bibliography.rst
new file mode 100644
index 0000000..7a183a6
--- /dev/null
+++ b/doc/bibliography.rst
@@ -0,0 +1,4 @@
+Bibliography
+************
+
+This bibliography is inentionally left blank.
\ No newline at end of file
diff --git a/doc/conf.py b/doc/conf.py
index 1a5de06..ba09c27 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -226,10 +226,8 @@
# -- Autodoc Configuration ---------------------------------------------------
# Add here all modules to be mocked up. When the dependencies are not met
-# at building time. Here used to have PyQT mocked.
-autodoc_mock_imports = ['PyQt5', 'PyQt5.QtGui', 'PyQt5.QtCore', 'PyQt5.QtWidgets',
- "matplotlib.backends.backend_qt5agg",
- ]
+# at building time.
+autodoc_mock_imports = ["qtpy", "pyqtgraph", "accwidgets", "PySide2", "shiboken2",]
# -- Type Aliases --------------------------------------------------------------
diff --git a/doc/index.rst b/doc/index.rst
index a2f4cb9..97d236f 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -1,5 +1,5 @@
Welcome to omc3-GUI's documentation!
-================================
+====================================
``omc3_gui`` is a wrapper for ``omc3`` to provide a graphical user interface for beam optics measurements and corrections in particle accelerators used by the OMC team at `CERN `_.
@@ -7,11 +7,11 @@ Package Reference
=================
.. toctree::
- :caption: Main Entrypoints
+ :caption: Main GUIs
:maxdepth: 1
:glob:
- entrypoints/*
+ main/*
.. toctree::
@@ -21,6 +21,11 @@ Package Reference
modules/*
+.. toctree::
+ :caption: Bibliography
+ :maxdepth: 1
+
+ bibliography
Indices and tables
==================
diff --git a/doc/main/sbs_gui.rst b/doc/main/sbs_gui.rst
new file mode 100644
index 0000000..b852b3f
--- /dev/null
+++ b/doc/main/sbs_gui.rst
@@ -0,0 +1,50 @@
+Segment-by-Segment
+******************
+
+.. automodule:: omc3_gui.sbs_gui
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.main_controller
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.main_model
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.main_view
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.measurement_model
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.measurement_view
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.segment_model
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.segment_view
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.defaults
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.help_view
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.plotting
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.segment_by_segment.settings
+ :members:
+ :noindex:
diff --git a/doc/modules/plotting.rst b/doc/modules/plotting.rst
new file mode 100644
index 0000000..7e14be5
--- /dev/null
+++ b/doc/modules/plotting.rst
@@ -0,0 +1,18 @@
+Plotting
+********
+
+.. automodule:: omc3_gui.plotting.classes
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.plotting.element_lines
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.plotting.latex_to_html
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.plotting.tfs_plotter
+ :members:
+ :noindex:
diff --git a/doc/modules/ui.rst b/doc/modules/ui.rst
new file mode 100644
index 0000000..f44b703
--- /dev/null
+++ b/doc/modules/ui.rst
@@ -0,0 +1,60 @@
+UI-Components
+*************
+
+.. automodule:: omc3_gui.ui_components.dataclass_ui
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.dataclass_ui.controller
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.dataclass_ui.model
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.dataclass_ui.view
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.dataclass_ui.tools
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.base_classes_cvm
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.colors
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.file_dialogs
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.item_models
+ :members:
+ :noindex:
+
+
+.. automodule:: omc3_gui.ui_components.message_boxes
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.styles
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.text_editor
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.threads
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.ui_components.widgets
+ :members:
+ :noindex:
+
diff --git a/doc/modules/utils.rst b/doc/modules/utils.rst
new file mode 100644
index 0000000..86bbfac
--- /dev/null
+++ b/doc/modules/utils.rst
@@ -0,0 +1,14 @@
+Utilities
+*********
+
+.. automodule:: omc3_gui.utils.counter
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.utils.iteration_classes
+ :members:
+ :noindex:
+
+.. automodule:: omc3_gui.utils.log_handler
+ :members:
+ :noindex:
diff --git a/omc3_gui/__init__.py b/omc3_gui/__init__.py
index ceedaf5..ed1ad4a 100644
--- a/omc3_gui/__init__.py
+++ b/omc3_gui/__init__.py
@@ -1,20 +1,17 @@
"""
The omc3-gui library
-~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~
omc3-gui provides graphical user interfaces for the ``omc3`` package for the optics measurements and corrections group (OMC) at CERN.
:copyright: pyLHC/OMC-Team working group.
-:license: GNU GPL v3, see the LICENSE file for details.
"""
__title__ = "omc3-gui"
-__description__ = "PyQT Graphical User Interface wrapper of the ``omc3`` package"
+__description__ = "QT Graphical User Interface wrapper of the ``omc3`` package"
__url__ = "https://github.com/pylhc/omc3_gui"
-__version__ = "0.0.0"
-__omc3_version__ = "0.11.0" # KEEP UP TO DATE!
+__version__ = "1.0.0"
__author__ = "pylhc"
__author_email__ = "pylhc@github.com"
-__license__ = "GNU GPL v3"
__all__ = [__version__]
diff --git a/omc3_gui/__main__.py b/omc3_gui/__main__.py
new file mode 100644
index 0000000..f473d2a
--- /dev/null
+++ b/omc3_gui/__main__.py
@@ -0,0 +1,13 @@
+from pathlib import Path
+
+this_dir = Path(__file__).parent
+
+# Welcome ---
+print("Welcome to omc3_gui!\n")
+
+# Scripts ---
+scripts = this_dir.glob("[a-zA-Z]*.py")
+print("Available entrypoints:\n")
+for script in scripts:
+ print(f"omc3_gui.{script.stem}")
+print()
diff --git a/omc3_gui/plotting/classes.py b/omc3_gui/plotting/classes.py
new file mode 100644
index 0000000..41fbc98
--- /dev/null
+++ b/omc3_gui/plotting/classes.py
@@ -0,0 +1,177 @@
+"""
+Classes
+-------
+
+Containers for figures, plots, etc.
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+import numpy as np
+import pyqtgraph as pg
+from accwidgets.graph import StaticPlotWidget
+from accwidgets.graph.widgets.plotitem import ExViewBox
+from accwidgets.graph.widgets.plotwidget import GridOrientationOptions
+from qtpy.QtCore import Signal, Qt
+
+if TYPE_CHECKING:
+ from pyqtgraph.GraphicsScene import mouseEvents
+ from qtpy.QtWidgets import QGraphicsSceneMouseEvent
+
+YAXES_WIDTH: int = 60
+
+class ObservablePlotDataItem(pg.PlotDataItem):
+ """A PlotDataItem that emits a signal when visibility changes."""
+ visibilityChanged = Signal(bool)
+
+ def setVisible(self, visible):
+ super().setVisible(visible)
+ self.visibilityChanged.emit(visible)
+
+
+class DualPlotWidget(pg.LayoutWidget):
+ """ A widget containing and handling two plots stacked vertically. """
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+
+ pg.setConfigOptions(antialias=True) # not sure if best place here
+
+ self.top = PlotWidget()
+ self.bottom = PlotWidget()
+
+ self.addWidget(self.top, row=0, col=0)
+ self.addWidget(self.bottom, row=1, col=0)
+
+ # set margins, so the axes line up
+ for plot in (self.top, self.bottom):
+ plot.setContentsMargins(0, 0, 0, 0)
+ plot.plotItem.getAxis("left").setWidth(YAXES_WIDTH) # keep constant
+
+ @property
+ def plots(self) -> tuple[pg.PlotWidget, pg.PlotWidget]:
+ return (self.top, self.bottom)
+
+ def clear(self) -> None:
+ for plot in self.plots:
+ plot.clear()
+ plot.enableAutoRange()
+
+ def set_connect_x(self, connect: bool) -> None:
+ if connect:
+ self.top.setXLink(self.bottom)
+ else:
+ self.top.setXLink(None)
+ self.bottom.setXLink(None)
+
+ def set_connect_y(self, connect: bool) -> None:
+ if connect:
+ self.top.setYLink(self.bottom)
+ else:
+ self.top.setYLink(None)
+ self.bottom.setYLink(None)
+
+
+class PlotWidget(StaticPlotWidget):
+ """ A widget containing and handling a single plot.
+
+ Adds the look-and-feel for our omc3 guis to the default accwidgets plotwidget.
+ """
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs, viewBox=ZoomingViewBox()) # using viewbox here requires accwidgets >= 3.0.11
+ self.setBackground("w")
+ self._set_show_grid(GridOrientationOptions.Both)
+
+
+class ZoomingViewBox(ExViewBox):
+ """ ViewBox that imitates the behavior of the Java-GUI a bit more closely than the default. """
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.setMouseMode(ZoomingViewBox.RectMode) # mode that makes zooming rectangles
+
+ # Look and Feel ------------------------------------------------------------
+ def suggestPadding(self, axis):
+ """ Suggests padding (between the data and the axis) for the autoRange function.
+ For our purposes, we do not want any padding on the x-axis. """
+ if axis == 0:
+ return 0.0 # disable padding for x axis
+ return super().suggestPadding(axis)
+
+ # Mouse Events -------------------------------------------------------------
+ def mouseClickEvent(self, ev: mouseEvents.MouseClickEvent):
+ if ev.button() == Qt.MouseButton.MiddleButton:
+ ev.accept()
+ self.auto_zoom()
+ return
+
+ if ev.button() == Qt.MouseButton.RightButton:
+ ev.accept()
+ if ev.modifiers() == Qt.KeyboardModifier.AltModifier:
+ self.raiseContextMenu(ev)
+ return
+
+ self.undo_zoom(reset=ev.modifiers() == Qt.KeyboardModifier.ShiftModifier)
+ return
+
+ super().mouseClickEvent(ev)
+
+ def mouseDoubleClickEvent(self, ev: QGraphicsSceneMouseEvent):
+ if ev.button() == Qt.MouseButton.LeftButton:
+ ev.accept()
+ self.undo_zoom(reset=ev.modifiers() == Qt.KeyboardModifier.ShiftModifier)
+ return
+
+ super().mouseDoubleClickEvent(ev)
+
+ # Zoom History -------------------------------------------------------------
+ def undo_zoom(self, reset: bool = False):
+ """ Go back in zoom history. """
+ if self.axHistoryPointer == 0:
+ self.enableAutoRange()
+ self.axHistoryPointer = -1
+ self.save_view()
+ return
+
+ if reset:
+ # Go back to the first zoom
+ self.scaleHistory(-len(self.axHistory))
+ return
+
+ # go one step back
+ self.scaleHistory(-1)
+
+ def save_view(self):
+ """ Save the current view to the zoom history. """
+ self.axHistoryPointer += 1
+ self.axHistory = self.axHistory[:self.axHistoryPointer] + [self.viewRect()]
+
+ # Auto Zoom ----------------------------------------------------------------
+ def auto_zoom(self):
+ """ Zoom to 6, 4 and 2 standard deviations and save the steps. """
+ for nsigma in (6, 4, 2):
+ self.set_y_range_to_n_sigma(nsigma)
+ self.save_view()
+
+ def set_y_range_to_n_sigma(self, n_sigma):
+ """ Set the y-range to a number of standard deviations,
+ assuming the data is taken from a normal distribution. """
+ # Get the data from all curves in the viewbox
+ all_data = []
+ for item in self.allChildren():
+ if isinstance(item, pg.PlotDataItem):
+ y_data = item.yData
+ if y_data is not None:
+ all_data.extend(y_data)
+
+ if not all_data:
+ return
+
+ all_data = np.array(all_data)
+ mean = np.mean(all_data)
+ std_dev = np.std(all_data)
+
+ y_min = mean - n_sigma * std_dev
+ y_max = mean + n_sigma * std_dev
+
+ self.setYRange(y_min, y_max, padding=0)
diff --git a/omc3_gui/plotting/element_lines.py b/omc3_gui/plotting/element_lines.py
new file mode 100644
index 0000000..7470cb6
--- /dev/null
+++ b/omc3_gui/plotting/element_lines.py
@@ -0,0 +1,52 @@
+"""
+Element Line Plotter
+--------------------
+
+This module contains functions to plot element lines with pyqtgraph.
+"""
+import numpy as np
+import tfs
+import pyqtgraph as pg
+import logging
+from omc3.optics_measurements.constants import S
+from qtpy.QtCore import Qt
+
+LOGGER = logging.getLogger(__name__)
+LENGTH: str = "LENGTH"
+
+def plot_element_lines(plot: pg.PlotWidget, data_frame: tfs.TfsDataFrame, ranges: list[tuple[str, str]], start_zero: bool):
+ """
+ Plot vertical lines on the plot for elements in the data_frame in the given ranges.
+
+ Args:
+ plot (pg.PlotWidget): The plot to plot the lines into.
+ data_frame (tfs.TfsDataFrame): The data_frame to plot the lines from.
+ ranges (list[tuple[str, str]]): A list of tuples of the form (start_element, end_element).
+ start_zero (bool): Whether to start the plot from zero or not.
+ """
+ if start_zero and not all(ranges[0][0] == r[0] for r in ranges):
+ LOGGER.warning("Not all ranges start at the same element. Using only the first!")
+ ranges = [ranges[0]]
+
+ plotItem = plot.plotItem
+
+ # Find start and end elements
+ start = min(ranges, key=lambda r: data_frame.loc[r[0], S])[0]
+ end = max(ranges, key=lambda r: data_frame.loc[r[1], S])[1]
+
+ # Select element range and do some wrapping gymnastics if needed
+ if data_frame.loc[start, S] <= data_frame.loc[end, S]:
+ s_elements = data_frame.loc[start:end, S]
+ else:
+ s_elements = data_frame.loc[start:, S] + data_frame.loc[:end, S]
+
+ if start_zero:
+ s_elements = s_elements - s_elements.loc[start]
+ s_elements = s_elements + np.where(s_elements < 0, data_frame.headers[LENGTH], 0)
+
+ # Plot the lines - this takes a while, not sure how to improve
+ plotItem.disableAutoRange() # speeds it up a bit
+ pen = pg.mkPen(color="grey", width=1, style=Qt.PenStyle.DotLine)
+ for element, x in s_elements.items():
+ plotItem.addLine(x=x, z=-10, pen=pen, label=element, labelOpts={"angle": 90})
+
diff --git a/omc3_gui/plotting/latex_to_html.py b/omc3_gui/plotting/latex_to_html.py
new file mode 100644
index 0000000..e94926a
--- /dev/null
+++ b/omc3_gui/plotting/latex_to_html.py
@@ -0,0 +1,94 @@
+"""
+Latex to HTML converter
+-----------------------
+
+Converts LaTeX commands for Greek letters and other symbols to HTML.
+Needed to be able to re-use matplotlib-labels - which understand Latex -
+in pyqtgraph, which does not understand Latex.
+"""
+from __future__ import annotations
+
+import re
+
+# Dictionary to map Greek LaTeX symbols to HTML
+LATEX_TO_HTML_SYMBOLS = {
+ # Greek ---
+ r'\alpha': 'α',
+ r'\beta': 'β',
+ r'\gamma': 'γ',
+ r'\delta': 'δ',
+ r'\epsilon': 'ε',
+ r'\zeta': 'ζ',
+ r'\eta': 'η',
+ r'\theta': 'θ',
+ r'\iota': 'ι',
+ r'\kappa': 'κ',
+ r'\lambda': 'λ',
+ r'\mu': 'μ',
+ r'\nu': 'ν',
+ r'\xi': 'ξ',
+ r'\pi': 'π',
+ r'\rho': 'ρ',
+ r'\sigma': 'σ',
+ r'\tau': 'τ',
+ r'\upsilon': 'υ',
+ r'\phi': 'φ',
+ r'\chi': 'χ',
+ r'\psi': 'ψ',
+ r'\omega': 'ω',
+ r'\Alpha': 'Α',
+ r'\Beta': 'Β',
+ r'\Gamma': 'Γ',
+ r'\Delta': 'Δ',
+ r'\Epsilon': 'Ε',
+ r'\Zeta': 'Ζ',
+ r'\Eta': 'Η',
+ r'\Theta': 'Θ',
+ r'\Iota': 'Ι',
+ r'\Kappa': 'Κ',
+ r'\Lambda': 'Λ',
+ r'\Mu': 'Μ',
+ r'\Nu': 'Ν',
+ r'\Xi': 'Ξ',
+ r'\Pi': 'Π',
+ r'\Rho': 'Ρ',
+ r'\Sigma': 'Σ',
+ r'\Tau': 'Τ',
+ r'\Upsilon': 'Υ',
+ r'\Phi': 'Φ',
+ r'\Chi': 'Χ',
+ r'\Psi': 'Ψ',
+ r'\Omega': 'Ω',
+ # Symbols ---
+ r'\pm': '±',
+ r'\times': '×',
+ r'\Re': 'ℜ',
+ r'\Im': 'ℑ',
+ # Spacing ---
+ r'\quad': ' ',
+ r'\;': ' ',
+}
+
+def latex_to_html_converter(latex_str: str) -> str:
+ """
+ Converts LaTeX commands for Greek letters and other symbols to HTML.
+
+ Args:
+ latex_str (str): LaTeX string to convert.
+
+ Returns:
+ str: HTML string
+ """
+ # Convert LaTeX commands for Greek letters to HTML
+ html_str = latex_str.replace("$", "")
+ for latex, html in LATEX_TO_HTML_SYMBOLS.items():
+ html_str = html_str.replace(latex, html)
+
+ # Other HTML formatting like superscript/subscript, fractions, etc.
+ html_str = re.sub(r'_{([^}]*)}', r'\1', html_str)
+ html_str = re.sub(r'_(.)', r'\1', html_str)
+ html_str = re.sub(r'\\frac{([^}]*)}{([^}]*)}', r'\1/\2', html_str)
+ html_str = re.sub(r'\\left\((.*?)\\right\)', r'(\1)', html_str) # Basic parentheses
+ html_str = re.sub(r'\\left\|(.*?)\\right\|', r'|\1|', html_str) # Absolute values
+
+ return html_str
diff --git a/omc3_gui/plotting/tfs_plotter.py b/omc3_gui/plotting/tfs_plotter.py
new file mode 100644
index 0000000..106b7c2
--- /dev/null
+++ b/omc3_gui/plotting/tfs_plotter.py
@@ -0,0 +1,246 @@
+"""
+TFS Plotter
+-----------
+
+This module contains functions to plot TFS dataframes with pyqtgraph.
+"""
+from collections.abc import Sequence
+
+import numpy as np
+import pandas as pd
+import pyqtgraph as pg
+from omc3.plotting.utils.colors import get_mpl_color
+import logging
+
+from qtpy.QtCore import Qt
+from qtpy.QtGui import QColor
+
+from omc3_gui.plotting.classes import ObservablePlotDataItem, YAXES_WIDTH
+
+PenStyle = Qt.PenStyle
+
+LOGGER = logging.getLogger(__name__)
+
+
+def plot_dataframes(
+ plot: pg.PlotWidget,
+ dataframes: dict[str, pd.DataFrame],
+ xcolumn: str,
+ ycolumn: str,
+ xerrcolumn: str = None,
+ yerrcolumn: str = None,
+ xlabel: str = None,
+ ylabel: str = None,
+ legend: bool = True,
+ brightness: int | None = None,
+ marker: str = 'o',
+ markersize: float = 6,
+ linestyle: PenStyle = PenStyle.SolidLine,
+ suffix: str = "",
+ ):
+ """
+ Plot a collection of DataFrames with pyqtgraph.
+
+ Args:
+ plot (pg.PlotWidget): The plot to plot the dataframes into.
+ dataframes (dict[str, pd.DataFrame]): A dictionary of DataFrames to plot.
+ xcolumn (str): The name of the column to plot on the x-axis.
+ ycolumn (str): The name of the column to plot on the y-axis.
+ xerrcolumn (str): The name of the column to plot as horizontal errorbars.
+ yerrcolumn (str): The name of the column to plot as vertical errorbars.
+ xlabel (str): The label of the x-axis.
+ ylabel (str): The label of the y-axis.
+ legend (bool, optional): Whether to add a legend to the plot. Defaults to True.
+ brightness (int, optional): The brightness of the colors to use. Defaults to None.
+ marker (str, optional): The marker to use for the data points. Defaults to 'o'.
+ markersize (float, optional): The size of the markers to use for the data points. Defaults to 6.
+ linestyle (PenStyle, optional): The linestyle to use for the data points. Defaults to PenStyle.SolidLine.
+ suffix (str, optional): The suffix to add to the legend. Defaults to "".
+ """
+ plot_item: pg.PlotItem = plot.plotItem
+
+ plot_item.addLegend(offset=(0, 0))
+
+ for idx, (name, df) in enumerate(dataframes.items()):
+ color = pg.Color(get_mpl_color(idx))
+ if brightness is not None:
+ color = color.lighter(brightness)
+
+ try:
+ df[ycolumn]
+ except KeyError:
+ LOGGER.debug(f"Could not find column '{ycolumn}' in DataFrame for '{name}'. Skipping!")
+ continue
+
+ plot_errorbar(
+ plot_item,
+ x=df[xcolumn],
+ y=df[ycolumn],
+ xerr=df.get(xerrcolumn),
+ yerr=df.get(yerrcolumn),
+ names=df.index,
+ label=f"{name}{suffix}",
+ color=color,
+ marker=marker,
+ markersize=markersize,
+ linestyle=linestyle,
+ )
+
+ if xlabel is not None:
+ plot_item.setLabel("bottom", xlabel)
+
+ if ylabel is not None:
+ plot_item.setLabel("left", ylabel)
+
+ plot_item.getAxis("left").setWidth(YAXES_WIDTH) # keep constant
+ plot_item.legend.setVisible(legend)
+
+
+def plot_errorbar(
+ plot: pg.PlotItem,
+ *,
+ x: Sequence,
+ y: Sequence,
+ xerr: Sequence | None = None,
+ yerr: Sequence | None = None,
+ names: Sequence | None = None,
+ label: str | None = None,
+ color: str | pg.Color | None = None,
+ marker: str = 'o',
+ markersize: float = 6,
+ linestyle: PenStyle = PenStyle.SolidLine,
+ linewidth: float = 2,
+ ) -> tuple[pg.PlotDataItem, pg.ErrorBarItem]:
+ """
+ Plot a single errorbar with pyqtgraph.
+ This tries to imitate the behavior of matplotlib's errorbar function,
+ and the naming is mostly borrowed from there.
+
+ Args:
+ plot (pg.PlotWidget): The plot to plot the errorbar into.
+ x (Sequence): The x values of the errorbar.
+ y (Sequence): The y values of the errorbar.
+ xerr (Sequence): The xerr values of the errorbar.
+ yerr (Sequence): The yerr values of the errorbar.
+ names (Sequence): The names of the entries in the data sequence.
+ label (str | None, optional): The label of the errorbar. Defaults to None.
+ color (str | None, optional): The color of the errorbar. Defaults to None.
+ marker (str, optional): The marker of the errorbar. Defaults to 'o'.
+ markersize (float, optional): The markersize of the errorbar. Defaults to 10.
+ linestyle (PenStyle, optional): The linestyle of the errorbar. Defaults to PenStyle.SolidLine.
+ linewidth (float, optional): The linewidth of the errorbar. Defaults to 2.
+ """
+
+ curvePen = pg.mkPen(color=color, width=linewidth, style=linestyle)
+ errorbarPen = pg.mkPen(color=color, width=linewidth, style=PenStyle.SolidLine)
+
+ # convert everything to numpy, as this is what pyqtgraph expects.
+ # pd.Series seems to also work for now, but raises deprecation warnings,
+ # as in the future it needs .iloc to work with indices,
+ # yet pyqtgraph and create_tooltips access items via `[ ]` (for now at least).
+ x = safe_convert_to_numpy(x)
+ y = safe_convert_to_numpy(y)
+ xerr = safe_convert_to_numpy(xerr)
+ yerr = safe_convert_to_numpy(yerr)
+ names = safe_convert_to_numpy(names)
+
+ hex_color = None
+ if color is not None:
+ hex_color = curvePen.color().name(QColor.NameFormat.HexRgb)
+
+ tooltips = create_tooltips(x, y, xerr, yerr, names, label, hex_color)
+ curve = ObservablePlotDataItem(
+ x=x, y=y, data=tooltips,
+ name=label,
+ pen=curvePen,
+ tip=None,
+ symbol=marker, symbolBrush=color, symbolSize=markersize
+ )
+ # curve.sigPointsHovered.connect(hovered)
+ # curve.sigPointsClicked.connect(clicked)
+ curve.scatter.opts['hoverable'] = True
+ curve.scatter.sigHovered.connect(hovered)
+
+ errorbar = None
+ if xerr is not None or yerr is not None:
+ errorbar = pg.ErrorBarItem(
+ x=x, y=y, data=tooltips,
+ width=2*xerr if xerr is not None else None,
+ height=2*yerr if yerr is not None else None,
+ pen=errorbarPen
+ )
+
+ # Connect errorbar and curve's visibility
+ curve.visibilityChanged.connect(errorbar.setVisible)
+ plot.addItem(errorbar)
+
+ plot.addItem(curve)
+ return curve, errorbar
+
+
+def create_tooltips(x, y, xerr, yerr, names, label, color) -> list[str]:
+ """
+ Create a list of tooltips for a given errorbar.
+
+ Args:
+ x (Sequence): The x values of the errorbar.
+ y (Sequence): The y values of the errorbar.
+ xerr (Sequence): The xerr values of the errorbar.
+ yerr (Sequence): The yerr values of the errorbar.
+ names (Sequence): The names of the entries in the data sequence.
+ label (str | None, optional): The label of the errorbar
+ color (str | None, optional): The color of the tooltip background
+ """
+ tooltips = [""] * len(x)
+
+
+ for index in range(len(x)):
+ tooltip_text = ""
+
+ if color is not None:
+ tooltip_text += "" # TODO
+
+ if label is not None:
+ tooltip_text += f"{label} "
+
+ tooltip_text += f"x: {x[index]:.2e}"
+ if xerr is not None:
+ tooltip_text += f" ± {xerr[index]:.2e}"
+
+ tooltip_text += f" y: {y[index]:.2e}"
+ if yerr is not None:
+ tooltip_text += f" ± {yerr[index]:.2e}"
+
+ if names is not None:
+ tooltip_text += f" {names[index]}"
+
+ if color is not None:
+ tooltip_text += ""
+
+ tooltip_text += ""
+
+ tooltips[index] = tooltip_text
+ return tooltips
+
+
+def safe_convert_to_numpy(data: Sequence | None) -> list:
+ if data is None:
+ return None
+
+ # check if data is even iterable
+ try:
+ return data.to_numpy()
+ except AttributeError:
+ return np.array(data)
+
+def hovered(item, points, ev):
+ if not len(points):
+ item.setToolTip(None)
+ return
+ item.setToolTip(points[0].data())
+
+
+def clicked(item, points, ev):
+ # print('clicked')
+ pass
+
diff --git a/omc3_gui/sbs_gui.py b/omc3_gui/sbs_gui.py
new file mode 100644
index 0000000..39a03c5
--- /dev/null
+++ b/omc3_gui/sbs_gui.py
@@ -0,0 +1,45 @@
+"""
+Segment-by-Segment GUI
+----------------------
+
+Graphical user interface to run the Segment-by-Segment propagation.
+"""
+from argparse import ArgumentParser
+from pathlib import Path
+import sys
+from omc3_gui.segment_by_segment.main_controller import SbSController
+from omc3_gui.segment_by_segment.settings import Settings
+from omc3_gui.utils.log_handler import init_logging
+
+# --- For QT Debugging ----------------
+# import os
+# os.environ["QT_DEBUG_PLUGINS"] = "1"
+# -------------------------------------
+
+
+def parse_args() -> tuple[list[Path], Settings]:
+ args = ArgumentParser()
+ args.add_argument(
+ "-m",
+ "--measurements",
+ nargs="+",
+ type=Path,
+ help="Measurements to process",
+ )
+ args.add_argument(
+ "-s",
+ "--settings",
+ type=Path,
+ help="Settings file to use",
+ )
+ opt = args.parse_args()
+ return opt.measurements, opt.settings
+
+
+if __name__ == "__main__":
+ init_logging()
+ measurements, settings = parse_args()
+ sys.exit(SbSController.run_application(
+ measurements=measurements,
+ settings=settings
+ ))
\ No newline at end of file
diff --git a/omc3_gui/segment_by_segment/__init__.py b/omc3_gui/segment_by_segment/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/omc3_gui/segment_by_segment/defaults.py b/omc3_gui/segment_by_segment/defaults.py
new file mode 100644
index 0000000..4407f99
--- /dev/null
+++ b/omc3_gui/segment_by_segment/defaults.py
@@ -0,0 +1,58 @@
+"""
+Defaults
+--------
+
+Defaults for segment by segment.
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from omc3_gui.segment_by_segment.segment_model import SegmentTuple
+
+if TYPE_CHECKING:
+ from omc3_gui.segment_by_segment.measurement_model import OpticsMeasurement
+
+DEFAULT_SEGMENTS =(
+ SegmentTuple("IP1", "BPM.12L1", "BPM.12R1"),
+ SegmentTuple("IP2", "BPM.12L2", "BPM.12R2"),
+ SegmentTuple("IP5", "BPM.12L5", "BPM.12R5"),
+ SegmentTuple("IP8", "BPM.12L8", "BPM.12R8"),
+)
+
+
+LHC_ARCS = ('78', '81', '12', '23', '34', '45', '56', '67') # 78 needs to be before 81 to make lr fit
+LHC_CORRECTORS = (
+ "kqf.a{}",
+ "kqd.a{}",
+ "kqt12.{}{}b{}",
+ "kqtl11.{}{}b{}",
+ "kq10.{}{}b{}",
+ "kq9.{}{}b{}",
+ "kq8.{}{}b{}",
+ "kq7.{}{}b{}",
+ "kq6.{}{}b{}",
+ "kq5.{}{}b{}",
+ "kq4.{}{}b{}",
+ "kqsx3.{}{}",
+ "ktqx2.{}{}",
+ "ktqx1.{}{}",
+ "kqx.{}{}",
+)
+
+def get_default_correctors(measurement: OpticsMeasurement) -> str:
+ text = ""
+ if measurement.accel == "lhc":
+ text = ""
+ for ip in (1, 2, 5, 8):
+ text += f"! IP{ip} -----\n"
+ arcs = [arc for arc in LHC_ARCS if str(ip) in arc]
+ for arc, side in zip(arcs, "lr"):
+ lhc_correctors = LHC_CORRECTORS if side == "l" else LHC_CORRECTORS[::-1]
+ for corrector in lhc_correctors:
+ if "a" in corrector:
+ corrector = corrector.format(arc)
+ else:
+ corrector = corrector.format(side, ip, measurement.beam)
+ text += f"! {corrector} = {corrector};\n"
+ text += "\n"
+ return text
diff --git a/omc3_gui/segment_by_segment/help_view.py b/omc3_gui/segment_by_segment/help_view.py
new file mode 100644
index 0000000..f2c11d6
--- /dev/null
+++ b/omc3_gui/segment_by_segment/help_view.py
@@ -0,0 +1,89 @@
+"""
+Help Dialogs
+------------
+"""
+from __future__ import annotations
+
+from qtpy.QtCore import Qt
+from qtpy.QtWidgets import QMessageBox
+
+def show_help_dialog():
+ """ Displays the help dialog for the segment-by-segment GUI. """
+
+ help_text = """
+
Frequently Asked Questions
+
+
+ How do I open a Measurement?
+
+ One way to open measurements automatically, is to give them as command line
+ arguments when starting the sbs_gui, either -m or --measurements .
+ If you want to load them manually, you click the Load button.
+
+ The given/selected measurements can be either omc3 optics folders, folders containing sbs-json files
+ or sbs-json files directly.
+ The latter are created automatically in the measurement-output folder when editing a loaded measurement.
+
+
+
+ Do I have to invert my corrections when using them in the machine?
+
+ YES! (but it depends)
+ The "corrections" here are actually used to match the model opttics to the
+ mesured optics (see the info about the dashed "corr" line below).
+ Therefore you have to invert them, to actually use them as corrections in the machine.
+ NOTE that these are the MAD-X values. Make how the signs are actually
+ mapped in the machine!
+
+
+
+ What is the solid line?
+
+ The solid line is the difference between the Measurement and
+ the propagated model, i.e. the Measurement at the start (or end) of the segment
+ propagated through the nominal model via MAD-X.
+ This line therefore shows you how much the optics deviate through the segment
+ from the nominal model.
+
+
+
+ What is the dashed line that says "corr"?
+
+ This is the difference between the "corrected" propagated model and the
+ nominal propagated model.
+ This means in both cases the measured values are used as initial conditions.
+ What you are trying to achieve is a match between the dashed and the solid line,
+ because that means that now your model matches the optics in the measured data.
+
+
+
+ What is the dashed line that says "expct"?
+
+ This is the difference between the Measurement and the "corrected" propagated model
+ and is therefore the expected measured difference to the nominal model after
+ applying the correction in the machine (same as in global correction).
+ You can activate this view via the plot-settings "Expectation".
+
+
+
+ Shortcuts
+
+ In Graph:
+ Double-Click : Zoom history back one step.
+ Right-Click : Zoom history back one step.
+ Shift + Right-Click : Zoom history back all steps.
+ Alt + Right-Click : pyqtgraph context menu.
+
+
+
+ In Measurements-List:
+ Double-Click : Edit the Measurement.
+
+
+
+ """
+ msg_box = QMessageBox(icon=QMessageBox.Information)
+ msg_box.setWindowTitle("Help")
+ msg_box.setTextFormat(Qt.TextFormat.RichText)
+ msg_box.setText(help_text)
+ msg_box.exec_()
diff --git a/omc3_gui/segment_by_segment/main_controller.py b/omc3_gui/segment_by_segment/main_controller.py
new file mode 100644
index 0000000..0623188
--- /dev/null
+++ b/omc3_gui/segment_by_segment/main_controller.py
@@ -0,0 +1,878 @@
+"""
+Main Controller
+---------------
+
+This is the main controller for the Segment-by-Segment application.
+"""
+from __future__ import annotations
+
+import logging
+import re
+from functools import partial
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from omc3.sbs_propagation import segment_by_segment
+from qtpy import QtWidgets
+from qtpy.QtCore import Slot
+
+from omc3_gui.plotting.classes import DualPlotWidget
+from omc3_gui.segment_by_segment.defaults import (
+ DEFAULT_SEGMENTS,
+ get_default_correctors,
+)
+from omc3_gui.segment_by_segment.main_model import SegmentTableModel
+from omc3_gui.segment_by_segment.main_view import SbSWindow
+from omc3_gui.segment_by_segment.measurement_model import OpticsMeasurement
+from omc3_gui.segment_by_segment.measurement_view import OpticsMeasurementDialog
+from omc3_gui.segment_by_segment.plotting import plot_segment_data
+from omc3_gui.segment_by_segment.segment_model import (
+ SegmentDataModel,
+ SegmentItemModel,
+ compare_segments,
+ get_segments_from_directory,
+)
+from omc3_gui.segment_by_segment.segment_view import SegmentDialog
+from omc3_gui.segment_by_segment.settings import PlotSettings, Settings
+from omc3_gui.ui_components.base_classes_cvm import Controller
+from omc3_gui.ui_components.dataclass_ui import SettingsDialog
+from omc3_gui.ui_components.file_dialogs import OpenAnyMultiDialog, OpenAnySingleDialog
+from omc3_gui.ui_components.message_boxes import show_confirmation_dialog
+from omc3_gui.ui_components.text_editor import TextEditorDialog
+from omc3_gui.ui_components.threads import BackgroundThread
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+LOGGER = logging.getLogger(__name__)
+
+class SbSController(Controller):
+
+ settings: Settings
+ _view: SbSWindow
+
+ def __init__(self, measurements: list[Path | str] | None = None, settings: Settings | None = None):
+ super().__init__(SbSWindow())
+ self.settings: Settings = settings or Settings()
+ self._last_selected_measurement_path: Path = self.settings.main.cwd
+ self._running_tasks: list[BackgroundThread] = []
+
+ self.connect_signals()
+ self.set_measurement_interaction_buttons_enabled(False)
+ self.set_all_segment_buttons_enabled(False)
+
+ if measurements is not None:
+ self.open_measurements_from_paths(measurements)
+
+ def connect_signals(self):
+ """ Connect the signals from the GUI components (view) to the slots (controller). """
+ view: SbSWindow = self._view # for shorthand and type hinting
+
+ # Tabs -----------------------------------------------------------------
+ view.sig_tab_changed.connect(self.plot)
+
+ # Menu Bar -------------------------------------------------------------
+ view.sig_menu_settings.connect(self.show_settings)
+
+ view.add_settings_to_menu(
+ menu="View",
+ settings=self.settings.plotting,
+ hook=self.plot,
+ )
+
+ view.sig_menu_clear_all.connect(self.clear_all_data)
+
+ # Measurements -------------------------------------------------------------
+ view.button_load_measurement.clicked.connect(self.open_measurements)
+ view.button_edit_measurement.clicked.connect(self.edit_measurement)
+ view.button_remove_measurement.clicked.connect(self.remove_measurement)
+ view.button_copy_measurement.clicked.connect(self.copy_measurement)
+
+ view.button_run_matcher.clicked.connect(self.run_matcher)
+ view.button_edit_corrections.clicked.connect(self.edit_corrections)
+
+ view.sig_list_measurements_double_clicked.connect(self.edit_measurement)
+ view.sig_list_measurements_selected.connect(self.measurement_selection_changed)
+
+ # Segments -------------------------------------------------------------
+ view.button_new_segment.clicked.connect(self.new_segment)
+ view.button_copy_segment.clicked.connect(self.copy_segment)
+ view.button_default_segments.clicked.connect(self.add_default_segments)
+ view.button_remove_segment.clicked.connect(self.remove_segment)
+ view.button_run_segment.clicked.connect(self.run_segments)
+ view.button_save_segments.clicked.connect(self.save_segments)
+ view.button_load_segments.clicked.connect(self.load_segments)
+
+ view.sig_table_segments_selected.connect(self.segment_selection_changed)
+ view.sig_thread_spinner_double_clicked.connect(self._show_running_tasks)
+
+ # Tasks --------------------------------------------------------------------
+ @Slot()
+ def _update_tasks_status(self):
+ """ Update the status bar with the number of running tasks. """
+ view: SbSWindow = self._view
+ status_bar: QtWidgets.QStatusBar = view.statusBar() # seems to return it, if already exist, because spinner is there
+
+ if self._running_tasks:
+ # status_bar.show() # looks nice, but moves the window around too much ...
+ status_bar.showMessage(f"{len(self._running_tasks)} Task(s) running ...")
+ status_bar.setToolTip(
+ f"{len(self._running_tasks)} Running Task(s):\n - "
+ + "\n - ".join([task.message for task in self._running_tasks])
+ )
+ view.thread_spinner.start()
+ else:
+ status_bar.setToolTip(None)
+ status_bar.clearMessage()
+ view.thread_spinner.stop()
+ # status_bar.hide()
+
+ @Slot()
+ def _add_running_task(self, task: BackgroundThread):
+ """ Add a task to the list of running tasks. """
+
+ # Automatically remove task when finished
+ remove_task_fun = partial(self._remove_running_task, task=task)
+ task.finished.connect(remove_task_fun)
+
+ self._running_tasks.append(task)
+ self._update_tasks_status()
+
+ @Slot()
+ def _remove_running_task(self, task: BackgroundThread):
+ """ Remove a task from the list of running tasks. """
+ self._running_tasks.remove(task)
+ self._update_tasks_status()
+ if not self._running_tasks:
+ self.clear_all_data() # last task finished, update plots
+
+ @Slot()
+ def _show_running_tasks(self):
+ """ Show (i.e. log) the list of running tasks. """
+ LOGGER.info(f"Running tasks: {[task.message for task in self._running_tasks]}")
+
+ # Measurements -------------------------------------------------------------
+ def set_measurement_interaction_buttons_enabled(self, enabled: bool = True):
+ """ Enable/disable the buttons that interact with measurements.
+
+ Args:
+ enabled (bool): True to enable, False to disable.
+ """
+ view: SbSWindow = self._view
+
+ measurement_interaction_buttons = (
+ view.button_remove_measurement,
+ view.button_edit_measurement,
+ view.button_run_matcher,
+ view.button_edit_corrections,
+ view.button_copy_measurement,
+ )
+ for button in measurement_interaction_buttons:
+ button.setEnabled(enabled)
+
+ def add_measurement(self, measurement: OpticsMeasurement):
+ """ Add a measurement to the GUI.
+
+ Args:
+ measurement (OpticsMeasurement): The measurement to add.
+ """
+ view: SbSWindow = self._view # for type hinting
+ view.get_measurement_list().add_item(measurement)
+
+ @Slot()
+ def open_measurements(self):
+ """ Open the file dialog for optics measurements. """
+ view: SbSWindow = self._view
+
+ LOGGER.debug("Opening new optics measurement. Asking for paths.")
+ filenames = OpenAnyMultiDialog(
+ parent=view,
+ caption="Select Optics Folders/SbS Folders/SbS json",
+ directory=self._last_selected_measurement_path,
+ ).run_selection_dialog()
+
+ self.open_measurements_from_paths(filenames, select=True)
+
+ def open_measurements_from_paths(self, paths: Sequence[Path | str], select: bool = False):
+ """ Open the given paths as measurements. """
+ if not len(paths):
+ LOGGER.debug("No measurement paths to load.")
+ return
+
+ view: SbSWindow = self._view
+ loaded_measurements = view.get_measurement_list()
+ measurement_indices = []
+
+ for filename in paths:
+ LOGGER.debug(f"Adding: {filename!s}")
+ if filename.is_dir():
+ optics_measurement = OpticsMeasurement.from_path(filename)
+ elif filename.is_file() and filename.suffix == ".json":
+ optics_measurement = OpticsMeasurement.from_json(filename)
+ else:
+ LOGGER.error(f"Invalid file: {filename}")
+ continue
+
+ try:
+ optics_measurement.quick_check()
+ except ValueError as e:
+ LOGGER.warning(str(e)) # Maybe even popup?
+ except NameError as e:
+ LOGGER.error(f"{e!s} ({filename})")
+ continue
+
+ if self.settings.main.autoload_segments:
+ self.load_segments_for_measurement(optics_measurement)
+
+ if self.settings.main.autodefault_segments:
+ self.add_default_segments(optics_measurement)
+
+ try:
+ loaded_measurements.add_item(optics_measurement)
+ except ValueError as e:
+ LOGGER.error(str(e))
+ else:
+ if select:
+ measurement_indices.append(loaded_measurements.get_index(optics_measurement))
+
+ self._last_selected_measurement_path = filename.parent
+
+ view.set_selected_measurements(measurement_indices)
+
+ @Slot()
+ def edit_measurement(self, measurement: OpticsMeasurement | None = None):
+ """ Open the edit dialog for a measurement.
+ If no measurement is given, the currently selected measurement is used.
+
+ Args:
+ measurement (OpticsMeasurement | None, optional): The measurement to edit. Defaults to None.
+ """
+ if measurement is None:
+ try:
+ measurement = self.get_single_measurement()
+ except ValueError as e:
+ LOGGER.warning(str(e))
+ return
+
+ LOGGER.debug(f"Opening edit dialog for {measurement.display()}.")
+ view: SbSWindow = self._view
+ dialog = OpticsMeasurementDialog(
+ parent=view,
+ optics_measurement=measurement,
+ )
+ if dialog.exec_() == dialog.Accepted:
+ LOGGER.debug("Edit dialog closed. Updating measurement.")
+
+ try:
+ measurement.quick_check()
+ except ValueError as e:
+ LOGGER.warning(str(e)) # Maybe even popup?
+ except NameError as e:
+ LOGGER.error(str(e))
+ return
+
+ try:
+ measurement.to_json()
+ except IOError as e:
+ LOGGER.warning(str(e))
+
+
+ @Slot()
+ def copy_measurement(self, measurement: OpticsMeasurement | None = None):
+ """ Create a copy of the given measurement and add it to the GUI.
+ If no measurement is given, the currently selected measurement is copied.
+
+ Args:
+ measurement (OpticsMeasurement | None, optional): The measurement to copy. Defaults to None.
+ """
+ if measurement is None:
+ try:
+ measurement = self.get_single_measurement()
+ except ValueError as e:
+ LOGGER.warning(str(e))
+ return
+
+ if measurement.output_dir is None: # should not be possible, but better to catch
+ LOGGER.error("Cannot copy measurement without output directory.")
+ return
+
+ LOGGER.debug(f"Copying {measurement.display()}.")
+ new_measurement = measurement.copy()
+
+ # might already have a counter from previous copy
+ name = re.sub(r"_\d+$", "", measurement.output_dir.name)
+
+ for count in range(1, 1000): # limit to avoid infinite loop
+ new_measurement.output_dir = measurement.output_dir.with_name(f"{name}_{count:d}")
+ try:
+ self.add_measurement(new_measurement)
+ except ValueError:
+ continue
+ break
+ else:
+ LOGGER.error(
+ "Could not copy measurement. Counter limit exeeded. Not sure what went wrong."
+ )
+ return
+
+ @Slot()
+ def remove_measurement(self, measurements: Sequence[OpticsMeasurement] | None = None):
+ """ Remove measurements from the GUI.
+ If no measurements are given, the currently selected measurements are removed.
+
+ Args:
+ measurements (Sequence[OpticsMeasurement] | None, optional): The measurements to remove. Defaults to None.
+ """
+ view: SbSWindow = self._view
+
+ if measurements is None:
+ measurements = view.get_selected_measurements()
+ if not len(measurements):
+ LOGGER.warning("No measurement selected.")
+ return
+
+ view.get_measurement_list().remove_items(measurements)
+ view.set_selected_measurements()
+
+ @Slot(tuple)
+ def measurement_selection_changed(self, measurements: Sequence[OpticsMeasurement]):
+ """ Updates the GUI when the selected measurements change.
+
+ Args:
+ measurements: Sequence[OpticsMeasurement]: The new selection of measurements.
+ """
+ LOGGER.debug(f"Selected {len(measurements)} measurements.")
+ view: SbSWindow = self._view
+
+ if not len(measurements):
+ self.set_measurement_interaction_buttons_enabled(False)
+ view.set_segments_table(SegmentTableModel())
+ self.segment_selection_changed()
+ self.set_all_segment_buttons_enabled(False)
+ return
+
+ self.set_measurement_interaction_buttons_enabled(True)
+ if len(measurements) > 1:
+ view.button_edit_measurement.setEnabled(False)
+ view.button_copy_measurement.setEnabled(False)
+
+ self.set_all_segment_buttons_enabled(True)
+
+ # Group the segments for the measurements into table-items when they have the same defintion ---
+ segment_table_items: list[SegmentItemModel] = []
+
+ for measurement in measurements:
+ for segment in measurement.segments:
+ for segment_item in segment_table_items:
+ if compare_segments(segment, segment_item):
+ segment_item.append_segment(segment)
+ break
+ else:
+ segment_table_items.append(SegmentItemModel.from_segments([segment]))
+
+ # Create the segment Table to show in the GUI ---
+ segment_table = SegmentTableModel()
+ try:
+ segment_table.add_items(segment_table_items)
+ except ValueError as e:
+ LOGGER.debug(str(e))
+
+ view.set_segments_table(segment_table)
+ self.segment_selection_changed()
+
+ def get_single_measurement(self) -> OpticsMeasurement:
+ """ Get a single selected measurement from the GUI.
+ Raises ValueError if no measurement is selected or multiple measurements are selected.
+ """
+ view: SbSWindow = self._view
+ measurements = view.get_selected_measurements()
+
+ if len(measurements) == 0:
+ raise ValueError("Please select at least one measurement.")
+
+ if len(measurements) > 1:
+ raise ValueError("Please select only one measurement.")
+
+ return measurements[0]
+
+ @ Slot()
+ def run_matcher(self) -> None:
+ """ Run the matcher. """
+ view: SbSWindow = self._view
+ view.showErrorDialog("Error: Not Implemented", "The Segment-by-Segment Matcher is not implemented yet.")
+ # TODO!
+
+ @ Slot()
+ def edit_corrections(self) -> None:
+ """ Edit the corrections file.
+
+ The following logic is applied to the selected measurements:
+
+ - Check if a measurement is selected:
+ - If not show error.
+
+ - Check if multiple measurements are selected, if so:
+ a) they all have the same correction file: open TextEditor,
+ c) they have different correction files: show error
+ b) some have the same correction file, all others have none: ask if the others should also get this one
+ d) they have no correction file: ask for path and then open TextEditor with that file
+ - If only a single measurement is s
+ """
+ view: SbSWindow = self._view
+
+ selected_measurements: tuple[OpticsMeasurement] = view.get_selected_measurements()
+ if not selected_measurements:
+ LOGGER.error("Please select at least one measurement.")
+ return
+
+ correction_files = {measurement.corrections for measurement in selected_measurements if measurement.corrections}
+ if len(correction_files) > 1:
+ view.showErrorDialog(
+ title="Error: Multiple correction files",
+ message="Please select only measurements using the same correction file."
+ )
+ return
+
+ # Only one or none correction file within the selection measurements from here ---
+ if len(correction_files) == 0: # If there is none, ask user to provide one
+ LOGGER.debug("No correction file selected. Asking.")
+ directory = self.settings.main.cwd
+ if len(selected_measurements) == 1:
+ directory = selected_measurements[0].measurement_dir
+
+ dialog = OpenAnySingleDialog(
+ caption="Select a new or existing correction file for the selected measurement(s).",
+ existing=False, # can be a new file
+ parent=view,
+ directory=directory
+ )
+ correction_file = dialog.run_selection_dialog()
+ if correction_file is None:
+ LOGGER.error("No correction file to edit selected.")
+ return
+
+ else: # There is only one. Maybe use it for all selected measurements
+ correction_file = correction_files.pop()
+
+ has_no_corrections = [m for m in selected_measurements if not m.corrections]
+ if has_no_corrections:
+ measurements_string = '\n'.join([m.display() for m in has_no_corrections])
+ use_for_all = show_confirmation_dialog(
+ question=(
+ f"The measurements\n\n{measurements_string}\n\n"
+ "have no correction file assigned.\n"
+ f"Do you want to use\n\n{correction_file}\n\n"
+ "also for these measurements?"
+ ),
+ title="There are unset correction files",
+ )
+ if not use_for_all:
+ LOGGER.error(
+ "User does not want to use the same correction file for all selected measurements."
+ "Select a correction file for each measurement manually."
+ )
+ return
+
+ # Use the correction file for all selected measurements ---
+ # We could simply assign the correction file to all (as the other have the same file),
+ # but this way we can log the changes (if even any).
+ for measurement in selected_measurements:
+ if not measurement.corrections:
+ LOGGER.debug(f"Setting {correction_file} for {measurement.display()}")
+ measurement.corrections = correction_file
+
+ # Open the TextEditor for the selected correction file ---
+ LOGGER.debug(f"Opening TextEditor for correction file: {correction_file}.")
+ edit_dialog = TextEditorDialog(correction_file)
+
+ if not correction_file.exists() and self.settings.main.suggest_correctors:
+ text = get_default_correctors(selected_measurements[0]) # bit hacky but ok for now?
+ edit_dialog.text_edit.setPlainText(text)
+
+ edit_dialog.exec_()
+
+
+ # Segments -----------------------------------------------------------------
+
+ def set_segment_interaction_buttons_enabled(self, enabled: bool = True):
+ """ Enable/disable the buttons that interact with segments.
+
+ Args:
+ enabled (bool): True to enable, False to disable.
+ """
+ view: SbSWindow = self._view
+
+ segment_interaction_buttons = (
+ view.button_run_segment,
+ view.button_copy_segment,
+ view.button_remove_segment,
+ )
+ for button in segment_interaction_buttons:
+ button.setEnabled(enabled)
+
+ def set_all_segment_buttons_enabled(self, enabled: bool = True):
+ """ Enable/disable all segment buttons.
+
+ Args:
+ enabled (bool): True to enable, False to disable.
+ """
+ view: SbSWindow = self._view
+
+ segment_buttons = (
+ view.button_run_segment,
+ view.button_copy_segment,
+ view.button_remove_segment,
+ view.button_new_segment,
+ view.button_default_segments,
+ view.button_save_segments,
+ view.button_load_segments,
+ )
+ for button in segment_buttons:
+ button.setEnabled(enabled)
+
+ @Slot(tuple)
+ def segment_selection_changed(self, segments: Sequence[SegmentItemModel] | None = None):
+ """ Updates the GUI when the selected segments change.
+ If no segments are given, the currently selected segments are used.
+
+ Args:
+ segments: Sequence[SegmentItemModel]: The new selection of segments.
+ """
+ view: SbSWindow = self._view
+ self.clear_plots()
+
+ if segments is None:
+ segments = view.get_selected_segments()
+
+ LOGGER.debug(f"{len(segments)} Segment(s) selected.")
+ if not len(segments):
+ self.set_segment_interaction_buttons_enabled(False)
+ return
+
+ self.set_segment_interaction_buttons_enabled(True)
+ self.plot()
+
+ @Slot()
+ def add_default_segments(self):
+ """ Add default segments to the currently selected measurements.
+ These segments are defined in :data:`omc3_gui.segment_by_segment.defaults.DEFAULT_SEGMENTS`.
+ """
+ LOGGER.debug("Adding default segments.")
+ view: SbSWindow = self._view
+
+ selected_measurements = view.get_selected_measurements()
+ if not selected_measurements:
+ LOGGER.error("Please select at least one measurement.")
+ return
+
+ for measurement in selected_measurements:
+ self.add_default_segements_to_measurement(measurement)
+
+ self.measurement_selection_changed(selected_measurements)
+
+ def add_default_segements_to_measurement(self, measurement: OpticsMeasurement):
+ """ Add default segments to the given measurement.
+ These segments are defined in :data:`omc3_gui.segment_by_segment.defaults.DEFAULT_SEGMENTS`.
+
+ Args:
+ measurement (OpticsMeasurement): The measurement to add segments to.
+ """
+ if measurement.beam is not None: # LHC
+ for segment_tuple in DEFAULT_SEGMENTS:
+ segment = SegmentDataModel(measurement, *segment_tuple)
+ segment.start = f"{segment.start}.B{measurement.beam}"
+ segment.end = f"{segment.end}.B{measurement.beam}"
+ measurement.try_add_segment(segment, silent=True)
+ return
+
+ # TODO: Implement for other accelerators
+ LOGGER.error(f"No beam found in measurement {measurement.display()}. Cannot add default segments.")
+
+
+ @Slot()
+ def new_segment(self):
+ """ Create a new segment and add it to the currently selected measurements. """
+ LOGGER.debug("Creating new segment.")
+ view: SbSWindow = self._view
+
+ selected_measurements = view.get_selected_measurements()
+ if not selected_measurements:
+ LOGGER.error("Please select at least one measurement.")
+ return
+
+ LOGGER.debug("Opening edit dialog for a new segment.")
+ dialog = SegmentDialog(parent=view)
+ dialog.validate_only_modified = False
+ if dialog.exec_() == dialog.Rejected:
+ LOGGER.debug("Segment dialog cancelled.")
+ return
+
+ LOGGER.debug("Segment dialog closed. Updating segement.")
+ for measurement in selected_measurements:
+ new_segment_copy = dialog.segment.copy()
+ new_segment_copy.measurement = measurement
+ measurement.try_add_segment(new_segment_copy)
+
+ self.measurement_selection_changed(selected_measurements)
+
+ @Slot()
+ def copy_segment(self, segments: Sequence[SegmentItemModel] | None = None):
+ """ Create a copy of the given segments and add them to the currently selected measurements.
+ If no segments are given, the currently selected segments are copied.
+
+ Args:
+ segments: Sequence[SegmentItemModel]: The segments to copy.
+ """
+ view: SbSWindow = self._view
+
+ if segments is None:
+ segments = view.get_selected_segments()
+
+ if not segments:
+ LOGGER.error("Please select at least one segment to copy.")
+ return
+
+ LOGGER.debug(f"Copying {len(segments)} segments.")
+ selected_measurements = view.get_selected_measurements()
+ if not selected_measurements:
+ LOGGER.error("Please select at least one measurement.")
+ return
+
+ for segment_item in segments:
+ new_segment_name = f"{segment_item.name}_copy"
+ for measurement in selected_measurements:
+ # Check if copied segment name already exists in one of the measurements
+ try:
+ measurement.get_segment_by_name(new_segment_name)
+ except NameError:
+ pass
+ else:
+ LOGGER.error(
+ f"Could not create copy \"{new_segment_name}\" as it already exists in {measurement.display()}."
+ )
+ break
+ else:
+ # None of the measurements have the copied segment name, so add to the measurements
+ for measurement in selected_measurements:
+ for segment in segment_item.segments:
+ new_segment = segment.copy()
+ new_segment.name = new_segment_name
+ new_segment.measurement = measurement
+ measurement.try_add_segment(new_segment)
+
+ self.measurement_selection_changed(selected_measurements)
+
+ @Slot()
+ def remove_segment(self, segments: Sequence[SegmentItemModel] | None = None):
+ """ Remove the given segments from the currently selected measurements.
+ If no segments are given, the currently selected segments are removed.
+
+ Args:
+ segments: Sequence[SegmentItemModel]: The segments to remove.
+ """
+ view: SbSWindow = self._view
+
+ if segments is None:
+ segments = view.get_selected_segments()
+
+ if not segments:
+ LOGGER.error("Please select at least one segment to remove.")
+ return
+
+ LOGGER.debug(f"Removing {len(segments)} segments.")
+
+ for segment_item in segments:
+ for segment_data in segment_item.segments:
+ segment_data.measurement.remove_segment(segment_data)
+
+ self.measurement_selection_changed(view.get_selected_measurements())
+
+ @Slot()
+ def load_segments(self):
+ self.load_segments_for_selected_measurements()
+ # LOGGER.debug("Loading segments from file/folder.")
+ # TODO: implement file saving and loading and ask user which one to do.
+
+ def load_segments_for_selected_measurements(self):
+ """ Load segments for the currently selected measurements. """
+ LOGGER.debug("Loading segments for selected measurements.")
+ view: SbSWindow = self._view
+
+ selected_measurements = view.get_selected_measurements()
+ if not selected_measurements:
+ LOGGER.error("Please select at least one measurement.")
+ return
+
+ for measurement in selected_measurements:
+ self.load_segments_for_measurement(measurement)
+
+ # Update the view
+ self.measurement_selection_changed(selected_measurements)
+
+ def load_segments_for_measurement(self, measurement: OpticsMeasurement):
+ """ Load segments for the given measurement. """
+ LOGGER.debug(f"Loading segments for {measurement.display()}.")
+
+ segments = get_segments_from_directory(measurement.output_dir)
+ if not segments:
+ LOGGER.debug(f"No segments found in {measurement.output_dir}.")
+ return
+
+ for segment_tuple in segments:
+ segment = SegmentDataModel(measurement, *segment_tuple)
+ measurement.try_add_segment(segment)
+
+ @Slot()
+ def save_segments(self):
+ LOGGER.debug("Saving segments to a file.")
+ view: SbSWindow = self._view
+ view.showErrorDialog("Error: Not Implemented", "The save segments function is not implemented yet.")
+ # TODO
+ # Save current segements to a json file
+
+ @Slot()
+ def run_segments(self, segments: Sequence[SegmentItemModel] | None = None):
+ """ Run the given segments on the currently selected measurements.
+ If no segments are given, the currently selected segments are run.
+
+ Args:
+ segments: Sequence[SegmentItemModel]: The segments to run.
+ """
+ view: SbSWindow = self._view
+
+ if segments is None:
+ segments: Sequence[SegmentItemModel] = view.get_selected_segments()
+
+ if not segments:
+ LOGGER.error("Please select at least one segment to run.")
+ return
+
+ LOGGER.debug(f"Running {len(segments)} segments.")
+ selected_measurements: Sequence[OpticsMeasurement] = view.get_selected_measurements()
+ if not selected_measurements:
+ LOGGER.error("Please select at least one measurement.")
+ return
+
+ all_selected_segment_data: list[SegmentDataModel] = [sdata for s in segments for sdata in s.segments]
+ measurements_to_run: list[OpticsMeasurement] = [
+ meas for meas in selected_measurements if any(s in meas.segments for s in all_selected_segment_data)
+ ]
+
+ for idx, measurement in enumerate(measurements_to_run):
+ # Filter segments that are in the measurement and sort into segments/elements
+ selected_segments_in_meas = [s for s in all_selected_segment_data if s in measurement.segments]
+ segment_parameters = [s.to_input_string() for s in selected_segments_in_meas if not s.is_element()]
+ element_parameters = [s.to_input_string() for s in selected_segments_in_meas if s.is_element()]
+
+ # Create sbs-callable from measurement/inputs
+ sbs_function = partial(
+ segment_by_segment,
+ **measurement.get_sbs_parameters(),
+ segments=segment_parameters or None,
+ elements=element_parameters or None,
+ )
+
+ # Create thread
+ measurement_task = BackgroundThread(
+ function=sbs_function,
+ message=f"SbS for {measurement.display()}",
+ )
+
+ # For Real Use: Run Task ---
+ LOGGER.info(f"Starting {measurement_task.message}")
+ self._add_running_task(task=measurement_task)
+ measurement_task.start()
+ # -------------------------------------
+
+ # For Debugging: Start sbs directly ---
+ # sbs_function()
+ # self.clear_all_data()
+ # LOGGER.info(f"Finished {measurement_task.message}")
+ # -------------------------------------
+
+ @Slot()
+ def clear_all_data(self):
+ """ Clear all segment data on all measurements. """
+ view: SbSWindow = self._view
+ measurements = view.get_all_measurements()
+ for meas in measurements:
+ clear_segments(meas.segments)
+ self.plot() # update plots -> reads needed data new
+
+# Plotting ---------------------------------------------------------------------
+ def plot(self, fail_ok: bool = True):
+ """ Trigger a plot update with the currently selected segments.
+
+ This function is called when the user changes the selected segments or the settings.
+ As a segment selection change is often triggered, i.e. when the user clicks on a segment in the table,
+ e.g. to actually run this segment, one should not consider that the plot NEEDS to be updated.
+ Hence in the default settings with ``fail_ok=True``, this function will mostly log to debug.
+
+ This function is also called after the plotting settings are changed
+ (either in the settings dialog or in the menu), hence this is also a good place to warn the user
+ if he did some mistakes there.
+ """
+ log_function = LOGGER.debug if fail_ok else LOGGER.error
+
+ view: SbSWindow = self._view
+ settings: PlotSettings = self.settings.plotting
+ definition, widget = view.get_current_tab()
+
+ self.clear_plots()
+
+ if not settings.forward and not settings.backward:
+ LOGGER.error("Please enable at least one propagation method to show.")
+ return
+
+ widget.set_connect_x(settings.connect_x)
+ widget.set_connect_y(settings.connect_y)
+
+ segments = view.get_selected_segments()
+ if not len(segments):
+ log_function("Not plotting, no segments selected.")
+ return
+
+ segments_data: list[SegmentDataModel] = [s_data for s in segments for s_data in s.segments if s_data.has_run()]
+ if not len(segments_data):
+ log_function("Not plotting, no segments have been run.")
+ return
+
+ if settings.same_start and not settings.model_s: # only an issue if they all start at 0
+ starts = {re.sub(r"\.B[12]$", "", s.start, flags=re.IGNORECASE) for s in segments_data}
+ if len(starts) > 1:
+ log_function("Not plotting, segments have different start BPMs (see 'Same Start' in settings).")
+ return
+
+ plot_segment_data(
+ widget=widget,
+ definitions=definition,
+ segments=segments_data,
+ settings=settings,
+ )
+
+ def clear_plots(self):
+ """ Clear the plots. """
+ view: SbSWindow = self._view
+ widget: DualPlotWidget = view.get_current_tab()[1]
+ widget.clear()
+
+# Other ------------------------------------------------------------------------
+ @Slot()
+ def show_settings(self):
+ LOGGER.debug("Showing settings.")
+ settings_dialog = SettingsDialog(settings=self.settings)
+ if settings_dialog.exec_():
+ view: SbSWindow = self._view
+ view.update_menu_settings(
+ menu="View",
+ settings=self.settings.plotting,
+ )
+ self.plot()
+
+
+# Helper Functions ------------------------------------------------------------
+
+def clear_segments(segments: Sequence[SegmentDataModel]):
+ """ Clear all chached segment data, so that the GUI loads the new SbS data. """
+ for segment in segments:
+ segment.data.clear()
diff --git a/omc3_gui/segment_by_segment/main_model.py b/omc3_gui/segment_by_segment/main_model.py
new file mode 100644
index 0000000..22b200f
--- /dev/null
+++ b/omc3_gui/segment_by_segment/main_model.py
@@ -0,0 +1,141 @@
+"""
+Main Model
+----------
+
+This is the main model for the Segment-by-Segment application.
+"""
+from __future__ import annotations
+
+import enum
+import logging
+
+from qtpy import QtCore
+from qtpy.QtCore import Qt
+
+from omc3_gui.segment_by_segment.measurement_model import OpticsMeasurement
+from omc3_gui.segment_by_segment.segment_model import SegmentItemModel
+from omc3_gui.ui_components.item_models import UniqueItemListModel
+
+ItemDataRole = Qt.ItemDataRole
+ItemFlag = Qt.ItemFlag
+
+LOGGER = logging.getLogger(__name__)
+
+class MeasurementListModel(QtCore.QAbstractListModel, UniqueItemListModel):
+
+ _items: list[OpticsMeasurement] # only for the IDE
+
+ class ColorIDs(enum.IntEnum):
+ NONE = 0
+ BEAM1 = enum.auto()
+ BEAM2 = enum.auto()
+ RING1 = enum.auto()
+ RING2 = enum.auto()
+ RING3 = enum.auto()
+ RING4 = enum.auto()
+
+ @classmethod
+ def get_color(cls, meas: OpticsMeasurement) -> int:
+ if meas.accel == "lhc":
+ return getattr(cls, f"BEAM{meas.beam}")
+
+ if meas.accel == "psb":
+ return getattr(cls, f"RING{meas.ring}")
+
+ return cls.NONE
+
+ def __init__(self, *args, **kwargs):
+ super(QtCore.QAbstractListModel, self).__init__(*args, **kwargs)
+ super(UniqueItemListModel, self).__init__()
+
+ def data(self, index: QtCore.QModelIndex, role: int = ItemDataRole.DisplayRole):
+
+ meas: OpticsMeasurement = self.get_item_at(index.row())
+ # https://doc.qt.io/qt-5/qt.html#ItemDataRole-enum
+ if role == ItemDataRole.DisplayRole:
+ return meas.display()
+
+ if role == ItemDataRole.ToolTipRole:
+ return meas.tooltip()
+
+ if role == Qt.TextColorRole:
+ return self.ColorIDs.get_color(meas)
+
+ if role == ItemDataRole.UserRole:
+ return meas
+
+ def rowCount(self, index: QtCore.QModelIndex = None):
+ return len(self._items)
+
+ @property
+ def items(self):
+ return self._items
+
+
+class SegmentTableModel(QtCore.QAbstractTableModel, UniqueItemListModel):
+ """ Data Model for the table of segments.
+
+ Hint: Uses Qt.UserRole to retrieve the actual segment.
+ """
+
+ _COLUMNS: list[str] = ["Segment", "Start", "End"] # display names
+ _ATTRIBUTES: list[str] = ["name", "start", "end"] # segment attributes
+
+ _items: list[SegmentItemModel] # only for the IDE
+
+ def __init__(self, *args, **kwargs):
+ super(QtCore.QAbstractTableModel, self).__init__(*args, **kwargs)
+ super(UniqueItemListModel, self).__init__() # Items need to be unique
+
+ def headerData(self, section, orientation, role=ItemDataRole.DisplayRole):
+ """ Sets the header of the table. """
+ # When we are displaying the header, use the display column names
+ if orientation == QtCore.Qt.Horizontal and role == ItemDataRole.DisplayRole:
+ return self._COLUMNS[section]
+
+ # Otherwise whatever the default is
+ return super().headerData(section, orientation, role)
+
+ def rowCount(self, parent=QtCore.QModelIndex()):
+ """ Returns the number of rows in the model. """
+ return len(self._items)
+
+ def columnCount(self, parent=QtCore.QModelIndex()):
+ """ Returns the number of columns in the model. """
+ return len(self._COLUMNS)
+
+ def data(self, index: QtCore.QModelIndex, role=ItemDataRole.DisplayRole):
+ """ Return the data, depending on index and role. """
+ i = index.row()
+ j = index.column()
+ segment: SegmentItemModel = self.get_item_at(i)
+
+ if role == ItemDataRole.DisplayRole or role == ItemDataRole.EditRole:
+ return str(getattr(segment, self._ATTRIBUTES[j]))
+
+ if role == ItemDataRole.ToolTipRole:
+ return segment.tooltip()
+
+ if role == ItemDataRole.UserRole:
+ return segment
+
+ def setData(self, index, value, role):
+ """ Set the data, depending on index and role. """
+ i = index.row()
+ j = index.column()
+ segment: SegmentItemModel = self.get_item_at(i)
+
+ if role == ItemDataRole.EditRole:
+ if value is None or value == "":
+ return False
+
+ attribute = self._ATTRIBUTES[j]
+ setattr(segment, attribute, value)
+
+ self.dataChanged.emit(index, index)
+ return True
+
+ def flags(self, index):
+ """ Set the flags for the given index.
+ At the moment: all elements are editable and selectable. """
+ return ItemFlag.ItemIsEnabled | ItemFlag.ItemIsEditable | ItemFlag.ItemIsSelectable
diff --git a/omc3_gui/segment_by_segment/main_view.py b/omc3_gui/segment_by_segment/main_view.py
new file mode 100644
index 0000000..19a45bb
--- /dev/null
+++ b/omc3_gui/segment_by_segment/main_view.py
@@ -0,0 +1,471 @@
+"""
+Main View
+---------
+
+This is the main view for the Segment-by-Segment application.
+"""
+# from omc3_gui.segment_by_segment.segment_by_segment_ui import Ui_main_window
+from __future__ import annotations
+
+import logging
+from collections.abc import Sequence
+from dataclasses import fields
+from functools import partial
+
+from omc3.definitions.optics import (
+ ALPHA_COLUMN,
+ BETA_COLUMN,
+ DISPERSION_COLUMN,
+ PHASE_COLUMN,
+)
+from qtpy import QtGui, QtWidgets
+from qtpy.QtCore import QItemSelectionModel, QModelIndex, Qt, Signal, Slot
+
+from omc3_gui.plotting.classes import DualPlotWidget
+from omc3_gui.segment_by_segment.help_view import show_help_dialog
+from omc3_gui.segment_by_segment.main_model import (
+ MeasurementListModel,
+ SegmentTableModel,
+)
+from omc3_gui.segment_by_segment.measurement_model import OpticsMeasurement
+from omc3_gui.segment_by_segment.plotting import DualPlotDefinition
+from omc3_gui.segment_by_segment.segment_model import SegmentItemModel
+from omc3_gui.ui_components import colors
+from omc3_gui.ui_components.base_classes_cvm import View
+from omc3_gui.ui_components.styles import MONOSPACED_TOOLTIP
+from omc3_gui.ui_components.widgets import (
+ ChangeButton,
+ DefaultButton,
+ OpenButton,
+ RemoveButton,
+ RunButton,
+)
+from omc3_gui.utils.counter import HorizontalGridLayoutFiller
+from omc3_gui.utils.iteration_classes import IterClass
+
+ItemDataRole = Qt.ItemDataRole
+LOGGER = logging.getLogger(__name__)
+
+
+class Tabs(IterClass):
+ """ Define the Tabs and the things to plot in them. """
+ PHASE: DualPlotDefinition = DualPlotDefinition.generate_xy("Phase", "phase", PHASE_COLUMN)
+ BETA: DualPlotDefinition = DualPlotDefinition.generate_xy("Beta", "beta_phase", BETA_COLUMN)
+ ALPHA: DualPlotDefinition = DualPlotDefinition.generate_xy("Alpha", "alpha_phase", ALPHA_COLUMN)
+ DISPERSION: DualPlotDefinition = DualPlotDefinition.generate_xy("Dispersion", "dispersion", DISPERSION_COLUMN)
+ F1001AP: DualPlotDefinition = DualPlotDefinition.generate_amplitude_phase("f1001")
+ F1001RI: DualPlotDefinition = DualPlotDefinition.generate_real_imag("f1001")
+ F1010AP: DualPlotDefinition = DualPlotDefinition.generate_amplitude_phase("f1010")
+ F1010RI: DualPlotDefinition = DualPlotDefinition.generate_real_imag("f1010")
+
+
+class SbSWindow(View):
+ WINDOW_TITLE = "OMC Segment-by-Segment"
+
+ # QtSignals need to be defined as class-attributes
+ sig_list_measurements_double_clicked = Signal(OpticsMeasurement)
+ sig_list_measurements_selected = Signal(tuple) # Tuple[OpticsMeasurement]
+ sig_table_segments_selected = Signal(tuple)
+ sig_thread_spinner_double_clicked = Signal()
+ sig_tab_changed = Signal()
+
+ # Menu Signals ---
+ sig_menu_settings = Signal()
+ sig_menu_clear_all = Signal()
+
+ def __init__(self, parent=None):
+
+ super().__init__(parent)
+ self.setWindowTitle(self.WINDOW_TITLE)
+
+ # List of UI elements accessible as instance-attributes:
+ # Widgets ---
+ self._cental: QtWidgets.QSplitter = None
+ self._tabs_widget: QtWidgets.QTabWidget = None
+ self._list_view_measurements: QtWidgets.QListView = None
+ self._table_segments: QtWidgets.QTableView = None
+
+ # Buttons ---
+ self.button_load_measurement: QtWidgets.QPushButton = None
+ self.button_remove_measurement: QtWidgets.QPushButton = None
+ self.button_edit_measurement: QtWidgets.QPushButton = None
+ self.button_copy_measurement: QtWidgets.QPushButton = None
+ self.button_edit_corrections: QtWidgets.QPushButton = None
+ self.button_run_matcher: QtWidgets.QPushButton = None
+
+ self.button_run_segment: QtWidgets.QPushButton = None
+ self.button_remove_segment: QtWidgets.QPushButton = None
+ self.button_copy_segment: QtWidgets.QPushButton = None
+ self.button_new_segment: QtWidgets.QPushButton = None
+ self.button_default_segments: QtWidgets.QPushButton = None
+ self.button_save_segments: QtWidgets.QPushButton = None
+ self.button_load_segments: QtWidgets.QPushButton = None
+
+ # Build GUI and connect Signals ---
+ self._add_menus()
+ self._build_gui()
+ self._connect_signals()
+
+ def _connect_signals(self):
+ """ Connect signals with slots.
+ Here only the basic signals are connected, the ones requireing more complex logic
+ or calling different views are connected in the controller.
+ """
+ # Optics Measurements ---
+ self._list_view_measurements.doubleClicked.connect(self._handle_list_measurements_double_clicked)
+ self._list_view_measurements.selectionModel().selectionChanged.connect(self._handle_list_measurements_selected)
+ self._tabs_widget.currentChanged.connect(self._handle_tab_changed)
+
+ # Slots --------------------------------------------------------------------
+ @Slot(QModelIndex)
+ def _handle_list_measurements_double_clicked(self, idx):
+ LOGGER.debug(f"Entry in Optics List double-clicked: {idx.data(role=Qt.DisplayRole)}")
+ self.sig_list_measurements_double_clicked.emit(idx.data(role=Qt.UserRole))
+
+ @Slot()
+ def _handle_list_measurements_selected(self):
+ LOGGER.debug("Optics List selection changed.")
+ selected_measurements = self.get_selected_measurements()
+ self.sig_list_measurements_selected.emit(selected_measurements)
+
+ @Slot()
+ def _handle_table_segments_selected(self):
+ LOGGER.debug("Segment Table selection changed.")
+ selected_segments = self.get_selected_segments()
+ self.sig_table_segments_selected.emit(selected_segments)
+
+ @Slot()
+ def _handle_tab_changed(self):
+ LOGGER.debug("Tab changed.")
+ self.sig_tab_changed.emit()
+
+ # Menu ---------------------------------------------------------------------
+ def _add_menus(self):
+ # File ---
+ file_menu: QtWidgets.QMenu = self.get_action_by_title("File") # defined in View-class
+ file_menu.setTitle("SbS-GUI")
+
+ # Settings ---
+ menu_settings = QtWidgets.QAction("Settings", self)
+ menu_settings.setIcon(
+ QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
+ )
+ menu_settings.triggered.connect(self.sig_menu_settings.emit)
+
+ # insert before the last entry (which is "Exit")
+ file_menu.insertAction(file_menu.actions()[-1], menu_settings)
+
+ # Help ---
+ help_menu: QtWidgets.QMenu = self.get_action_by_title("Help") # defined in View-class
+
+ # Clear All -
+ menu_clear_all = QtWidgets.QAction("Reload Data", self)
+ menu_clear_all.setIcon(
+ QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogResetButton)
+ )
+ menu_clear_all.triggered.connect(self.sig_menu_clear_all.emit)
+ help_menu.insertAction(help_menu.actions()[-1], menu_clear_all)
+
+ menu_show_help = QtWidgets.QAction("Show Help", self)
+ menu_show_help.setIcon(
+ QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxQuestion)
+ )
+ menu_show_help.triggered.connect(show_help_dialog) # more of a controller thing, but OK for this one
+ help_menu.insertAction(help_menu.actions()[-1], menu_show_help)
+
+ def add_settings_to_menu(self, menu: str, settings: object, names: Sequence[str] | None = None, hook: callable = None):
+ """ Add quick-access checkboxes to the menu which are connected to the respective attributes in settings.
+
+ Args:
+ menu (str): Main menu name to add the settings to.
+ settings (object): Settings to connect with the menu. Assumes dataclasses.
+ names (Sequence[str] | None): Which fields to connect. All fields need to be boolean.
+ hook (callable | None): Function to call after the settings have been updated.
+
+ """
+ qmenu: QtWidgets.QMenu = self.get_action_by_title(menu)
+
+ def update_settings(value: bool, name: str):
+ setattr(settings, name, value)
+ if hook is not None:
+ hook()
+
+ qmenu.addSeparator()
+ for field in fields(settings):
+ if names is not None and field.name not in names:
+ continue
+
+ if field.name.startswith("_"):
+ continue
+
+ value = getattr(settings, field.name)
+ if not isinstance(value, bool):
+ continue
+
+ label = field.metadata.get("label", field.name)
+ entry = QtWidgets.QAction(label, self)
+ entry.setCheckable(True)
+ entry.setChecked(value)
+ entry.toggled.connect(partial(update_settings, name=field.name))
+ qmenu.addAction(entry)
+ qmenu.addSeparator()
+
+ def update_menu_settings(self, menu: str, settings: object, names: Sequence[str] | None = None):
+ """ Update the menu settings.
+ See :func:`add_settings_to_menu`.
+
+ Args:
+ menu (str): Main menu name where the settings are located.
+ settings (object): Settings to connected with the menu. Assumes dataclasses.
+ names (Sequence[str] | None): Which fields to connect. All fields need to be boolean.
+ """
+ qmenu: QtWidgets.QMenu = self.get_action_by_title(menu)
+
+ for field in fields(settings):
+ if names is not None and field.name not in names:
+ continue
+
+ if field.name.startswith("_"):
+ continue
+
+ label = field.metadata.get("label", field.name)
+ entry: QtWidgets.QAction = self.get_action_by_title(label, parent=qmenu)
+ if entry is None:
+ continue
+
+ entry.setChecked(getattr(settings, field.name))
+
+ # Build Main UI-------------------------------------------------------------
+ def _build_gui(self):
+ self._central = QtWidgets.QSplitter(Qt.Horizontal)
+
+ def build_navigation_widget(): # --- Left Hand Side
+ navigation_widget = QtWidgets.QSplitter(Qt.Vertical)
+
+ def build_navigation_top():
+ nav_top = QtWidgets.QWidget()
+
+ layout = QtWidgets.QVBoxLayout()
+ nav_top.setLayout(layout)
+ layout.addWidget(QtWidgets.QLabel("Loaded Optics:"))
+
+ self._list_view_measurements = MeasurementListView()
+ layout.addWidget(self._list_view_measurements)
+
+ def build_measurement_buttons():
+ grid_buttons = QtWidgets.QGridLayout()
+ grid_buttons_filler = HorizontalGridLayoutFiller(layout=grid_buttons, cols=3)
+
+ load = OpenButton("Load")
+ load.setToolTip("Load a measurement, i.e. omc3-optics output folder.")
+ grid_buttons_filler.add(load)
+ self.button_load_measurement = load
+
+ copy = DefaultButton("Copy")
+ copy.setToolTip("Create a virtual copy of the measurement, with a different output dir.")
+ grid_buttons_filler.add(copy)
+ self.button_copy_measurement = copy
+
+ remove = RemoveButton()
+ remove.setToolTip("Remove the currently selected measurement(s).")
+ grid_buttons_filler.add(remove)
+ self.button_remove_measurement = remove
+
+ matcher = RunButton("Run Matcher")
+ matcher.setToolTip("Run the Segment-by-Segment Matcher.")
+ grid_buttons_filler.add(matcher)
+ self.button_run_matcher = matcher
+
+ edit = DefaultButton("Edit")
+ edit.setToolTip("Edit the settings of the currently selected measurement.")
+ grid_buttons_filler.add(edit)
+ self.button_edit_measurement = edit
+
+ edit_corrections = ChangeButton("Corrections")
+ edit_corrections.setToolTip("Edit the corrections file of the currently selected measurement.")
+ grid_buttons_filler.add(edit_corrections)
+ self.button_edit_corrections = edit_corrections
+
+ return grid_buttons
+
+ layout.addLayout(build_measurement_buttons())
+ return nav_top
+ navigation_widget.addWidget(build_navigation_top())
+
+ def build_navigation_bottom():
+ nav_bottom = QtWidgets.QWidget()
+
+ layout = QtWidgets.QVBoxLayout()
+ nav_bottom.setLayout(layout)
+
+ layout.addWidget(QtWidgets.QLabel("Segments:"))
+
+ self._table_segments = SegmentTableView()
+ layout.addWidget(self._table_segments)
+
+ def build_segment_buttons():
+ grid_buttons = QtWidgets.QGridLayout()
+ grid_buttons_filler = HorizontalGridLayoutFiller(layout=grid_buttons, cols=3)
+
+ run = RunButton("Run Segment(s)")
+ run.setToolTip("Run the currently selected segment(s).")
+ grid_buttons_filler.add(run, col_span=3)
+ self.button_run_segment = run
+
+ new = OpenButton("New")
+ new.setToolTip("Add a new segment.")
+ grid_buttons_filler.add(new)
+ self.button_new_segment = new
+
+ default = ChangeButton("Add Defaults")
+ default.setToolTip(
+ "Add default segments for the currently selected measurements (if not already present)."
+ )
+ grid_buttons_filler.add(default)
+ self.button_default_segments = default
+
+ copy = DefaultButton("Copy")
+ copy.setToolTip("Create a copy of the currently selected segment(s).")
+ grid_buttons_filler.add(copy)
+ self.button_copy_segment = copy
+
+ remove = RemoveButton("Remove")
+ remove.setToolTip("Remove the currently selected segment(s) from the list (does not delete files).")
+ grid_buttons_filler.add(remove)
+ self.button_remove_segment = remove
+
+ save = DefaultButton("Save")
+ save.setToolTip("Save current segments definitions to a json file.")
+ grid_buttons_filler.add(save)
+ self.button_save_segments = save
+
+ load = DefaultButton("Load")
+ load.setToolTip("Load segments definitions from an existing SbS-Folder or a json file.")
+ grid_buttons_filler.add(load)
+ self.button_load_segments = load
+
+ return grid_buttons
+ layout.addLayout(build_segment_buttons())
+ return nav_bottom
+
+ navigation_widget.addWidget(build_navigation_bottom())
+ return navigation_widget
+ self._central.addWidget(build_navigation_widget())
+
+ def build_tabs_widget(): # --- Right Hand Side
+ self._tabs_widget = QtWidgets.QTabWidget()
+ for tab in Tabs.values():
+ tab: DualPlotDefinition
+ self._tabs_widget.addTab(DualPlotWidget(), tab.name)
+ return self._tabs_widget
+
+ self._central.addWidget(build_tabs_widget())
+
+ # Set up main widget layout ----
+ self._central.setSizes([300, 1000])
+ self._central.setStretchFactor(1, 3)
+
+ self.setCentralWidget(self._central)
+
+ # Interactors --------------------------------------------------------------
+ def get_current_tab(self) -> tuple[DualPlotDefinition, DualPlotWidget]:
+ widget = self._tabs_widget.currentWidget()
+ index = self._tabs_widget.currentIndex()
+ return list(Tabs.values())[index], widget
+
+ # Getters and Setters
+ def set_measurements_list(self, measurement_model: MeasurementListModel):
+ self._list_view_measurements.setModel(measurement_model)
+
+ def get_measurement_list(self) -> MeasurementListModel:
+ return self._list_view_measurements.model()
+
+ def get_selected_measurements(self) -> tuple[OpticsMeasurement]:
+ """ Get the currently selected measurements from the GUI.
+ Hint: Use the Qt.UserRole to retrieve the actual OpticsMeasurement.
+ """
+ selected = self._list_view_measurements.selectedIndexes()
+ return tuple(s.data(role=ItemDataRole.UserRole) for s in selected)
+
+ def get_all_measurements(self) -> tuple[OpticsMeasurement]:
+ """ Get the currently selected measurements from the GUI.
+ Hint: Use the Qt.UserRole to retrieve the actual OpticsMeasurement.
+ """
+ return self._list_view_measurements.model().items
+
+ def set_selected_measurements(self, indices: Sequence[QModelIndex] = ()):
+ self._list_view_measurements.selectionModel().clear()
+ for idx in indices:
+ self._list_view_measurements.selectionModel().select(idx, QItemSelectionModel.Select)
+
+ def set_segments_table(self, segment_model: SegmentTableModel):
+ self._table_segments.setModel(segment_model)
+ self._table_segments.selectionModel().selectionChanged.connect(self._handle_table_segments_selected)
+
+ def get_segments_table(self) -> SegmentTableModel:
+ return self._table_segments.model()
+
+ def get_selected_segments(self) -> tuple[SegmentItemModel]:
+ """ Get the currently selected segments from the GUI.
+ Hint: Use the Qt.UserRole to retrieve the actual SegmentItemModel.
+ """
+ selected: list[QModelIndex] = self._table_segments.selectedIndexes()
+ return tuple(s.data(role=ItemDataRole.UserRole) for s in selected if s.column() == 0) # need only one per row
+
+
+class MeasurementListView(QtWidgets.QListView):
+ """ Defines the view for the measurement list (on the top left). """
+
+ def __init__(self):
+ super().__init__()
+ self.setModel(MeasurementListModel())
+ self.setItemDelegate(ColoredItemDelegate())
+ self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
+ self.setStyleSheet(MONOSPACED_TOOLTIP)
+
+
+class SegmentTableView(QtWidgets.QTableView):
+ """ Defines the view for the segment table (on the bottom left). """
+
+ def __init__(self):
+ super().__init__()
+ self.setModel(SegmentTableModel())
+
+ header_hor = self.horizontalHeader()
+ header_hor.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
+ header_hor.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
+
+ header_ver = self.verticalHeader()
+ header_ver.setVisible(False)
+ header_ver.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
+
+ self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
+ self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
+ self.setShowGrid(True)
+ self.setStyleSheet(MONOSPACED_TOOLTIP)
+
+
+class ColoredItemDelegate(QtWidgets.QStyledItemDelegate):
+ """ Defines an ItemDelegate that uses a custom color for the text. """
+
+ COLOR_MAP = {
+ MeasurementListModel.ColorIDs.NONE: colors.TEXT_DARK,
+ MeasurementListModel.ColorIDs.BEAM1: colors.BEAM1,
+ MeasurementListModel.ColorIDs.BEAM2: colors.BEAM2,
+ MeasurementListModel.ColorIDs.RING1: colors.RING1,
+ MeasurementListModel.ColorIDs.RING2: colors.RING2,
+ MeasurementListModel.ColorIDs.RING3: colors.RING3,
+ MeasurementListModel.ColorIDs.RING4: colors.RING4,
+ }
+ def paint(self, painter, option, index):
+ # Customize the text color
+ color_id = index.data(Qt.TextColorRole)
+ try:
+ color = self.COLOR_MAP[color_id]
+ except KeyError:
+ color = self.COLOR_MAP[MeasurementListModel.ColorIDs.NONE]
+ option.palette.setColor(QtGui.QPalette.Text, QtGui.QColor(color))
+
+ super().paint(painter, option, index)
+
+
diff --git a/omc3_gui/segment_by_segment/measurement_model.py b/omc3_gui/segment_by_segment/measurement_model.py
new file mode 100644
index 0000000..a4fbccf
--- /dev/null
+++ b/omc3_gui/segment_by_segment/measurement_model.py
@@ -0,0 +1,384 @@
+"""
+Measurement Model
+-----------------
+
+This module contains the model for the Optics Measurement
+in the Segment-by-Segment application.
+"""
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass, field, fields
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, ClassVar
+
+from omc3.model.constants import TWISS_DAT
+from omc3.optics_measurements.constants import (
+ BETA_NAME,
+ EXT,
+ KICK_NAME,
+ MODEL_DIRECTORY,
+ PHASE_NAME,
+)
+from omc3.segment_by_segment.constants import corrections_madx
+from tfs.reader import read_headers
+
+from omc3_gui.ui_components.dataclass_ui import DirectoryPath, FilePath, metafield
+from omc3_gui.ui_components.dataclass_ui import choices_validator as choices
+from omc3_gui.ui_components.dataclass_ui.tools import (
+ load_dataclass_from_json,
+ save_dataclass_to_json,
+ update_dataclass_from_json,
+)
+
+if TYPE_CHECKING:
+ from omc3_gui.segment_by_segment.segment_model import SegmentDataModel
+
+SEQUENCE: str = "SEQUENCE"
+DATE: str = "DATE"
+
+FILES_TO_LOOK_FOR: tuple[str, ...] = tuple(f"{name}{plane}" for name in (KICK_NAME, PHASE_NAME, BETA_NAME) for plane in ("x", "y"))
+TO_BE_DEFINED: str = "to_be_defined"
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+def exists(value: Path | None) -> bool:
+ return value is not None and value.exists()
+
+
+@dataclass(slots=True)
+class OpticsMeasurement:
+ """ Class to load and hold the optics-measurement folder.
+ This class also stores the meta-data for the loaded measurement,
+ which can then be passed on to the segment-by-segment.
+ The :func:`omc3_gui.utils.dataclass_ui.metafield` is used to provide hints about the fields for the GUI.
+ """
+ measurement_dir: DirectoryPath = metafield("Optics Measurement", "Path to the optics-measurement folder", default=Path(TO_BE_DEFINED), validate=exists)
+ model_dir: DirectoryPath = metafield("Model", "Path to the model folder", default=Path(TO_BE_DEFINED), validate=exists)
+ accel: str = metafield("Accelerator", "Name of the accelerator", default=None)
+ output_dir: DirectoryPath = metafield("Output", "Path to the sbs-output folder", default=None)
+ corrections: FilePath = metafield("Corrections", "Path to the corrections file", default=None)
+ year: str = metafield("Year", "Year of the measurement (model)", default=None)
+ ring: int = metafield("Ring", "Ring of the accelerator", default=None, validate=choices(1, 2, 3, 4))
+ beam: int = metafield("Beam", "Beam of the accelerator", default=None, validate=choices(1, 2))
+ # List of segments. Using a list here, so the name and start/end can be changed
+ # without having to modify anything here.
+ _segments: list[SegmentDataModel] = field(default_factory=list)
+
+ DEFAULT_OUTPUT_DIR: ClassVar[str] = "sbs"
+ JSON_FILENAME: ClassVar[str] = "sbs_measurement.json"
+
+ def __post_init__(self):
+ if self.output_dir is None:
+ self.output_dir = self.measurement_dir / self.DEFAULT_OUTPUT_DIR
+
+ # Visualization ------------------------------------------------------------
+ def display(self) -> str:
+ if self.output_dir.name == self.DEFAULT_OUTPUT_DIR:
+ return self.measurement_dir.name
+ return f"{self.measurement_dir.name} -> {self.output_dir.name}"
+
+ @property
+ def id(self) -> str:
+ """ Unique identifier for the measurement, used in the ItemModel. """
+ return str(self.output_dir)
+
+ @classmethod
+ def get_label(cls, name: str) -> str:
+ """ Returns the label for the field named `name`. """
+ try:
+ return cls.__dataclass_fields__[name].metadata["label"]
+ except KeyError:
+ return name
+
+ @classmethod
+ def get_comment(cls, name: str) -> str:
+ """ Returns the comment for the field named `name`. """
+ try:
+ return cls.__dataclass_fields__[name].metadata["comment"]
+ except KeyError:
+ return ""
+
+ def tooltip(self) -> str:
+ """ Returns a string with information about the measurement,
+ as to be used in a tool-tip. """
+ parts = [
+ (self.get_label(f.name), getattr(self, f.name)) for f in fields(self)
+ if not f.name.startswith("_")
+ ]
+ size = max(len(name) for name, _ in parts)
+ return "\n".join(f"{name:{size}s}: {value}" for name, value in parts if value is not None)
+
+ # Segment Control ----------------------------------------------------------
+ def remove_segment(self, segment: SegmentDataModel):
+ try:
+ self.segments.remove(segment)
+ except ValueError as e:
+ raise ValueError(f"Segment with name {segment.name} is not in {self.display()}.") from e
+
+ def add_segment(self, segment: SegmentDataModel):
+ if segment in self.segments:
+ raise NameError(f"Segment {segment} is already in {self.display()}")
+
+ if segment.name in [s.name for s in self.segments]:
+ raise NameError(f"A segment with name {segment.name} is already in {self.display()}")
+
+ self.segments.append(segment)
+
+ def try_add_segment(self, segment: SegmentDataModel, silent: bool = False) -> bool:
+ try:
+ self.add_segment(segment)
+ except NameError as e:
+ if silent:
+ LOGGER.debug(str(e))
+ else:
+ LOGGER.error(str(e))
+ return False
+ return True
+
+ def try_remove_segment(self, segment: SegmentDataModel | str) -> bool:
+ if isinstance(segment, str):
+ try:
+ segment = self.get_segment_by_name(segment)
+ except NameError as e:
+ LOGGER.error(str(e))
+ return False
+
+ try:
+ self.remove_segment(segment)
+ except ValueError as e:
+ LOGGER.error(str(e))
+ return False
+ return True
+
+ def get_segment_by_name(self, name: str) -> SegmentDataModel:
+ for segment in self.segments:
+ if segment.name == name:
+ return segment
+
+ msg = f"No segment with name {name} in {self.display()}."
+ raise NameError(msg)
+
+ @property
+ def segments(self) -> list[SegmentDataModel]:
+ return self._segments
+
+ # Segment-by-Segment Parameters --------------------------------------------
+ def get_sbs_parameters(self) -> dict[str, Any]:
+ parameters = dict(
+ measurement_dir=self.measurement_dir,
+ corrections=self.corrections,
+ output_dir=self.output_dir,
+ accel = self.accel,
+ model_dir = self.model_dir,
+ )
+ if self.beam is not None:
+ parameters["beam"] = self.beam
+ if self.year is not None:
+ parameters["year"] = self.year
+ if self.ring is not None:
+ parameters["ring"] = self.ring
+ return parameters
+
+ # Builder ------------------------------------------------------------------
+ def copy(self) -> OpticsMeasurement:
+ """ Creates a copy of the measurement. """
+ new_measurement = OpticsMeasurement(
+ **{
+ f.name: getattr(self, f.name) for f in fields(self)
+ if not f.name.startswith("_")
+ },
+ )
+ for segment in self.segments:
+ new_segment = segment.copy()
+ new_segment.measurement = new_measurement
+ new_measurement.add_segment(new_segment)
+ return new_measurement
+
+ @classmethod
+ def from_path(cls, path: Path) -> OpticsMeasurement:
+ """ Creates an OpticsMeasurement from a folder, by trying
+ to parse information from the data in the folder.
+
+ Args:
+ path (Path): Path to the folder.
+
+ Returns:
+ OpticsMeasurement: OpticsMeasurement instance.
+ """
+ # Try to load from json first ---
+ json_path = None
+ default_json_path = path / cls.JSON_FILENAME
+ default_output_json_path = path / cls.DEFAULT_OUTPUT_DIR / cls.JSON_FILENAME
+ measurement_path = None
+ if (default_json_path).is_file():
+ json_path = default_json_path
+ if any((path / f).is_file() for f in FILES_TO_LOOK_FOR):
+ measurement_path = path # otherwise probably an output dir
+ elif(default_output_json_path).is_file():
+ json_path = default_output_json_path
+ measurement_path = path
+
+ if json_path is not None:
+ try:
+ return cls.from_json(json_path, measurement_path)
+ except json.decoder.JSONDecodeError as e:
+ LOGGER.error(f"JSON errror: {e!s}\nTrying to load as optics-measurement folder.")
+
+ # Try to load from optics-measurement folder ---
+ info = {}
+ try:
+ model_dir = _parse_model_dir_from_optics_measurement(path)
+ except FileNotFoundError as e:
+ LOGGER.error(str(e))
+ else:
+ info = _parse_info_from_model_dir(model_dir)
+ info["model_dir"] = model_dir
+
+ if (path / corrections_madx).is_file():
+ info["corrections"] = path / corrections_madx
+
+ return cls(measurement_dir=path, **info)
+
+ @classmethod
+ def from_json(cls, path: Path, measurement_dir: Path | None = None) -> OpticsMeasurement:
+ """ Creates an OpticsMeasurement from a folder, by trying
+ to parse information from the data in the folder.
+
+ Args:
+ path (Path): Path to the folder.
+
+ Returns:
+ OpticsMeasurement: OpticsMeasurement instance.
+ """
+ if measurement_dir is not None:
+ meas: OpticsMeasurement = cls(measurement_dir=measurement_dir)
+ meas = update_dataclass_from_json(meas, path)
+ meas.measurement_dir = measurement_dir # in case the folder name/path changed since the json was written
+ return meas
+ return load_dataclass_from_json(cls, path)
+
+ def to_json(self, path: Path | None = None) -> None:
+ if path is None:
+ if self.output_dir is not None:
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+ path = self.output_dir / self.JSON_FILENAME
+ else:
+ if self.measurement_dir is None or self.measurement_dir == TO_BE_DEFINED:
+ raise ValueError("Measurement dir is still to be defined.")
+ path = self.measurement_dir / self.JSON_FILENAME
+ save_dataclass_to_json(self, path)
+
+ def quick_check(self) -> None:
+ """ Tests for completeness of the definition (e.g. after loading). """
+ if self.measurement_dir is None or self.measurement_dir == TO_BE_DEFINED:
+ raise NameError("Measurement dir is still to be defined.") # BAD
+
+ if any(getattr(self, name) is None for name in ("model_dir", "accel", "output_dir")) or self.model_dir == TO_BE_DEFINED:
+ raise ValueError(f"Current definition of '{self.measurement_dir!s}' is incomplete. Adjust manually!!")
+
+ if self.accel == 'lhc' and (self.year is None or self.beam is None):
+ raise ValueError(f"Current definition of '{self.measurement_dir!s}' for LHC is incomplete. Adjust manually!!")
+
+ if self.accel == 'psb' and self.ring is None:
+ raise ValueError(f"Current definition of '{self.measurement_dir!s}' for PSB is incomplete. Adjust manually!!")
+
+
+def _parse_model_dir_from_optics_measurement(measurement_path: Path) -> Path:
+ """Tries to find the model directory in the headers of one of the optics measurement files.
+
+ Args:
+ measurement_path (Path): Path to the folder.
+
+ Returns:
+ Path: Path to the (associated) model directory.
+ """
+ LOGGER.debug(f"Searching for model dir in {measurement_path!s}")
+ for file_name in FILES_TO_LOOK_FOR:
+ LOGGER.debug(f"Checking {file_name!s} for model dir.")
+ try:
+ headers = read_headers((measurement_path / file_name).with_suffix(EXT))
+ except FileNotFoundError:
+ LOGGER.debug(f"{file_name!s} not found in {measurement_path!s}.")
+ else:
+ if MODEL_DIRECTORY in headers:
+ LOGGER.debug(f"{MODEL_DIRECTORY!s} found in {file_name!s}: {headers[MODEL_DIRECTORY]!s}!")
+ return Path(headers[MODEL_DIRECTORY])
+
+ LOGGER.debug(f"{MODEL_DIRECTORY!s} not found in {file_name!s}.")
+ raise FileNotFoundError(f"Could not find '{MODEL_DIRECTORY}' in any of {FILES_TO_LOOK_FOR!r} in {measurement_path!r}")
+
+
+def _parse_info_from_model_dir(model_dir: Path) -> dict[str, Any]:
+ """ Checking twiss.dat for more info about the accelerator.
+
+ Args:
+ model_dir (Path): Path to the model-directory.
+
+ Returns:
+ Dict[str, Any]: Containing the additional info found (accel, beam, year, ring).
+ """
+ result = {}
+
+ try:
+ headers = read_headers(model_dir / TWISS_DAT)
+ except FileNotFoundError as e:
+ LOGGER.debug(str(e))
+ return result
+
+ sequence = headers.get(SEQUENCE)
+ if sequence is not None:
+ sequence = sequence.lower()
+ if "lhc" in sequence:
+ result['accel'] = "lhc"
+ result['beam'] = int(sequence[-1])
+ result['year'] = map_lhc_year(_get_year_from_header(headers))
+ elif "psb" in sequence:
+ result['accel'] = "psb"
+ result['ring'] = int(sequence[-1])
+ else:
+ result['accel'] = sequence
+ LOGGER.debug(f"Associated info found in model dir '{model_dir!s}':\n {result!s}")
+ return result
+
+
+def _get_year_from_header(headers: dict) -> str | None:
+ """ Parses the year from the date in the twiss.dat file.
+
+ TODO: Will not work for hl-lhc models. These should return the hl-version.
+ """
+ date = headers.get(DATE)
+
+ if date is None:
+ return None
+
+ year = f"20{date.split('/')[-1]}"
+ LOGGER.debug(f"Assuming model year {year!s} from '{date}'!")
+ return year
+
+
+def map_lhc_year(year: str | None) -> str:
+ """ Maps the input year to the corresponding available model year. """
+ if year is None:
+ return None
+
+ try:
+ int_year = int(year)
+ except ValueError:
+ return year
+
+ # no new models (see omc/model/accelerators/lhc)
+ if 2012 < int_year < 2015:
+ LOGGER.info(f"Mapping year {year} to LHC model 2012!")
+ return "2012"
+
+ # no new models (there was a 2021 in acc-models, but we were not using it then)
+ if 2018 < int_year < 2022:
+ LOGGER.info(f"Mapping year {year} to LHC model 2018!")
+ return "2018"
+
+ return year
+
\ No newline at end of file
diff --git a/omc3_gui/segment_by_segment/measurement_view.py b/omc3_gui/segment_by_segment/measurement_view.py
new file mode 100644
index 0000000..ffa2bde
--- /dev/null
+++ b/omc3_gui/segment_by_segment/measurement_view.py
@@ -0,0 +1,35 @@
+"""
+Measurement View
+----------------
+
+This module contains the view for the measurement dialog.
+"""
+from dataclasses import fields
+
+from omc3_gui.segment_by_segment.measurement_model import OpticsMeasurement
+from omc3_gui.ui_components.dataclass_ui import DataClassDialog, FieldUIDef, DataClassUI
+
+
+
+class OpticsMeasurementDialog(DataClassDialog):
+
+ WINDOW_TITLE = "Optics Measurement"
+ DEFAULT_SIZE = (800, -1)
+
+ def __init__(self, parent=None, optics_measurement: OpticsMeasurement | None = None):
+ if optics_measurement is None:
+ optics_measurement = OpticsMeasurement()
+
+ non_editable = ("measurement_dir", ) # set by program not by user
+ dataclass_ui = DataClassUI(
+ field_definitions=[
+ FieldUIDef(field.name, editable=field.name not in non_editable)
+ for field in fields(OpticsMeasurement) if field.name[0] != "_"
+ ],
+ dclass=optics_measurement,
+ )
+ super().__init__(dataclass_ui=dataclass_ui, parent=parent)
+
+ @property
+ def measurement(self) -> OpticsMeasurement:
+ return self._dataclass_ui.model
\ No newline at end of file
diff --git a/omc3_gui/segment_by_segment/plotting.py b/omc3_gui/segment_by_segment/plotting.py
new file mode 100644
index 0000000..1e16992
--- /dev/null
+++ b/omc3_gui/segment_by_segment/plotting.py
@@ -0,0 +1,245 @@
+"""
+Plotting
+--------
+
+Plots for segment-by-segment.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import cache
+import logging
+from pathlib import Path
+
+from omc3.definitions.optics import (
+ S_COLUMN,
+ S_MODEL_COLUMN,
+ RDT_AMPLITUDE_COLUMN,
+ RDT_PHASE_COLUMN,
+ RDT_REAL_COLUMN,
+ RDT_IMAG_COLUMN,
+ ColumnsAndLabels,
+)
+from omc3.segment_by_segment.propagables import PropagableColumns
+from omc3.model.constants import TWISS_ELEMENTS_DAT
+from omc3.optics_measurements.constants import NAME
+from qtpy.QtCore import Qt
+import tfs
+
+from omc3_gui.plotting.classes import DualPlotWidget
+from omc3_gui.plotting.element_lines import plot_element_lines
+from omc3_gui.plotting.latex_to_html import latex_to_html_converter
+from omc3_gui.plotting.tfs_plotter import plot_dataframes
+from omc3_gui.segment_by_segment.segment_model import SegmentDataModel
+from omc3_gui.segment_by_segment.settings import PlotSettings
+
+LOGGER = logging.getLogger(__name__)
+
+
+PenStyle = Qt.PenStyle
+
+@dataclass(frozen=True)
+class PlotDefinition:
+ file_name: str
+ ylabel: str
+ column: str
+ error_column: str
+ propagable_columns: PropagableColumns
+
+ @classmethod
+ def create(cls, file_name: str, columns: ColumnsAndLabels):
+ return cls(
+ file_name=file_name,
+ ylabel=latex_to_html_converter(columns.delta_label),
+ column=columns.column,
+ error_column=columns.error_column,
+ propagable_columns=PropagableColumns(columns.column, plane="")
+
+ )
+
+
+@dataclass(frozen=True)
+class DualPlotDefinition:
+ name: str
+ top: PlotDefinition
+ bottom: PlotDefinition
+
+ @property
+ def plots(self) -> tuple[PlotDefinition, PlotDefinition]:
+ return (self.top, self.bottom)
+
+ @classmethod
+ def generate_xy(cls, name: str, file_name: str, columns: ColumnsAndLabels):
+ """ Generate a DualPlotDefinition for XY-Planed Plots. """
+ return cls(name, *(
+ PlotDefinition.create(f"{file_name}_{plane}", columns.set_plane(plane.upper())) for plane in "xy"
+ ))
+
+ @classmethod
+ def generate_amplitude_phase(cls, file_name: str):
+ """ Generate a DualPlotDefinition for Amplitude/Phase Plots. """
+ name = f"{file_name} A/φ"
+ amp = RDT_AMPLITUDE_COLUMN.set_label_formatted(file_name)
+ phase = RDT_PHASE_COLUMN.set_label_formatted(file_name)
+ return cls.generate_rdt(name, file_name, amp, phase)
+
+ @classmethod
+ def generate_real_imag(cls, file_name: str):
+ """ Generate a DualPlotDefinition for Amplitude/Phase Plots. """
+ name = f"{file_name} Re/Im"
+ real = RDT_REAL_COLUMN.set_label_formatted(file_name)
+ imag = RDT_IMAG_COLUMN.set_label_formatted(file_name)
+ return cls.generate_rdt(name, file_name, real, imag)
+
+ @classmethod
+ def generate_rdt(cls, name: str, file_name: str, columns_top: ColumnsAndLabels, columns_bottom: ColumnsAndLabels):
+ """ Generate a DualPlotDefintion for RDTs, i.e. Amplitude/Phase, Re/Im. """
+ return cls(name, *(
+ PlotDefinition.create(file_name, columns) for columns in (columns_top, columns_bottom)
+ ))
+
+
+class DirectionStyle:
+ """ Helper Class to define the Style based on direction and expected. """
+
+ def __init__(self, direction: str, expected: bool | None):
+ self.direction = direction
+ self.expected = expected
+
+ @property
+ def linestyle(self):
+ if self.expected is None:
+ return PenStyle.SolidLine
+ return PenStyle.DashLine
+
+ @property
+ def brightness(self):
+ return {
+ "forward": None,
+ "backward": 150
+ }[self.direction]
+
+ @property
+ def marker(self):
+ return {
+ "forward": "t2",
+ "backward": "t3"
+ }[self.direction]
+
+ @property
+ def suffix(self):
+ shorthand = {
+ "forward": " fwd",
+ "backward": " bwd"
+ }[self.direction]
+ return {
+ None: shorthand,
+ True: f"{shorthand} expct",
+ False: f"{shorthand} corr"
+ }[self.expected]
+
+ @property
+ def column(self):
+ return {
+ None: self.direction,
+ True: f"{self.direction}_expected",
+ False: f"{self.direction}_correction"
+ }[self.expected]
+
+ @property
+ def error_column(self):
+ return f"error_{self.column}"
+
+
+# Plotting ---------------------------------------------------------------------
+
+def plot_segment_data(
+ widget: DualPlotWidget,
+ definitions: DualPlotDefinition,
+ segments: list[SegmentDataModel],
+ settings: PlotSettings
+ ):
+ """ Plot the given segments with the given definition. """
+ # use the segment name as label, if there is more than one segment from the same measurement
+ use_segment_label = len(set(s.measurement.display() for s in segments)) != len(segments)
+ def get_label(segment: SegmentDataModel) -> str:
+ if use_segment_label:
+ return f"{segment.measurement.display()} {segment.name}"
+ return segment.measurement.display()
+
+ # wrap loading for better error handling and logging
+ def get_data(segment: SegmentDataModel, file_name: str):
+ try:
+ return segment.data[file_name]
+ except FileNotFoundError:
+ LOGGER.error(f"Segment {segment.name} has no data for {file_name}.")
+ return None
+
+ # set x column
+ x_column = S_COLUMN
+ if settings.model_s:
+ x_column = S_MODEL_COLUMN
+
+ # Loop over top/bottom plots ---
+ for definition, plot in zip(definitions.plots, widget.plots):
+ definition: PlotDefinition
+
+ dataframes = {
+ get_label(segment): get_data(segment, definition.file_name)
+ for segment in segments
+ }
+
+ if any(df is None for df in dataframes.values()):
+ LOGGER.error("Could not find data for all segments, please run these again !?")
+ # continue anyway
+ dataframes = {label: df for label, df in dataframes.items() if df is not None}
+
+ # Plot Model Elements ---
+ if settings.show_model:
+ model_dir = segments[0].measurement.model_dir
+ bpm_ranges = [(s.start, s.end) for s in segments]
+ plot_element_lines(
+ plot=plot,
+ data_frame=load_twiss_elements(model_dir),
+ ranges=bpm_ranges,
+ start_zero=not settings.model_s,
+ )
+
+ # Loop over forward/backward plots ---
+ for direction in ("forward", "backward"):
+ if not getattr(settings, direction): # user activated
+ continue
+
+ # Loop over propagaed/expected or corrected values ---
+ for expected in (None, settings.expected):
+ style = DirectionStyle(direction, expected)
+
+ # Now put it all together ---
+ plot_dataframes(
+ plot=plot,
+ dataframes=dataframes,
+ xcolumn=x_column.column,
+ ycolumn=getattr(definition.propagable_columns, style.column),
+ yerrcolumn=getattr(definition.propagable_columns, style.error_column),
+ xlabel=x_column.label,
+ ylabel=definition.ylabel,
+ legend=settings.show_legend,
+ marker=style.marker,
+ markersize=settings.marker_size,
+ brightness=style.brightness,
+ linestyle=style.linestyle,
+ suffix=style.suffix,
+ )
+
+ if settings.reset_zoom:
+ plot.enableAutoRange()
+
+
+@cache
+def load_twiss_elements(model_dir: Path) -> tfs.TfsDataFrame:
+ """ Load the twiss elements from the model directory.
+ Cache here, because that might take a moment, so better to keep the DataFrame in memory.
+ """
+ df = tfs.read_tfs(model_dir / TWISS_ELEMENTS_DAT, index=NAME)
+ df = df.loc[df.index.str.match(r"^(?!DRIFT).*"), [S_COLUMN.column]] # we actually only need the s column and headers
+ return df
diff --git a/omc3_gui/segment_by_segment/segment_model.py b/omc3_gui/segment_by_segment/segment_model.py
new file mode 100644
index 0000000..8c04147
--- /dev/null
+++ b/omc3_gui/segment_by_segment/segment_model.py
@@ -0,0 +1,220 @@
+"""
+Segment Model
+-------------
+
+This module contains the model for the Segments
+in the Segment-by-Segment application.
+"""
+from __future__ import annotations
+
+from collections import namedtuple
+from dataclasses import dataclass
+from pathlib import Path
+import re
+from typing import TYPE_CHECKING
+
+from omc3.segment_by_segment.segments import SegmentDiffs, EXT
+from omc3.optics_measurements.constants import NAME
+import tfs
+
+from omc3_gui.ui_components import colors
+from omc3_gui.ui_components.dataclass_ui import metafield
+from omc3_gui.ui_components.item_models import Item
+
+if TYPE_CHECKING:
+ from omc3_gui.segment_by_segment.measurement_model import OpticsMeasurement
+
+OK: str = f"✓"
+NO: str = f"✗"
+TO_BE_DEFINED: str = "to_be_defined"
+
+
+def not_empty(value: str | None) -> bool:
+ if value is None:
+ return False
+ value = value.strip()
+ return value != "" and value != TO_BE_DEFINED
+
+
+SegmentTuple = namedtuple('Segment', ['name', 'start', 'end']) # the simplest way to store a segment definition
+
+
+@dataclass(slots=True)
+class SegmentDataModel:
+ """" Container for the segment data, which is also used in the Segment creation dialog. """
+
+ measurement: OpticsMeasurement
+ name: str = metafield("Name", "Name of the Segment", default=TO_BE_DEFINED, validate=not_empty)
+ start: str | None = metafield("Start", "Start of the Segment", default=None, validate=not_empty)
+ end: str | None = metafield("End", "End of the Segment", default=None, validate=not_empty)
+ _data: SegmentDiffs | None = None
+
+ def __str__(self):
+ return self.name
+
+ def is_element(self):
+ return is_element(self)
+
+ def to_input_string(self):
+ """ String representation of the segment as used in inputs."""
+ return to_input_string(self)
+
+ @property
+ def data(self) -> SegmentDiffs:
+ if self._data is None or self._data.directory != self.measurement.output_dir or self._data.segment_name != self.name:
+ self._data = SegmentDiffs(self.measurement.output_dir, self.name)
+ return self._data
+
+ def has_run(self) -> bool:
+ try:
+ return self.data.get_path("phase_x").is_file()
+ except AttributeError:
+ return False
+ # TODO: Maybe load and check first and last BPM? (jdilly, 2025)
+
+ def clear_data(self):
+ self._data = None
+
+ def copy(self):
+ return SegmentDataModel(measurement=self.measurement, name=self.name, start=self.start, end=self.end)
+
+
+class SegmentItemModel(Item):
+ """ Model for a segment item in the Segment-Table of the Segment-by-Segment application.
+ Each item has name, start and end and attached a list of actual segment-obejcts
+ """
+
+ def __init__(self, name: str, start: str = None, end: str = None):
+ self._name = name
+ self._start = start
+ self._end = end
+ self._segments: list[SegmentDataModel] = []
+
+ @classmethod
+ def from_segments(cls, segments: list[SegmentDataModel]) -> SegmentItemModel:
+ new = cls(segments[0].name, segments[0].start, segments[0].end)
+ new.segments = segments # also checks for equality of given segments
+ return new
+
+ @classmethod
+ def from_segment(cls, segment: SegmentDataModel) -> SegmentItemModel:
+ new = cls(segment.name, segment.start, segment.end)
+ return new
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @name.setter
+ def name(self, value: str):
+ self._name = value
+ for segment in self.segments:
+ segment.name = value
+
+ @property
+ def start(self) -> str:
+ return self._start
+
+ @start.setter
+ def start(self, value: str):
+ self._start = value
+ for segment in self.segments:
+ segment.start = value
+
+ @property
+ def end(self) -> str:
+ return self._end
+
+ @end.setter
+ def end(self, value: str):
+ self._end = value
+ for segment in self.segments:
+ segment.end = value
+
+ @property
+ def segments(self) -> list[SegmentDataModel]:
+ return self._segments
+
+ @segments.setter
+ def segments(self, segments: list[SegmentDataModel]):
+ if any(not compare_segments(self, segment) for segment in segments):
+ raise ValueError(
+ "At least one given segment has a different "
+ f"definition than the others or than this {self.__class__.name}."
+ )
+ self._segments = segments
+
+ def append_segment(self, segment: SegmentDataModel):
+ if not compare_segments(self, segment):
+ raise ValueError(f"Given segment has a different definition than this {self.__class__.name}.")
+ self.segments.append(segment)
+
+ @property
+ def id(self) -> str:
+ """ Unique identifier for the segment. """
+ return self.name + self.start + self.end
+
+ def tooltip(self) -> str:
+ """ Returns a string with information about the segment,
+ as to be used in a tool-tip.
+
+ Hint: Use fully HTML compatible strings, otherwise Qt will revert to plain text.
+ e.g. use instead of \\n. Also instead of whitespaces, as they are collapsed in HTML.
+
+ """
+ parts = [
+ f"
{OK if segment.has_run() else NO}
"
+ "
"
+ f"
{segment.measurement.display()}
"
+ for segment in self.segments
+ ]
+ return "
Run
|
In Measurement
" + "".join(parts)
+
+ def is_element(self):
+ return is_element(self)
+
+ def to_input_string(self):
+ """ String representation of the segment as used in inputs."""
+ return to_input_string(self)
+
+
+# Segment functions ---
+
+def compare_segments(a: SegmentDataModel | SegmentItemModel, b: SegmentDataModel | SegmentItemModel) -> bool:
+ """ Checks if two Segments have the same definition. """
+ return a.name == b.name and a.start == b.start and a.end == b.end
+
+
+def is_element(segment: SegmentItemModel | SegmentDataModel) -> bool:
+ """ Checks if the segment is an element-segment. """
+ return segment.start is None or segment.end is None
+
+
+def to_input_string(segment: SegmentItemModel | SegmentDataModel) -> str:
+ """ Convert the segment to the string representation as used in inputs. """
+ if is_element(segment):
+ return segment.name
+ return f"{segment.name},{segment.start},{segment.end}"
+
+
+# Other ---
+
+def get_segments_from_directory(directory: Path) -> list[SegmentTuple]:
+ """ Parse segments from a directory.
+
+ This function needs to be kept synchronized with :class:`omc3.segment_by_segment.segments.SegmentDiffs`.
+ """
+ pattern = fr"{SegmentDiffs.PREFIX}.+?_[xy]_(.+)\{EXT}" # keep in sync, hint: '.' in EXT
+ segments: list[SegmentTuple] = []
+ names: set[str] = set()
+ for file in directory.glob(f"{SegmentDiffs.PREFIX}*{EXT}"):
+ file_match = re.match(pattern, file.name)
+ if file_match is None:
+ continue
+
+ name = file_match.group(1)
+ if name not in names:
+ names.add(name)
+ df = tfs.read(file, index=NAME)
+ segments.append(SegmentTuple(name, df.index[0], df.index[-1]))
+ return segments
\ No newline at end of file
diff --git a/omc3_gui/segment_by_segment/segment_view.py b/omc3_gui/segment_by_segment/segment_view.py
new file mode 100644
index 0000000..3de72ad
--- /dev/null
+++ b/omc3_gui/segment_by_segment/segment_view.py
@@ -0,0 +1,31 @@
+"""
+Segment View
+------------
+
+This module contains the view for the segment dialog.
+"""
+from __future__ import annotations
+
+from omc3_gui.segment_by_segment.segment_model import SegmentDataModel
+from omc3_gui.ui_components.dataclass_ui import DataClassDialog, DataClassUI, FieldUIDef
+
+class SegmentDialog(DataClassDialog):
+
+ WINDOW_TITLE = "Segment Editor"
+ DEFAULT_SIZE = (400, -1)
+
+ def __init__(self, parent=None, segment: SegmentDataModel | None = None):
+ if segment is None:
+ segment = SegmentDataModel(measurement=None) # dummy
+
+ dataclass_ui = DataClassUI(
+ field_definitions=[
+ FieldUIDef(name) for name in ("name", "start", "end")
+ ],
+ dclass=segment,
+ )
+ super().__init__(dataclass_ui=dataclass_ui, parent=parent)
+
+ @property
+ def segment(self) -> SegmentDataModel:
+ return self._dataclass_ui.model
diff --git a/omc3_gui/segment_by_segment/settings.py b/omc3_gui/segment_by_segment/settings.py
new file mode 100644
index 0000000..25cd2af
--- /dev/null
+++ b/omc3_gui/segment_by_segment/settings.py
@@ -0,0 +1,39 @@
+"""
+Settings
+--------
+
+Global Settings for the Segment-by-Segment application.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from dataclasses import dataclass, field
+
+from omc3_gui.ui_components.dataclass_ui import metafield
+
+@dataclass(slots=True)
+class MainSettings:
+ cwd: Path = metafield("Working Directory", "Current working directory. Used for default path when opening file selection dialogs.", default=Path.cwd())
+ autoload_segments: bool = metafield("Autoload Segments", "Automatically try to load existing segments when loading a measurement.", default=True)
+ autodefault_segments: bool = metafield("Auto-Add Default Segments", "Automatically add default segments when loading a measurement.", default=False)
+ suggest_correctors: bool = metafield("Suggest Correctors", "Suggest correctors when editing a new correction file.", default=True)
+
+@dataclass(slots=True)
+class PlotSettings:
+ show_model: bool = metafield("Show Model", "Show markers for the elements of the Model.", default=False)
+ show_legend: bool = metafield("Show Legend", "Show legend.", default=True)
+ marker_size: float = metafield("Marker Size", "Size of the markers.", default=8.5)
+ expected: bool = metafield("Expectation", "Show expected value after correction instead of correction itself.", default=False)
+ forward: bool = metafield("Forward Propagation", "Show forward propagation.", default=True)
+ backward: bool = metafield("Backward Propagation", "Show backward propagation.", default=False)
+ connect_x: bool = metafield("Connect X", "Connect X axes of the two plots.", default=True)
+ connect_y: bool = metafield("Connect Y", "Connect Y axes of the two plots.", default=False)
+ reset_zoom: bool = metafield("Reset Zoom", "Reset zoom when changing segments.", default=True)
+ same_start: bool = metafield("Same Segment Start", "Plot only if the selected segments all have the same starting BPM.", default=True)
+ model_s: bool = metafield("Model Location", "Use the model longitudinal location instead of the segment location.", default=False)
+
+
+@dataclass(slots=True)
+class Settings:
+ main: MainSettings = field(default_factory=MainSettings)
+ plotting: PlotSettings = field(default_factory=PlotSettings)
diff --git a/omc3_gui/ui_components/__init__.py b/omc3_gui/ui_components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/omc3_gui/ui_components/base_classes_cvm.py b/omc3_gui/ui_components/base_classes_cvm.py
new file mode 100644
index 0000000..81916cd
--- /dev/null
+++ b/omc3_gui/ui_components/base_classes_cvm.py
@@ -0,0 +1,279 @@
+"""
+UI: Base Classes for CVM
+------------------------
+
+This module contains base classes for UI's
+that use the Controller-View-Model pattern.
+"""
+from __future__ import annotations
+
+import logging
+import re
+import sys
+
+from qtpy import QtGui
+from qtpy.QtCore import QObject, Slot
+from qtpy.QtWidgets import (
+ QAction,
+ QApplication,
+ QDesktopWidget,
+ QDockWidget,
+ QMenu,
+ QMenuBar,
+ QStatusBar,
+ QStyle,
+ QWidgetAction,
+)
+
+from omc3_gui import __version__
+from omc3_gui.ui_components import colors
+from omc3_gui.utils.log_handler import get_console_formatter
+from omc3_gui.ui_components.widgets import RunningSpinner
+from omc3_gui.ui_components.message_boxes import show_error_dialog
+
+try: # CERN Application Frame
+ from accwidgets.app_frame import ApplicationFrame
+ from accwidgets.qt import exec_app_interruptable
+except ImportError: # Standard QT
+ from qtpy.QtWidgets import QMainWindow as ApplicationFrame
+
+ def exec_app_interruptable(app):
+ app.exec_()
+
+try: # CERN Console
+ from accwidgets.app_frame._about_dialog import AboutDialog
+ from accwidgets.log_console import LogConsoleFormatter as AccPyLogConsoleFormatter
+except ImportError: # Deactivated
+ AccPyLogConsoleFormatter = object
+ AboutDialog = None
+
+LOGGER = logging.getLogger(__name__)
+
+
+class Controller(QObject):
+ """
+ Base class for the controller of a UI.
+
+ The controller is the glue between the view and the model
+ and also the entry-point for the app.
+ """
+
+ def __init__(self, view: ApplicationFrame, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._view = view
+
+ def show(self):
+ self._view.show()
+
+ @classmethod
+ def run_application(cls, *args, **kwargs) -> int:
+ app = QApplication(sys.argv)
+ controller = cls(*args, **kwargs)
+ controller.show()
+ return exec_app_interruptable(app)
+
+
+class View(ApplicationFrame):
+ """
+ Base class for the view of a UI.
+
+ Adds a menu bar and a status bar as well as the log-console (if in CERN mode).
+ """
+ def __init__(self, *args, **kwargs):
+ kwargs["use_log_console"] = kwargs.get("use_log_console", True)
+ try:
+ super().__init__(*args, **kwargs) # CERN Application Frame
+ except TypeError:
+ del kwargs["use_log_console"]
+ super().__init__(*args, **kwargs) # QT Main window
+
+ self._menu_bar: QMenuBar = None
+ self._thread_spinner: RunningSpinner = None
+ self.app_version = __version__
+
+ self._adapt_logger()
+ self._adapt_to_screensize()
+ self.build_menu_bar()
+ self.build_status_bar()
+
+ def build_menu_bar(self):
+ self._menu_bar = QMenuBar()
+
+ # File menu ---
+ file = self._menu_bar.addMenu("File")
+ quit = file.addAction("Exit", self.close)
+ quit.setIcon(QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton))
+ quit.setMenuRole(QWidgetAction.QuitRole)
+
+ # View menu ---
+ # needs to be called "View" to be found by ApplicationFrame,
+ # which adds some additional actions if needed.
+ view = self._menu_bar.addMenu("View")
+
+ # Fullscreen -
+ toggle_fullscreen = view.addAction("Full Screen", self.toggleFullScreen)
+ toggle_fullscreen.setCheckable(True)
+
+ # Log Console -
+ # Add the log-console checkbox here, so Application Frame doesn't give it
+ # the wrong title ("Toggle Log Console"), is horrible for a checkbox (jdilly, 2025)
+ log_console: QDockWidget = getattr(self, "log_console", None)
+ if log_console:
+ log_console_action = log_console.toggleViewAction()
+ log_console_action.setText("Log Console")
+ # hide the setText function, as otherwise ApplicationFrame overwrites the title
+ log_console_action._setText = log_console_action.setText
+ log_console_action.setText = lambda text: None
+ view.addAction(log_console_action)
+
+ # Help menu ---
+ help = self._menu_bar.addMenu("Help")
+
+ # About -
+ about = help.addAction("About", self.showAboutDialog)
+ about.setIcon(self.windowIcon())
+ about.setMenuRole(QWidgetAction.AboutRole)
+
+ # Set menu bar ---
+ self.setMenuBar(self._menu_bar)
+
+ def get_action_by_title(self, title: str, parent: QMenuBar | QMenu | None = None) -> QAction | QMenu:
+ """ Retrieve a menu action by its title.
+
+ Args:
+ title (str): Action title.
+ parent (QMenuBar): Parent menu bar to search, if `None` the main menu bar is used.
+ """
+ if parent is None:
+ parent: QMenuBar | None = self._menu_bar
+
+ if parent is None:
+ LOGGER.debug("Menu bar does not seem to have been build yet.")
+ return None
+
+ for action in parent.actions():
+ if action.text() == title:
+ menu = action.menu()
+ if menu is not None: # action is a menu (submenu)
+ return menu
+ return action # action is an entry (leaf)
+
+ LOGGER.debug(f"Unable to find action with title: {title} in {parent!r}")
+ return None
+
+ def build_status_bar(self):
+ status_bar: QStatusBar = self.statusBar()
+
+ # Build thread spinner ---
+ thread_spinner = RunningSpinner(parent=status_bar, center_on_parent=False)
+ status_bar.addPermanentWidget(thread_spinner)
+ thread_spinner.mouseDoubleClickEvent = lambda *args, **kwargs: self.sig_thread_spinner_double_clicked.emit()
+ thread_spinner.stop()
+ self._thread_spinner = thread_spinner
+
+ self.setStatusBar(status_bar)
+ # status_bar.hide() # looks nice, but moves the window around too much ...
+
+ @property
+ def thread_spinner(self) -> RunningSpinner:
+ return self._thread_spinner
+
+ def _adapt_to_screensize(self):
+ """ Sets the window size to 2/3 of the screen size. """
+ screen_shape = QDesktopWidget().screenGeometry()
+ self.resize(
+ int(2 * screen_shape.width() / 3),
+ int(2 * screen_shape.height() / 3)
+ )
+
+ def _adapt_logger(self):
+ """ Changes the appearance of the log console. """
+ if getattr(self, "log_console") is None:
+ return
+ self.log_console.console.expanded = False
+ self.log_console.setFeatures(
+ self.log_console.DockWidgetClosable | self.log_console.DockWidgetMovable
+ )
+ self.log_console.console.formatter = LogConsoleFormatter(show_date=False) # see below
+ for level, color in colors.LOGGING.items():
+ self.log_console.console._set_color_to_scheme(color=QtGui.QColor(color), level=level)
+ self.log_console.console.model.buffer_size = 10_000 # default: 1000
+ if sys.flags.debug:
+ self.log_console.console.model.visible_levels |= {logging.DEBUG}
+
+ def setWindowTitle(self, title: str):
+ super().setWindowTitle(f"{title} v{self.app_version}")
+
+ def toggleFullScreen(self):
+ if self.isFullScreen():
+ self.showNormal()
+ else:
+ self.showFullScreen()
+
+ @Slot()
+ def showAboutDialog(self) -> None:
+ """
+ Display an 'about' dialog.
+ """
+ if AboutDialog is None:
+ return
+
+ name = self.windowTitle()
+ try:
+ name = " ".join(name.split()[:-1])
+ except (IndexError, TypeError, AttributeError):
+ pass
+
+ dialog = AboutDialog(
+ app_name=name,
+ version=self.app_version,
+ icon=self.windowIcon(),
+ parent=self,
+ )
+ dialog.exec_()
+
+ def showErrorDialog(self, title: str, message: str):
+ """ Convenience function to displays an error dialog.
+
+ Args:
+ title (str): Dialog title.
+ message (str): Dialog message.
+ """
+ LOGGER.error(message)
+ show_error_dialog(message, title, parent=self)
+
+
+class LogConsoleFormatter(AccPyLogConsoleFormatter):
+
+ def __init__(self, show_date: bool = True, show_time: bool = True, show_logger_name: bool = True) -> None:
+ """
+ Reimplementation of the AccPy LogConsoleFormatter, to allow for a different logging format.
+
+ Args:
+ show_date: Add date to the log message prefix.
+ show_time: Add time to the log message prefix.
+ show_logger_name: Add logger name to the log message prefix.
+ """
+ super().__init__()
+
+ self.show_date = show_date
+ self.show_time = show_time
+ self.show_logger_name = show_logger_name
+
+ fmt_str = get_console_formatter()._fmt
+ date_format = []
+
+ if show_date:
+ date_format.append("%Y-%m-%d")
+ if show_time:
+ date_format.append("%H:%M:%S")
+
+ if not show_date and not show_time:
+ fmt_str = re.sub(r"\%\(asctime\)\d+s\s+", "", fmt_str)
+
+ if not show_logger_name:
+ fmt_str = re.sub(r"\%\(name\)\d+s\s+", "", fmt_str)
+
+ fmt_str = fmt_str[fmt_str.index("%"):]
+
+ self._fmt = logging.Formatter(fmt=fmt_str, datefmt=" ".join(date_format), style="%")
diff --git a/omc3_gui/ui_components/colors.py b/omc3_gui/ui_components/colors.py
new file mode 100644
index 0000000..e1d666a
--- /dev/null
+++ b/omc3_gui/ui_components/colors.py
@@ -0,0 +1,80 @@
+"""
+UI: Colors
+----------
+
+This module contains the color definitions for the application.
+All colors should be defined here for consistency and the elements
+will then refer to the main color-constants, e.g. `colors.TEXT_DARK`
+(not to the colors themselves).
+This allows for a unified look and feel.
+"""
+import logging
+
+# Predefined Colors ---
+BLACK: str = "#000000"
+BLACK_87: str = "#212121"
+BLACK_54: str = "#757575"
+BLACK_38: str = "#9e9e9e"
+BLACK_26: str = "#bdbdbd"
+BLACK_12: str = "#e1e1e1"
+WHITE: str = "#FFFFFF"
+
+GREEN: str = "#00FF00"
+GREEN_DARK: str = "#28642A"
+GREEN_LIGHT: str = "#4CAF50"
+GREEN_DARK_GREY: str = "#8EA18F"
+GREEN_LIGHT_GREY: str = "#C8E6C9"
+
+RED: str = "#FF0000"
+RED_DARK: str = "#B71C1C"
+RED_LIGHT: str = "#F44336"
+RED_GREY: str = "#CFADAD"
+
+BLUE: str = "#0000FF"
+BLUE_DARK: str = "#0D47A1"
+BLUE_LIGHT: str = "#2196F3"
+BLUE_GREY: str = "#9FA8DA"
+
+ORANGE_DARK: str = "#E65100"
+ORANGE_LIGHT: str = "#FF9800"
+ORANGE_GREY: str = "#F9E0B3"
+
+PURPLE_DARK: str = "#4A148C"
+PURPLE_LIGHT: str = "#9C27B0"
+PURPLE_GREY: str = "#D1C4E9"
+
+CORAL: str = "#E91E63"
+
+# Machine Colors ---
+# LHC Colors
+BEAM1: str = BLUE
+BEAM2: str = RED
+
+# PSB Ring Colors
+RING1: str = GREEN_LIGHT
+RING2: str = ORANGE_LIGHT
+RING3: str = PURPLE_DARK
+RING4: str = CORAL
+
+# Light Background, dark text ---
+TEXT_DARK: str = BLACK_87
+SECONDARY_TEXT_DARK: str = BLACK_54
+GREYED_OUT_TEXT_DARK: str = BLACK_26
+
+# Dark Background, light text ---
+TEXT_LIGHT: str = WHITE
+SECONDARY_TEXT_LIGHT: str = BLACK_12
+
+# Tooltips ---
+TOOLTIP_TEXT: str = BLACK_87
+TOOLTIP_BACKGROUND: str = BLACK_12
+TOOLTIP_BORDER: str = BLACK_38
+
+# Logging ---
+LOGGING: dict[int, str] = {
+ logging.DEBUG: BLACK_26, # default: black
+ logging.INFO: BLACK_87, # default: green
+ # logging.WARNING: ORANGE_LIGHT, # default: orange
+ # logging.ERROR: RED_DARK, # default: red
+ # logging.CRITICAL: CORAL,
+}
\ No newline at end of file
diff --git a/omc3_gui/ui_components/dataclass_ui/__init__.py b/omc3_gui/ui_components/dataclass_ui/__init__.py
new file mode 100644
index 0000000..d9c30fb
--- /dev/null
+++ b/omc3_gui/ui_components/dataclass_ui/__init__.py
@@ -0,0 +1,24 @@
+"""
+DataClass UI
+------------
+
+A simple UI for dataclasses, which allows to edit the values of dataclasses
+by the user within a dialog.
+"""
+from omc3_gui.ui_components.dataclass_ui import controller, view, model
+
+# Dialogs ---
+DataClassDialog = view.DataClassDialog
+SettingsDialog = view.SettingsDialog
+
+# Controller ---
+DataClassUI = controller.DataClassUI
+DataClassTabbedUI = controller.DataClassTabbedUI
+
+# Model ---
+FieldUIDef = model.FieldUIDef
+DirectoryPath = model.DirectoryPath
+FilePath = model.FilePath
+
+metafield = model.metafield
+choices_validator = model.choices_validator
\ No newline at end of file
diff --git a/omc3_gui/ui_components/dataclass_ui/controller.py b/omc3_gui/ui_components/dataclass_ui/controller.py
new file mode 100644
index 0000000..d721b4b
--- /dev/null
+++ b/omc3_gui/ui_components/dataclass_ui/controller.py
@@ -0,0 +1,442 @@
+"""
+DataClass UI: Controller
+------------------------
+
+This module provides classes to generate simple UI's for dataclasses,
+which allow to edit the values of dataclasses in an easy way.
+
+Use these controllers to create and steer the UI's.
+"""
+from __future__ import annotations
+
+import dataclasses as dc
+import logging
+from dataclasses import Field, dataclass, fields
+from functools import partial
+from pathlib import Path
+
+from qtpy import QtWidgets
+
+from omc3_gui.ui_components import colors, file_dialogs
+from omc3_gui.ui_components.dataclass_ui import view, model
+from omc3_gui.ui_components.dataclass_ui.model import FieldUIDef, FilePath, DirectoryPath # noqa: F401 paths need to be known by `get_dataclass_types`
+from omc3_gui.ui_components.widgets import HorizontalSeparator
+from typing import get_type_hints, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Sequence
+
+LOGGER = logging.getLogger(__name__)
+
+
+@dataclass()
+class FieldUI:
+ """ Controller for a single field in a dataclass, connecting it to a widget.
+
+ This class is not meant to be used directly, but is rather automatically
+ generated by and attached to :class:`omc3_gui.utils.dataclass_ui.controller.DataClassUI`.
+ """
+ widget: QtWidgets.QWidget # edit-widget to edit field value
+ label: QtWidgets.QLabel # label-widget of the field
+ get_value: Callable # getter for the widget value, returns the value as appropriate type for the dataclass
+ set_value: Callable # setter for the widget value
+ text_color: str | None = colors.TEXT_DARK # default text-color for both widget and label
+ modified: bool = False # flag indicating if the widget-content has been modified by the user
+
+ def __post_init__(self):
+ """ Connects the widget to the label and sets the text-color. """
+ for main_attribute in ["text", "value", "state"]:
+ try:
+ signal = getattr(self.widget, f"{main_attribute}Changed")
+ except AttributeError:
+ pass
+ else:
+ signal.connect(self.has_changed)
+ break
+ self.widget.setStyleSheet(f"color: {self.text_color};")
+ self.label.setStyleSheet(f"QLabel {{color: {self.text_color}}};")
+
+ def has_changed(self):
+ """ Triggered when the widget has been modified.
+ Sets then the modified flag and changes label font.
+ """
+ self.modified = True
+ font = self.label.font()
+ font.setItalic(True)
+ self.label.setFont(font)
+
+ def reset(self):
+ """ Reset the label font to normal and clear the modified flag. """
+ self.modified = False
+ font = self.label.font()
+ font.setItalic(False)
+ self.label.setFont(font)
+
+
+class DataClassUI:
+ """ Controller for the UI representation of a dataclass.
+ It contains a grid-layout that can be added to any QWidget/QLayout.
+ """
+
+ def __init__(self,
+ field_definitions: Sequence[FieldUIDef],
+ dclass: type | object,
+ layout: QtWidgets.QLayout | None = None
+ ):
+ """ Builds a DataClassUI from a list of field definitions.
+
+ NOTE: `dclass` is not automatically attached to the resulting class, unless it is an already an instance.
+ This function works with classes and instances, but the attached object needs to be the instance.
+
+ Args:
+ field_def (Sequence[FieldUIDef | str]): list of field definitions
+ dclass (type | object): DataClass type or instance.
+ layout (QtWidgets.QLayout | None): Layout to add the UI to. If None, a new grid-layout is created.
+
+ Returns:
+ DataClassUI: An instances with a grid-layout containing edit-widgets and labels.
+ """
+ if layout is None:
+ layout = QtWidgets.QGridLayout()
+
+ self.model: object = None if isinstance(dclass, type) else dclass # dataclass instance
+ self.layout: QtWidgets.QGridLayout = layout # final layout of the UI for dataclass
+ self.fields: dict[str, FieldUI] = {} # stored field UI-elements
+
+ field_instances: dict[str, Field] = {field.name: field for field in fields(dclass)}
+ field_types = get_dataclass_types(
+ dclass,
+ [
+ field.name for field in field_definitions
+ if field is not None and not isinstance(field, str) and field.name in field_instances.keys()
+ ]
+ )
+ #
+
+ for idx_row, field_def in enumerate(field_definitions):
+ if field_def is None: # Separator ---
+ layout.addWidget(HorizontalSeparator(), idx_row, 0, 1, 3)
+ continue
+
+ if isinstance(field_def, str): # Label only ---
+ layout.addWidget(QtWidgets.QLabel(field_def), idx_row, 0)
+ continue
+
+ if field_def.name not in field_instances:
+ raise ValueError(f"Field {field_def.name} not found in dataclass {dclass}")
+
+ field_inst = field_instances[field_def.name]
+
+ # Label ---
+ qlabel = QtWidgets.QLabel(field_def.label or field_inst.metadata.get("label", field_def.name))
+ qlabel.setToolTip(field_def.comment or field_inst.metadata.get("comment", ""))
+ layout.addWidget(qlabel, idx_row, 0)
+
+ # User input ---
+ # If field_def.type is not given, use evaluate from dataclass.
+ # Check __args__ in case of Union/Optional and use first one.
+ # The type needs to be instanciable!
+ eval_type = field_types[field_def.name] # evaluated type, might be Optional/Union etc with __args__
+ field_type = field_def.pytype or getattr(eval_type, "__args__", [eval_type])[0]
+
+ widget = TYPE_TO_WIDGET_MAP.get(field_type, QtWidgets.QLineEdit)()
+
+ try:
+ widget.setReadOnly(not field_def.editable)
+ except AttributeError:
+ widget.setEnabled(field_def.editable)
+
+ get_value, set_value = build_getter_setter(widget, field_type)
+ self.fields[field_def.name] = FieldUI(
+ widget=widget,
+ label=qlabel,
+ set_value=set_value,
+ get_value=get_value,
+ text_color=colors.TEXT_DARK if field_def.editable else colors.GREYED_OUT_TEXT_DARK
+ )
+
+ if not issubclass(field_type, Path) or not field_def.editable:
+ layout.addWidget(widget, idx_row, 1, 1, 2)
+ else:
+ layout.addWidget(widget, idx_row, 1)
+ # Add Path selection button ---
+ button = QtWidgets.QPushButton("...")
+ button.setFixedWidth(30)
+
+ if field_type is model.FilePath:
+ dialog = file_dialogs.OpenFileDialog
+ elif field_type is model.DirectoryPath:
+ dialog = file_dialogs.OpenDirectoryDialog
+ else:
+ dialog = file_dialogs.OpenAnySingleDialog
+
+ button.clicked.connect(partial(
+ run_dialog,
+ dialog=dialog,
+ get_value=get_value,
+ set_value=set_value))
+ layout.addWidget(button, idx_row, 2)
+
+ if self.model is not None:
+ self.update_ui() # fill with values from the instance
+
+ def reset_labels(self):
+ """ Resets all labels to indicate that the field shows the currently set value in the dataclass."""
+ for name in self.fields.keys():
+ self.fields[name].reset()
+
+ def update_widget_from_model(self, name: str):
+ """ Updates the edit-widget of the given field from the dataclass values.
+
+ Args:
+ name (str): name of the field in the dataclass
+ """
+ if self.model is None:
+ raise ValueError("No dataclass instance attached to the UI.")
+
+ value = getattr(self.model, name)
+ if value is not None:
+ self.fields[name].set_value(value)
+
+ def update_model_from_widget(self, name: str):
+ """ Updates the dataclass value of the given field from the edit-widget.
+
+ Args:
+ name (str): name of the field in the dataclass
+ """
+ if self.model is None:
+ raise ValueError("No dataclass instance attached to the UI.")
+
+ field: FieldUI = self.fields[name]
+
+ if not field.modified: # avoid replacing 'None' with widget defaults
+ LOGGER.debug(f"Field {name} was not modified.")
+ return
+
+ value = field.get_value()
+ setattr(self.model, name, value)
+
+ def update_ui(self):
+ """ Updates all edit-widgets from the dataclass values. """
+ for name in self.fields.keys():
+ self.update_widget_from_model(name)
+
+ def update_model(self):
+ """ Updates all dataclass fields from the current edit-widget values. """
+ for name in self.fields.keys():
+ self.update_model_from_widget(name)
+
+ def validate(self, only_modified: bool = False):
+ """ Checks all edit-widgets for valid choices.
+
+ The validation function NEED to return a truthy value, if
+ the choice is valid. If it is invalid, they can either return a falsy
+ value or raise an exception. In the latter case, this exception will be
+ printed instead of the default message.
+ """
+ if self.model is None:
+ raise ValueError("No dataclass instance attached to the UI.")
+
+ invalid_fields_str = []
+ for name in self.fields.keys():
+ field = self.model.__dataclass_fields__[name]
+ field_ui = self.fields[field.name]
+ if only_modified and not field_ui.modified:
+ continue
+
+ validate_function = field.metadata.get("validate")
+ if validate_function is not None:
+ value = self.fields[field.name].get_value()
+
+ validation_result = False
+ try:
+ validation_result = validate_function(value)
+ except ValueError as e:
+ invalid_fields_str.append(f"{field_ui.label.text()}: {str(e)}")
+ else:
+ if not validation_result:
+ invalid_fields_str.append(f"{field_ui.label.text()}: {value} is not a valid choice.")
+
+ if invalid_fields_str:
+ full_str = "\n".join(invalid_fields_str)
+ raise ValueError(f'The following fields contain wrong values:\n{full_str}')
+
+
+class DataClassTabbedUI():
+ """ Controller for the UI representation of a dataclass, containing other dataclasses as fields.
+ It contains a tabbed layout that can be added to any QWidget.
+ """
+
+ def __init__(self,
+ dclass: object | type,
+ layout: QtWidgets.QLayout | None = None,
+ widget: QtWidgets.QTabWidget | None = None
+ ):
+ """ Builds a DataClassTabbedUI from a dataclass, using DataClassUIs as widgets in the tabs.
+ NOTE: As with the DataClassUI, the dataclass (`dclass`) is not automatically
+ attached to the resulting class,
+
+ Args:
+ dclass (object | type): dataclass instance or class.
+ Needs to contain dataclasses as fields, which will converted to tabs.
+ layout (QtWidgets.QLayout | None): Layout to add the UI widget to.
+ If None, a new QVBoxLayout is created.
+ widget (QtWidgets.QTabWidget | None): TabWidget to add the UI tabs to.
+ If None, a new QTabWidget is created.
+ The widget is always added to the layout.
+ """
+ self.layout: QtWidgets.QLayout = layout or QtWidgets.QVBoxLayout()
+ self.widget: QtWidgets.QTabWidget = widget or QtWidgets.QTabWidget()
+ self._dataclass_uis: dict[str, DataClassUI] = {}
+
+ self.layout.addWidget(self.widget)
+
+ fields = [field.name for field in dc.fields(dclass) if field.name[0] != "_"]
+ for field_name in fields:
+ sub_dclass = getattr(dclass, field_name)
+ dataclass_ui = DataClassUI(
+ field_definitions=[FieldUIDef(field.name) for field in dc.fields(sub_dclass) if field.name[0] != "_"],
+ dclass=sub_dclass,
+ )
+ ui_widget = QtWidgets.QWidget()
+ ui_widget.setLayout(dataclass_ui.layout)
+ self.widget.addTab(ui_widget, field_name.capitalize())
+ self._dataclass_uis[field_name] = dataclass_ui
+
+ self._model: object = None
+ if not isinstance(dclass, type):
+ self.model = dclass # also updates ui with values
+
+ @property
+ def model(self):
+ return self._model
+
+ @model.setter
+ def model(self, model: object):
+ """ Sets the model and updates the dataclass_uis.
+ This does not add new dataclass_uis to the layout and throws errors
+ if fields in the model are missing. """
+ self._model = model
+ for name, dataclass_ui in self._dataclass_uis.items():
+ dataclass_ui.model = getattr(model, name)
+ dataclass_ui.update_ui()
+
+ # Function to pass on to dataclass_uis ---
+ def update_ui(self):
+ for dataclass_ui in self._dataclass_uis.values():
+ dataclass_ui.update_ui()
+
+ def update_model(self):
+ for dataclass_ui in self._dataclass_uis.values():
+ dataclass_ui.update_model()
+
+ def validate(self, only_modified: bool = False):
+ for dataclass_ui in self._dataclass_uis.values():
+ dataclass_ui.validate(only_modified=only_modified)
+
+ def reset_labels(self):
+ for dataclass_ui in self._dataclass_uis.values():
+ dataclass_ui.reset_labels()
+
+
+# Helpers for getter/setter and widget type ------------------------------------
+
+TYPE_TO_WIDGET_MAP = {
+ int: view.QFullIntSpinBox, # Maybe just use QLineEdit as well?
+ float: QtWidgets.QLineEdit,
+ str: QtWidgets.QLineEdit,
+ Path: QtWidgets.QLineEdit,
+ bool: QtWidgets.QCheckBox,
+}
+
+
+def build_getter_setter(widget: QtWidgets.QWidget, field_type: type) -> tuple[Callable, Callable]:
+ """ Getter/Setter Factory for widgets.
+
+ Args:
+ widget (QtWidgets.QWidget): The widget to get/set the value of.
+ field_type (type): The type of the dataclass field.
+ """
+ if isinstance(widget, QtWidgets.QCheckBox):
+ def get_value() -> bool:
+ return widget.isChecked()
+
+ def set_value(value: bool):
+ widget.setChecked(value)
+
+ elif isinstance(widget, QtWidgets.QSpinBox):
+ def get_value():
+ return field_type(widget.value())
+
+ def set_value(value):
+ widget.setValue(value)
+
+ else: # Any kind of widget should be able to handle strings.
+ def get_value():
+ if not widget.text():
+ return None
+ return field_type(widget.text())
+
+ def set_value(value):
+ widget.setText(str(value))
+ return get_value, set_value
+
+
+def run_dialog(dialog: file_dialogs.OpenFilesDialog, get_value: Callable, set_value: Callable):
+ """ Asks the user to select a directory/file, using the `get_value` function
+ o determine the default directory and passing the result to `set_value` if not `None`.
+
+ Args:
+ dialog (file_dialogs.OpenFilesDialog): The dialog to run.
+ get_value (Callable): The function to get the last value, e.g. from a widget.
+ set_value (Callable): The function to set the value returned from the dialog.
+ """
+ old_path: Path = get_value()
+ if old_path is not None:
+ old_path.parent
+ else:
+ old_path = Path()
+ path = dialog(directory=old_path).run_selection_dialog()
+ if path is not None:
+ set_value(path)
+
+
+def get_dataclass_types(dclass: type | object, names: Sequence[str]):
+ """
+ Returns a dictionary mapping field names to their associated types.
+
+ Why this weird way?
+ First of all, we avoided the cyclic imports of type-hints by using
+ the `from __future__ import annotations`, but whith
+ this, all type-hints on the dataclasses become `ForwardReferences`,
+ i.e. strings. So if we get them from the dataclass-fields
+ via `field.pytype` or `field.pytype.__args__`, we cannot instanciate them later on.
+
+ `get_type_hints()` from `typing` solves this problem, but it
+ evaluates the type-hints of all fields in the dataclass.
+ But this can cause again import errors, because the classes were not actually imported
+ (this is why there are no cyclic imports)!
+
+ So this function allows using `get_type_hints()` on the dataclass
+ itself, but only gets the types of the fields that we need, in the hopes
+ that there is no import problem with those.
+
+ In the use-case of the DataClass UI here, we should only have very basic
+ types for the fields, so this should be fine.
+
+ If you are here because there is, you need to redesign your dataclass/imports.
+ Sorry.
+
+ Args:
+ dclass (type): The data class to extract field types from.
+ names (Sequence[str]): The names of the fields to extract types from.
+
+ Returns:
+ Dict[str, type]: A dictionary mapping field names to their associated types.
+ """
+ required_annotations = {
+ name: value for name, value in dict(dclass.__annotations__).items() if name in names
+ }
+ class SafeMock:
+ __annotations__ = required_annotations
+ return get_type_hints(SafeMock)
diff --git a/omc3_gui/ui_components/dataclass_ui/model.py b/omc3_gui/ui_components/dataclass_ui/model.py
new file mode 100644
index 0000000..211c4bb
--- /dev/null
+++ b/omc3_gui/ui_components/dataclass_ui/model.py
@@ -0,0 +1,85 @@
+
+"""
+DataClass UI: Model
+-------------------
+
+The backend components that make up the dataclass UI.
+These are used as inputs to the controllers to define
+the meta-data and the type of the dataclass fields,
+so that the controller can find the fitting widgets and provide
+the user with additional information.
+"""
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable
+from dataclasses import MISSING, Field, dataclass, field
+from pathlib import Path
+from typing import Any
+
+LOGGER = logging.getLogger(__name__)
+
+@dataclass(slots=True)
+class MetaData:
+ """ Metadata for a dataclass-field. """
+ label: str | None = None
+ comment: str | None = None
+ validate: Callable | None = None # needs to return a truthy value if valid, else falsy or raise
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+ def get(self, key, default=None):
+ return getattr(self, key, default)
+
+
+def metafield(
+ label: str,
+ comment: str,
+ default=MISSING,
+ validate: Callable | None = None,
+ ) -> Field:
+ """ Convenience function to create a dataclass-field with metadata. """
+ return field(
+ default=default,
+ metadata=MetaData(label=label, comment=comment, validate=validate)
+ )
+
+
+def choices_validator(*choices: Any) -> Callable:
+ """ Return a validator that checks if a given value is in the choices. """
+ def validator(value):
+ if value not in choices:
+ raise ValueError(f"Value {value} is not in {choices}.")
+ return True
+ return validator
+
+
+class FilePath(type(Path())):
+ """ Convenience Class to indicate that the Path should lead to a file.
+ For the inheritance: https://stackoverflow.com/questions/29850801/subclass-pathlib-path-fails
+ """
+ pass
+
+
+class DirectoryPath(type(Path())):
+ """ Convenience Class to indicate that the Path should lead to a directory.
+ For the inheritance: https://stackoverflow.com/questions/29850801/subclass-pathlib-path-fails
+ """
+ pass
+
+
+@dataclass(slots=True)
+class FieldUIDef:
+ """ Definition of an FieldUI to be generated by
+ :class:`omc3_gui.utils.dataclass_ui.controller.DataClassUI`.
+ This defines how the label and edit-field widgets should look like
+ for a field in the dataclass to be UI'ed.
+ Missing information is parsed in from the dataclass
+ isetlf if possible, but values given here take precedence.
+ """
+ name: str # name of the field in the dataclass
+ label: str | None = None # label of the field
+ pytype: type | None = None # type of the field's data, needs to be instanciable
+ comment: str | None = None # comment for the field, e.g. used for tooltips
+ editable: bool | None = True # sets field to be editable
\ No newline at end of file
diff --git a/omc3_gui/ui_components/dataclass_ui/tools.py b/omc3_gui/ui_components/dataclass_ui/tools.py
new file mode 100644
index 0000000..5aa6693
--- /dev/null
+++ b/omc3_gui/ui_components/dataclass_ui/tools.py
@@ -0,0 +1,133 @@
+"""
+DataClass UI: Tools
+-------------------
+
+Additional tools that can be used with dataclasses.
+"""
+from __future__ import annotations
+
+from dataclasses import Field, fields
+import inspect
+import json
+import logging
+from pathlib import Path
+import re
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+def get_field_inline_comments(dclass: type) -> dict[str, str]:
+ """
+ Returns a dictionary mapping field names to their associated inline code-comments.
+ Has been replaced by the use of the metadata, but I like the function,
+ so I leave it here. (jdilly 2023)
+
+ Parameters:
+ dclass (type): The data class to extract field comments from.
+
+ Returns:
+ Dict[str, str]: A dictionary mapping field names to their associated comments.
+ """
+ matcher = re.compile(r"^(?P[a-zA-Z_]+)\s*:\s*[^#]+#\s*(?P.*)\s*$")
+ source = inspect.getsource(dclass)
+
+ found_fields = {}
+ for line in source.splitlines()[2:]: # first two is @dataclass and name
+ line = line.strip()
+ if line.startswith('def '):
+ break
+
+ match = matcher.match(line)
+ if match:
+ found_fields[match.group('field')] = match.group('comment')
+
+ return found_fields
+
+
+# JSON -------------------------------------------------------------------------
+
+# Load ---
+
+def load_dataclass_from_json(
+ dclass: type,
+ json_file: str | Path
+):
+ """
+ Load a dataclass from a JSON file.
+ Selects only the fields that are in the dataclass. Ignores fields that start with an underscore.
+
+ Parameters:
+ dclass (type): The data class to load from the JSON file.
+ json_file (str): The path to the JSON file to load the data class from.
+
+ Returns:
+ object: An instance of the data class loaded from the JSON file.
+ """
+ return dclass(**_load_json_file(json_file, dclass))
+
+
+def update_dataclass_from_json(
+ data: object,
+ json_file: str | Path
+):
+ """
+ Update a dataclass with data from a JSON file.
+ Selects only the fields that are in the dataclass and json file.
+ Ignores fields that start with an underscore.
+
+ Parameters:
+ dclass (object): The data class instance to update from to the JSON file.
+ json_file (str): The path to the JSON file to load the data from.
+
+ Returns:
+ object: An instance of the data class loaded from the JSON file.
+ """
+ json_data = _load_json_file(json_file, data)
+ for key, value in json_data.items():
+ setattr(data, key, value)
+ return data
+
+
+def _load_json_file(json_file: str | Path, dclass: object | type | None = None) -> dict:
+ """ Load a JSON file and return a dictionary of the data.
+ The data is converted to a path if the field type requires it and filtered if the field name
+ is either not in the dataclass or starts with an underscore. """
+ data: dict = json.loads(json_file.read_text())
+ if dclass is None:
+ return data
+ return {f.name: _maybe_path(data.get(f.name, None), f) for f in fields(dclass) if f.name[0] != "_"}
+
+
+def _maybe_path(value: str | Path, field: Field) -> Path:
+ """ Convert value to a path, if the field type requires it. """
+
+ if isinstance(field.type, str):
+ if "Path" in field.type and value is not None:
+ return Path(value)
+ return value
+
+ if issubclass(field.type, Path) and value is not None:
+ return Path(value)
+ return value
+
+
+# Save ----
+
+def save_dataclass_to_json(
+ data: object,
+ json_file: str | Path
+):
+ """
+ Save a dataclass to a JSON file.
+ Ignores fields that start with an underscore.
+
+ Parameters:
+ dclass (object): The data class instance to save to the JSON file.
+ json_file (str): The path to the JSON file to save the data class to.
+ """
+ data = {f.name: getattr(data, f.name) for f in fields(data) if f.name[0] != "_"}
+ for key, value in data.items():
+ if isinstance(value, Path):
+ data[key] = str(value)
+ json_file.write_text(f"{json.dumps(data, indent=2)}\n")
diff --git a/omc3_gui/ui_components/dataclass_ui/view.py b/omc3_gui/ui_components/dataclass_ui/view.py
new file mode 100644
index 0000000..ea2e1be
--- /dev/null
+++ b/omc3_gui/ui_components/dataclass_ui/view.py
@@ -0,0 +1,130 @@
+
+"""
+DataClass UI: View
+------------------
+
+The frontend components that make up the dataclass UI,
+in particular the dialog windows that can be used to edit dataclasses.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Protocol
+
+from qtpy import QtWidgets
+
+from omc3_gui.ui_components.dataclass_ui import controller
+
+LOGGER = logging.getLogger(__name__)
+class DataClassInterface(Protocol):
+ """ Protocol for the DataClassDialog.
+ Observed by :class:`omc3_gui.utils.dataclass_ui.controller.DataClassUI` and
+ :class:`omc3_gui.utils.dataclass_ui.controller.DataClassTabbedUI`,
+ both of which can hence be used with the dialog.
+ """
+ layout: QtWidgets.QLayout
+ model: object
+
+ def update_ui(self): ...
+ def update_model(self): ...
+ def validate(self, only_modified: bool = False): ...
+ def reset_labels(self): ...
+
+
+class DataClassDialog(QtWidgets.QDialog):
+ """ Simple dialog window to display the DataClassUI layout.
+ Adds some convenience functionality like "Ok" and "Cancel" buttons
+ and automatic data-validation on close.
+ """
+ WINDOW_TITLE = "Edit DataClass"
+ DEFAULT_SIZE = (800, 600) # width, height, use -1 for auto
+
+ def __init__(self, dataclass_ui: DataClassInterface, parent = None):
+ super().__init__(parent)
+ self._button_box: QtWidgets.QDialogButtonBox = None
+
+ self._dataclass_ui: DataClassInterface = dataclass_ui
+ self._build_gui()
+ self._connect_signals()
+ self._set_size(width=self.DEFAULT_SIZE[0], height=self.DEFAULT_SIZE[1])
+ self.update_ui()
+ self.validate_only_modified: bool = True
+
+
+ def _set_size(self, width: int = -1, height: int = -1):
+ # Set position to the center of the parent (does not work in WSL for me, jdilly 2023)
+ # parent = self.parent()
+ # if parent is not None:
+ # parent_geo = parent.geometry()
+ # parent_pos = parent.mapToGlobal(parent.pos()) # multiscreen support
+ # if width >= 0:
+ # x = parent_pos.x() + parent_geo.width() / 2
+ # else:
+ # x = parent_pos.x() + (parent_geo.width() - width) / 2
+
+ # if height >=0 :
+ # y = parent_pos.y() + parent_geo.height() / 2
+ # else:
+ # y = parent_pos.y() + (parent_geo.height() - height) / 2
+ # self.move(x, y)
+
+ # Set size
+ self.resize(width, height)
+
+ def _build_gui(self):
+ self.setWindowTitle(self.WINDOW_TITLE)
+ layout = QtWidgets.QVBoxLayout()
+ layout.addLayout(self._dataclass_ui.layout)
+
+ QBtn = QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
+ self._button_box = QtWidgets.QDialogButtonBox(QBtn)
+ layout.addWidget(self._button_box)
+
+ self.setLayout(layout)
+
+ def _connect_signals(self):
+ self._button_box.accepted.connect(self.accept)
+ self._button_box.rejected.connect(self.reject)
+
+ def update_ui(self, new_model: object = None):
+ if new_model is not None:
+ self._dataclass_ui.model = new_model
+ self._dataclass_ui.update_ui() # triggers changes, so the labels appear in "changed" state
+ self._dataclass_ui.reset_labels() # so we reset them
+
+ def accept(self):
+ try:
+ self._dataclass_ui.validate(only_modified=self.validate_only_modified)
+ except ValueError as e:
+ QtWidgets.QMessageBox.critical(self, "Error", str(e))
+ return
+
+ self._dataclass_ui.update_model()
+ super().accept()
+
+
+class SettingsDialog(DataClassDialog):
+ """ Slight modification of the DataClassDialog to be used for Tabbed-Settings. """
+
+ WINDOW_TITLE = "Settings"
+ DEFAULT_SIZE = (800, -1)
+
+ def __init__(self, settings: object, parent=None):
+ dataclass_tabbed_ui = controller.DataClassTabbedUI(dclass=settings)
+ super().__init__(dataclass_ui=dataclass_tabbed_ui, parent=parent)
+
+ @property
+ def settings(self) -> object:
+ return self._dataclass_ui.model
+
+
+# Type-to-Widget Helpers ----------------------------------------------------------------
+
+class QFullIntSpinBox(QtWidgets.QSpinBox):
+ """ Like a QSpinBox, but overwriting default range(0,100) with maximum integer range. """
+
+ def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setRange(-2**31, 2**31 - 1) # range of signed 32-bit integers
+
+
diff --git a/omc3_gui/ui_components/file_dialogs.py b/omc3_gui/ui_components/file_dialogs.py
new file mode 100644
index 0000000..8343c91
--- /dev/null
+++ b/omc3_gui/ui_components/file_dialogs.py
@@ -0,0 +1,119 @@
+"""
+UI: File Dialogs
+----------------
+
+Helper functions to open files.
+"""
+import logging
+from pathlib import Path
+
+from qtpy.QtWidgets import QApplication, QFileDialog, QStyle
+
+LOGGER = logging.getLogger(__name__)
+
+
+# Open Dialog Windows ----------------------------------------------------------
+class OpenFilesDialog(QFileDialog):
+ """ Quick dialog to open any kind of file.
+ Modifies QFileDialog, and allows only kwargs to be passed.
+ """
+
+ def __init__(self, **kwargs) -> None:
+ if "directory" in kwargs and isinstance(kwargs["directory"], Path):
+ kwargs["directory"] = str(kwargs["directory"]) # allow giving Paths
+
+ super().__init__(**kwargs) # parent, caption, directory, filter, options
+ self.setOption(QFileDialog.Option.DontUseNativeDialog, True)
+
+ def run_selection_dialog(self) -> list[Path]:
+ if self.exec_():
+ return [Path(f) for f in self.selectedFiles()]
+ return []
+
+
+class OpenFileDialog(OpenFilesDialog):
+ """ Open a single file. """
+
+ def __init__(self, caption: str = "Select File", **kwargs) -> None:
+ super().__init__(caption=caption, **kwargs) # parent, directory, filter, options
+ self.setFileMode(QFileDialog.FileMode.ExistingFile)
+
+ def run_selection_dialog(self) -> Path:
+ selected = super().run_selection_dialog()
+ if selected:
+ return selected[0]
+ return None
+
+
+class OpenDirectoriesDialog(OpenFilesDialog):
+ """ Open multiple directories. """
+
+ def __init__(self, caption: str = "Select Folders", **kwargs) -> None:
+ super().__init__(caption=caption, **kwargs) # parent, directory, filter, options
+ icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)
+ self.setWindowIcon(icon)
+ self.setOption(QFileDialog.Option.ShowDirsOnly, True)
+ self.setFileMode(QFileDialog.FileMode.ExistingFiles)
+
+ def accept(self):
+ """This function is called when the user clicks on "Open".
+ Normally, when selecting a directories, the first directory is followed/opened inside the dialog,
+ i.e. its content is shown. Overwrite super().accept() to prevent that and close the dialog instead.
+ """
+ for selected in self.selectedFiles():
+ if not Path(selected).is_dir(): # this should not happen, as only directories are shown
+ LOGGER.warning(f"{selected} is not a directory. Try again.")
+ return
+
+ self.done(QFileDialog.Accepted)
+
+
+class OpenDirectoryDialog(OpenDirectoriesDialog):
+ """ Open a single directory. """
+
+ def __init__(self, caption: str = "Select Folder", **kwargs) -> None:
+ super().__init__(caption=caption, **kwargs) # parent, directory, filter, options
+ self.setFileMode(QFileDialog.FileMode.Directory)
+
+ def run_selection_dialog(self) -> Path:
+ selected = super().run_selection_dialog()
+ if selected:
+ return selected[0]
+ return None
+
+
+class OpenAnyMultiDialog(OpenFilesDialog):
+ """ Open multiple files/folders. """
+
+ def __init__(self, caption: str = "Select Files", existing: bool = True, **kwargs) -> None:
+ super().__init__(caption=caption, **kwargs) # parent, directory, filter, options
+ if existing:
+ self.setFileMode(QFileDialog.FileMode.ExistingFiles)
+
+ def accept(self):
+ """This function is called when the user clicks on "Open".
+ Normally, when selecting a directories, the first directory is followed/opened inside the dialog,
+ i.e. its content is shown.
+ Overwrite super().accept() to prevent that and close the dialog instead.
+ """
+ if not self.selectedFiles():
+ LOGGER.warning("Nothing selected. Try again or cancel.")
+ return
+
+ self.done(QFileDialog.Accepted)
+
+
+class OpenAnySingleDialog(OpenAnyMultiDialog):
+ """ Open a single file/folder. """
+
+ def __init__(self, caption: str = "Select Single", **kwargs) -> None:
+ existing = kwargs.get("existing", True)
+ super().__init__(caption=caption, **kwargs) # parent, directory, filter, options
+ if existing:
+ self.setFileMode(QFileDialog.FileMode.ExistingFile)
+
+ def run_selection_dialog(self) -> Path:
+ selected = super().run_selection_dialog()
+ if selected:
+ return selected[0]
+ return None
diff --git a/omc3_gui/ui_components/item_models.py b/omc3_gui/ui_components/item_models.py
new file mode 100644
index 0000000..97fcb67
--- /dev/null
+++ b/omc3_gui/ui_components/item_models.py
@@ -0,0 +1,111 @@
+"""
+UI: Item Models
+---------------
+
+Classes that makes it easier to handle unique items in UI models.
+"""
+from __future__ import annotations
+
+from typing import Any, Protocol
+from collections.abc import Hashable, Sequence
+
+from qtpy import QtCore
+from qtpy.QtCore import Qt
+
+
+class Item(Protocol):
+ """ Protocol for a class that has an 'id'-property.
+ As used in :class:`omc3_gui.segment_by_segment.main_model.UniqueItemListModel`,
+ this id defines whether two items are "the same", i.e. only one of them
+ can be present in the model.
+ Example: For the Segments, this should be the name, as if we have two segements
+ with the same name, running them overwrites each other.
+ """
+ id: Hashable
+
+
+class UniqueItemListModel:
+ """ Mixin-Class for a class that has a dictionary of items.
+ Note: I have considered using QAbstractItemModel/QStandardItemModel,
+ but I do not need all the features it provides, so this should be easier
+ and allows for keeping items unique (jdilly, 2023).
+ All items need to have an 'id'-property.
+ """
+ def __init__(self):
+ self._items: list[Item] = []
+
+ def try_emit_change(self, emit: bool = True):
+ """ Emits a dataChanged-signal if the model has changed, and if the
+ class provides such a signal. """
+ if not emit:
+ return
+
+ if hasattr(self, "dataChanged"):
+ # TODO: return which data has actually changed?
+ try:
+ idx_start = self.index(0) # list
+ idx_end = self.index(self.rowCount()-1)
+ except TypeError:
+ idx_start = self.index(0, 0) # table
+ idx_end = self.index(self.rowCount()-1, self.columnCount()-1)
+ self.headerDataChanged.emit(Qt.Horizontal, 0, self.columnCount()-1)
+ self.dataChanged.emit(idx_start, idx_end)
+
+ def add_item(self, item: Item, emit: bool = True):
+ """ Adds an item to the model,
+ if an item with the same id is not already present. """
+ if item.id in [i.id for i in self._items]:
+ raise ValueError(f"Item {item.id} already exists")
+ self._items.append(item)
+ self.try_emit_change(emit)
+
+ def add_items(self, items: Sequence[Item]):
+ """ Adds all items from a list to the model,
+ for which items with the same id are not already present. """
+ already_exist_items = []
+ for item in items:
+ try:
+ self.add_item(item, emit=False)
+ except ValueError:
+ already_exist_items.append(item)
+ self.try_emit_change()
+ if already_exist_items:
+ raise ValueError(f"Items already exist: {already_exist_items}")
+
+ def remove_item(self, item: Item, emit: bool = True):
+ """ Removes an item from the model. """
+ self._items.remove(item)
+ self.try_emit_change(emit)
+
+ def remove_items(self, items: Sequence[Item]):
+ """ Removes all items from a list from the model, if they exist. """
+ do_not_exist_items = []
+ for item in items:
+ try:
+ self.remove_item(item, emit=False)
+ except ValueError:
+ do_not_exist_items.append(item)
+ self.try_emit_change()
+ if do_not_exist_items:
+ raise ValueError(f"Items do not exist: {do_not_exist_items}")
+
+ def clear(self):
+ """ Removes all items from the model. """
+ self._items = []
+ self.try_emit_change()
+
+ def remove_item_at(self, index: int):
+ self.remove_item(self.get_item_at(index))
+
+ def remove_items_at(self, indices: Sequence):
+ self.remove_items([self.get_item_at(index) for index in indices])
+
+ def get_item_at(self, index: int) -> Any:
+ return self._items[index]
+
+ def get_index(self, item: Item) -> QtCore.QModelIndex:
+ idx_item = self._items.index(item)
+ try:
+ return self.index(idx_item)
+ except TypeError:
+ return self.index(idx_item, 0)
diff --git a/omc3_gui/ui_components/message_boxes.py b/omc3_gui/ui_components/message_boxes.py
new file mode 100644
index 0000000..bce26b9
--- /dev/null
+++ b/omc3_gui/ui_components/message_boxes.py
@@ -0,0 +1,51 @@
+"""
+UI: Message Boxes
+-----------------
+
+Helper functions to display message boxes.
+"""
+from qtpy.QtWidgets import QMessageBox, QWidget
+
+def show_confirmation_dialog(question: str, title: str = "Confirmation", parent: QWidget = None) -> bool:
+ """ Displays a confirmation dialog.
+
+ Could also be done with QMessageBox.question(parent, title, question, QMessageBox.Ok | QMessageBox.Cancel).
+
+ Args:
+ question (str): Dialog question.
+ title (str): Dialog title.
+ parent (QtWidgets.QWidget): Parent widget.
+
+ Returns:
+ bool: True if the user confirmed, False otherwise
+ """
+ msg_box = QMessageBox(
+ QMessageBox.Question,
+ title,
+ question,
+ QMessageBox.Ok | QMessageBox.Cancel,
+ parent
+ )
+ msg_box.setDefaultButton(QMessageBox.Cancel)
+ result = msg_box.exec_()
+ return result == QMessageBox.Ok
+
+
+def show_error_dialog(message: str, title: str = "Error", parent: QWidget = None):
+ """ Displays an error dialog.
+
+ This is a convenience function to displays an error dialog.
+
+ Args:
+ title (str): Dialog title.
+ message (str): Dialog message.
+ parent (QtWidgets.QWidget): Parent widget.
+ """
+ message_box = QMessageBox(
+ QMessageBox.Critical,
+ title,
+ message,
+ QMessageBox.Ok,
+ parent,
+ )
+ message_box.exec_()
\ No newline at end of file
diff --git a/omc3_gui/ui_components/styles.py b/omc3_gui/ui_components/styles.py
new file mode 100644
index 0000000..fec6f71
--- /dev/null
+++ b/omc3_gui/ui_components/styles.py
@@ -0,0 +1,16 @@
+"""
+UI: Styles
+----------
+
+Helper functions to style UI elements and plots.
+"""
+from omc3_gui.ui_components import colors
+
+MONOSPACED_TOOLTIP = f"""
+ QToolTip {{
+ background-color: {colors.TOOLTIP_BACKGROUND}; /* Light gray background */
+ color: {colors.TOOLTIP_TEXT}; /* Dark gray text */
+ border: 1px solid {colors.TOOLTIP_BORDER}; /* Gray border */
+ font-family: "Courier New", monospace; /* Monospaced font */
+ }}
+"""
\ No newline at end of file
diff --git a/omc3_gui/ui_components/text_editor.py b/omc3_gui/ui_components/text_editor.py
new file mode 100644
index 0000000..88d22b6
--- /dev/null
+++ b/omc3_gui/ui_components/text_editor.py
@@ -0,0 +1,52 @@
+"""
+UI: Text Editor Dialog
+----------------------
+
+This module provides a dialog for editing text files.
+"""
+
+from pathlib import Path
+
+from qtpy.QtWidgets import QDialog, QDialogButtonBox, QTextEdit, QVBoxLayout
+
+from omc3_gui.ui_components.message_boxes import show_error_dialog
+
+class TextEditorDialog(QDialog):
+ def __init__(self, file_path: str | Path, title: str = "Edit {}", parent = None):
+ super().__init__(parent)
+ self.file_path: Path = Path(file_path)
+ self.setWindowTitle(title.format(self.file_path.name)) # maybe better absolute path?
+
+ self.text_edit = QTextEdit(self)
+ self.load_file_content()
+
+ self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
+ self.buttons.accepted.connect(self.save_changes)
+ self.buttons.rejected.connect(self.reject)
+
+ layout = QVBoxLayout()
+ layout.addWidget(self.text_edit)
+ layout.addWidget(self.buttons)
+ self.setLayout(layout)
+
+ def load_file_content(self):
+ try:
+ content = self.file_path.read_text()
+ except FileNotFoundError:
+ return # empty or new file
+
+ self.text_edit.setPlainText(content)
+
+ def save_changes(self):
+ content = self.text_edit.toPlainText()
+ try:
+ self.file_path.write_text(content)
+ except PermissionError:
+ show_error_dialog(
+ message=f"Could not write to file {self.file_path.absolute()}.",
+ title="Permission Error",
+ parent=self,
+ )
+ return
+
+ self.accept()
\ No newline at end of file
diff --git a/omc3_gui/ui_components/threads.py b/omc3_gui/ui_components/threads.py
new file mode 100644
index 0000000..39f9253
--- /dev/null
+++ b/omc3_gui/ui_components/threads.py
@@ -0,0 +1,71 @@
+"""
+UI: Threads
+-----------
+
+Helper functions for threads.
+"""
+import logging
+from qtpy.QtCore import QThread, Signal
+from collections.abc import Callable
+
+LOGGER = logging.getLogger(__name__)
+
+
+class BackgroundThread(QThread):
+ """ Thread that runs a function in the background,
+ providing a post- and exception-hook.
+
+ Args:
+ function (Callable): The function to run in the background.
+ message (str): The message to display while the function is running.
+ on_end_function (Callable): A function to call when the function is done.
+ on_exception_function (Callable): A function to call when the function throws an exception.
+ """
+
+ on_exception = Signal([str])
+
+ def __init__(self,
+ function: Callable,
+ message: str = None,
+ on_end_function: Callable = None,
+ on_exception_function: Callable = None):
+ QThread.__init__(self)
+ self._function = function
+ self._message = message
+ self._on_end_function = on_end_function
+ self._on_exception_function = on_exception_function
+
+ @property
+ def message(self):
+ return self._message
+
+ def run(self):
+ """
+ Runner for the thread.
+ This function is called automatically when the thread is started.
+ """
+ try:
+ self._function()
+ except Exception as e:
+ LOGGER.exception(str(e))
+ self.on_exception.emit(str(e))
+ return
+
+ LOGGER.info(f"Finished {self.message!s} successfully!")
+
+ def start(self):
+ """
+ Connects the thread to the on_end and on_exception signals and starts the thread.
+ This function is triggered manually to start the thread.
+ """
+ self.finished.connect(self._on_end)
+ self.on_exception.connect(self._on_exception)
+ super(BackgroundThread, self).start()
+
+ def _on_end(self):
+ if self._on_end_function:
+ self._on_end_function()
+
+ def _on_exception(self, exception_message):
+ if self._on_exception_function:
+ self._on_exception_function(exception_message)
diff --git a/omc3_gui/ui_components/widgets.py b/omc3_gui/ui_components/widgets.py
new file mode 100644
index 0000000..d59163b
--- /dev/null
+++ b/omc3_gui/ui_components/widgets.py
@@ -0,0 +1,191 @@
+"""
+UI: Widgets
+-----------
+
+Pre-Defined Widgets go here.
+"""
+from __future__ import annotations
+
+import math
+from qtpy import QtWidgets, QtCore, QtGui
+from omc3_gui.ui_components import colors
+
+
+# Buttons ----------------------------------------------------------------------
+
+class RunButton(QtWidgets.QPushButton):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if not args and "text" not in kwargs:
+ self.setText("Run")
+
+ self.setStyleSheet(
+ f":enabled {{ background-color: {colors.GREEN_DARK}; color: {colors.TEXT_LIGHT}; }}"
+ f":disabled {{ background-color: {colors.GREEN_DARK_GREY}; color: {colors.GREYED_OUT_TEXT_DARK}; }}"
+ )
+
+
+class OpenButton(QtWidgets.QPushButton):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if not args and "text" not in kwargs:
+ self.setText("Open")
+
+ self.setStyleSheet(
+ f":enabled {{ background-color: {colors.GREEN_LIGHT}; color: {colors.TEXT_DARK}; }}"
+ f":disabled {{ background-color: {colors.GREEN_LIGHT_GREY}; color: {colors.GREYED_OUT_TEXT_DARK}; }}"
+ )
+
+
+class RemoveButton(QtWidgets.QPushButton):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if not args and "text" not in kwargs:
+ self.setText("Remove")
+
+ self.setStyleSheet(
+ f":enabled {{ background-color: {colors.RED_DARK}; color: {colors.TEXT_LIGHT}; }}"
+ f":disabled {{ background-color: {colors.RED_GREY}; color: {colors.GREYED_OUT_TEXT_DARK}; }}"
+ )
+
+class ChangeButton(QtWidgets.QPushButton):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.setStyleSheet(
+ f":enabled {{ background-color: {colors.BLUE_DARK}; color: {colors.TEXT_LIGHT}; }}"
+ f":disabled {{ background-color: {colors.BLUE_GREY}; color: {colors.GREYED_OUT_TEXT_DARK}; }}"
+ )
+
+class DefaultButton(QtWidgets.QPushButton):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+# Filler -----------------------------------------------------------------------
+
+class HorizontalSeparator(QtWidgets.QFrame):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setFrameShape(QtWidgets.QFrame.HLine)
+ self.setFrameShadow(QtWidgets.QFrame.Sunken)
+
+
+class VerticalSeparator(QtWidgets.QFrame):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setFrameShape(QtWidgets.QFrame.VLine)
+ self.setFrameShadow(QtWidgets.QFrame.Sunken)
+
+# Animations -------------------------------------------------------------------
+
+class RunningSpinner(QtWidgets.QWidget):
+
+ def __init__(self, center_on_parent=True, *args, **kwargs):
+ self._spinner_color: QtGui.QColor = kwargs.pop("color", QtGui.QColor(colors.BLUE_DARK))
+ self._spinner_roundness: float = kwargs.pop("roundness", 300.0)
+ self._spinner_min_trail_opacity: float = kwargs.pop("min_trail_opacity", 0.05)
+ self._spinner_trail_fade_percentage: float = kwargs.pop("trail_fade_percentage", 100.0)
+ self._spinner_revolutions_per_second: float = kwargs.pop("revolutions_per_second", 1.2)
+ self._spinner_n_lines: int = kwargs.pop("n_lines", 10)
+ self._spinner_length: float = kwargs.pop("length", 5)
+ self._spinner_radius: float = kwargs.pop("radius", 3)
+ self._spinner_rotate: float = kwargs.pop("rotate", False)
+ self._spinner_width: float = self._spinner_radius * 2 * math.pi / self._spinner_n_lines
+ self._spinner_center: bool = center_on_parent
+
+ self._angle: int = 0
+ self._is_spinning: bool = False
+
+ QtWidgets.QWidget.__init__(self, *args, **kwargs)
+
+ self._animation = QtCore.QPropertyAnimation(self, b"angle", self)
+ self._animation.setStartValue(0)
+ self._animation.setEndValue(360)
+ self._animation.setLoopCount(-1)
+ self._animation.setDuration(int(1000 / self._spinner_revolutions_per_second))
+
+ self.updateSize()
+
+ @QtCore.Property(int)
+ def angle(self):
+ return self._angle
+
+ @angle.setter
+ def angle(self, value):
+ self._angle = value % 360
+ self.update()
+
+ def updateSize(self):
+ size = (self._spinner_radius + self._spinner_length) * 2
+ self.setFixedSize(size, size)
+
+ def updatePosition(self):
+ parent = self.parentWidget()
+ if parent and self._spinner_center:
+ self.move(int(parent.width() / 2 - self.width() / 2),
+ int(parent.height() / 2 - self.height() / 2))
+
+ def currentLineColor(self, line_number):
+ color = QtGui.QColor(self._spinner_color)
+ if line_number == 0:
+ return color
+
+ distance_threshold = math.ceil((self._spinner_n_lines - 1) * self._spinner_trail_fade_percentage / 100.0)
+ if line_number > distance_threshold:
+ color.setAlphaF(self._spinner_min_trail_opacity)
+
+ else:
+ alpha_diff = 1 - self._spinner_min_trail_opacity
+ gradient = alpha_diff / distance_threshold
+ alpha = color.alphaF() - gradient * line_number
+ alpha = min(1.0, max(0.0, alpha))
+ color.setAlphaF(alpha)
+ return color
+
+ def paintEvent(self, event):
+ self.updatePosition()
+ painter = QtGui.QPainter(self)
+ painter.fillRect(self.rect(), QtCore.Qt.transparent)
+ painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
+ painter.setPen(QtCore.Qt.NoPen)
+
+ for idx_line in range(self._spinner_n_lines):
+ painter.save()
+ painter.translate(self._spinner_radius + self._spinner_length,
+ self._spinner_radius + self._spinner_length)
+ rotateAngle = -(360.0 * idx_line / self._spinner_n_lines) + (self.angle if self._spinner_rotate else 0)
+ color_index = idx_line + (int(self.angle / 360.0 * self._spinner_n_lines) if not self._spinner_rotate else 0)
+ painter.rotate(rotateAngle)
+ painter.translate(self._spinner_radius, 0)
+ color = self.currentLineColor(color_index % self._spinner_n_lines)
+ painter.setBrush(color)
+
+ painter.drawRoundedRect(
+ QtCore.QRect(int(0), -int(self._spinner_width / 2), int(self._spinner_length), int(self._spinner_width)),
+ int(self._spinner_roundness),
+ int(self._spinner_roundness),
+ QtCore.Qt.RelativeSize
+ )
+ painter.restore()
+
+ def start(self):
+ self.updatePosition()
+ self._is_spinning = True
+ self.show()
+ self._animation.start()
+
+ def stop(self):
+ self._animation.stop()
+ self._is_spinning = False
+ self.hide()
+
+ @property
+ def is_spinning(self):
+ return self._is_spinning
diff --git a/omc3_gui/utils/__init__.py b/omc3_gui/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/omc3_gui/utils/counter.py b/omc3_gui/utils/counter.py
new file mode 100644
index 0000000..ea7b814
--- /dev/null
+++ b/omc3_gui/utils/counter.py
@@ -0,0 +1,72 @@
+"""
+Counter
+-------
+
+Classes to do some automatic counting and filling.
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from qtpy import QtWidgets
+
+
+class Counter:
+ """ Simple class to count up integers. Similar to itertools.count """
+
+ def __init__(self, start=0, end=None):
+ self.count = start
+ self._end = end
+ self._start = start
+
+ def reset(self):
+ self.count = self._start
+
+ def __next__(self):
+ self.count += 1
+
+ if self._end is not None and self.count >= self._end:
+ raise StopIteration
+
+ return self.count
+
+ def __iter__(self):
+ return self
+
+ def next(self):
+ return next(self)
+
+ def current(self):
+ return self.count
+
+
+class HorizontalGridLayoutFiller:
+ """
+ Fills a grid-layout with widgets, without having to give row and col positions,
+ but allows giving a col-span.
+ """
+
+ def __init__(self, layout: QtWidgets.QGridLayout, cols: int, rows: int = None):
+ self._layout = layout
+ self._cols = cols
+ self._rows = rows
+ self._current_col = 0
+ self._current_row = 0
+
+
+ def add(self, widget, col_span=1):
+ self._layout.addWidget(widget, self._current_row, self._current_col, 1, col_span)
+ self._current_col += col_span
+ if self._current_col > self._cols:
+ raise ValueError("Span too large for given columns.")
+
+ if self._current_col == self._cols:
+ self._current_col = 0
+ self._current_row += 1
+ if self._rows is not None and self._current_row >= self._rows:
+ raise ValueError("Grid is already full.")
+
+ addWidget = add
+
+
\ No newline at end of file
diff --git a/omc3_gui/utils/iteration_classes.py b/omc3_gui/utils/iteration_classes.py
new file mode 100644
index 0000000..b7c534e
--- /dev/null
+++ b/omc3_gui/utils/iteration_classes.py
@@ -0,0 +1,59 @@
+"""
+Iterable Classes
+----------------
+
+This module contains classes that are or make other classes iterable over their attributes.
+"""
+from typing import Any
+from collections.abc import Iterator
+
+EXCLUDED_NAME = "EXCLUDED_ATTRIBUTES"
+
+# Metaclasses ------------------------------------------------------------------
+
+class IterableAttributeNames(type):
+ """ Makes the class itself iterable over its attribute names. """
+
+ def __iter__(self) -> Iterator[str]:
+ for attr in self.__dict__.keys():
+ if not attr.startswith("__") and attr != EXCLUDED_NAME and attr not in getattr(self, EXCLUDED_NAME, []):
+ yield attr
+
+
+class IterableAttributeValues(type):
+ """ Makes the class itself iterable over its attribute values. """
+ def __iter__(self) -> Iterator[Any]:
+ for attr, value in self.__dict__.items():
+ if not attr.startswith("__"):
+ yield value
+
+
+class IterableAttributeItems(type):
+ """ Makes the class itself iterable over its attribute name and values. """
+ def __iter__(self) -> Iterator[tuple[str, Any]]:
+ for attr, value in self.__dict__.items():
+ if not attr.startswith("__"):
+ yield attr, value
+
+
+# Iterable Class ---------------------------------------------------------------
+
+
+class IterClass(metaclass=IterableAttributeNames):
+
+ EXCLUDED_ATTRIBUTES = ["keys", "values", "items"]
+
+ @classmethod
+ def keys(cls) -> Iterator[str]:
+ for attr in cls:
+ yield attr
+
+ @classmethod
+ def values(cls) -> Iterator[Any]:
+ for attr in cls:
+ yield getattr(cls, attr)
+
+ @classmethod
+ def items(cls) -> Iterator[tuple[str, Any]]:
+ for attr in cls:
+ yield attr, getattr(cls, attr)
diff --git a/omc3_gui/utils/log_handler.py b/omc3_gui/utils/log_handler.py
new file mode 100644
index 0000000..55ac8f9
--- /dev/null
+++ b/omc3_gui/utils/log_handler.py
@@ -0,0 +1,60 @@
+"""
+Logging
+-------
+
+Helper functions for logging.
+"""
+import sys
+import os
+import logging
+from logging import StreamHandler, FileHandler
+
+
+def set_up_console_logger(logger):
+ logger.setLevel(logging.DEBUG)
+
+ console_handler = StreamHandler(sys.stdout)
+ console_handler.setLevel(logging.INFO)
+
+ logger.addHandler(console_handler)
+ return logger
+
+
+def add_file_handler(log_dir):
+ logger = logging.getLogger("")
+ log_file = os.path.join(log_dir, "log.txt")
+ file_handler = FileHandler(log_file, mode="w")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(get_file_formatter())
+ logger.addHandler(file_handler)
+ logger.info("Set up debug log file in: " +
+ os.path.abspath(log_file))
+
+
+def get_file_formatter():
+ formatter = logging.Formatter(
+ "%(name)s %(asctime)s %(levelname)s %(message)s"
+ )
+ formatter.datefmt = '%Y-%m-%d %H:%M:%S'
+ return formatter
+
+
+def get_console_formatter():
+ formatter = logging.Formatter(
+ "%(asctime)s %(name)20s | %(levelname)s - %(message)s"
+ )
+ formatter.datefmt = '%H:%M:%S'
+ return formatter
+
+
+def init_logging(level: int | None = None):
+ """ Set up a basic logger. """
+ if level is None:
+ level = logging.DEBUG if sys.flags.debug else logging.INFO
+
+ logging.basicConfig(
+ stream=sys.stdout,
+ level=level,
+ format=get_console_formatter()._fmt,
+ datefmt=get_console_formatter().datefmt,
+ )
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8d21ce2
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,124 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.version]
+path = "omc3_gui/__init__.py"
+
+[tool.hatch.build.targets.sdist]
+exclude = [
+ "/.github",
+ "/doc",
+ "/tests",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["omc3_gui"]
+
+[project]
+name = "omc3-gui"
+readme = "README.md"
+description = "QT Graphical User Interface wrapper of the ``omc3`` package"
+authors = [
+ {name = "OMC Team", email = "pylhc@github.com"}, # see zenodo file / commits for details
+]
+license = "MIT"
+dynamic = ["version"]
+requires-python = ">=3.10"
+
+classifiers = [
+ "Intended Audience :: Science/Research",
+ "License :: OSI Approved :: MIT License",
+ "Natural Language :: English",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: Implementation :: CPython",
+ "Topic :: Scientific/Engineering :: Physics",
+ "Topic :: Scientific/Engineering :: Visualization",
+ "Topic :: Scientific/Engineering",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Typing :: Typed",
+]
+
+dependencies = [
+ "omc3 >= 0.24.0",
+ "qtpy >= 2.3.1", # runs with either PySide2/6 or PyQt5/6
+ "pyqtgraph",
+]
+
+
+[project.optional-dependencies]
+cern = [
+ "omc3[cern]",
+ "accwidgets[app_frame,log_console] >= 3.0.11", # app_frame,rbac,log_console,screenshot,graph
+]
+test = [
+ "pytest >= 7.0",
+ "pytest-cov >= 2.9",
+ "pytest-timeout >= 1.4",
+]
+doc = [
+ "sphinx >= 7.0",
+ "sphinx_rtd_theme >= 2.0",
+]
+
+all = [
+ "omc3_gui[cern]",
+ "omc3_gui[test]",
+ "omc3_gui[doc]",
+]
+
+[project.urls]
+homepage = "https://github.com/pylhc/omc3_gui"
+repository = "https://github.com/pylhc/omc3_gui"
+documentation = "https://pylhc.github.io/omc3_gui/"
+changelog = "https://github.com/pylhc/omc3_gui/blob/master/CHANGELOG.md"
+
+# ----- Testing ----- #
+
+[tool.pytest.ini_options]
+markers = [
+ "basic: basic tests run for every commit",
+ "extended: test run on PRs",
+ "cern_network: tests that require access to afs or the technical network",
+]
+# Helpful for pytest-debugging (leave commented out on commit):
+#log_cli = true
+#log_cli_level = "DEBUG"
+
+
+# ----- Dev Tools Configuration ----- #
+
+[tool.ruff]
+exclude = [
+ ".eggs",
+ ".git",
+ ".mypy_cache",
+ ".venv",
+ "_build",
+ "build",
+ "dist",
+]
+
+# Assume Python 3.10+
+target-version = "py310"
+
+line-length = 100
+indent-width = 4
+
+[tool.ruff.lint]
+# Allow unused variables when underscore-prefixed.
+dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
+ignore = [
+ "FBT001", # boolean-type-hint-positional-argument
+ "FBT002", # boolean-default-value-positional-argument
+ "PT019", # pytest-fixture-param-without-value (but suggested solution fails)
+]
+
+# Allow fix for all enabled rules (when `--fix`) is provided.
+fixable = ["ALL"]
+unfixable = []
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 23c4961..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,9 +0,0 @@
-[tool:pytest]
-testpaths = tests
-markers=
- basic: basic tests run for every commit
- extended: test run on PRs
- cern_network: tests that require access to afs or the technical network
-; Helpful for pytest-debugging (leave commented out on commit):
-;log_cli=true
-;log_level=DEBUG
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 4287c90..0000000
--- a/setup.py
+++ /dev/null
@@ -1,74 +0,0 @@
-import pathlib
-
-import setuptools
-
-# The directory containing this file
-TOPLEVEL_DIR = pathlib.Path(__file__).parent.absolute()
-ABOUT_FILE = TOPLEVEL_DIR / "omc3_gui" / "__init__.py"
-README = TOPLEVEL_DIR / "README.md"
-
-# Information on the omc3_gui package
-ABOUT_OMC3_GUI: dict = {}
-with ABOUT_FILE.open("r") as f:
- exec(f.read(), ABOUT_OMC3_GUI)
-
-with README.open("r") as docs:
- long_description = docs.read()
-
-# Dependencies for the package itself
-DEPENDENCIES = [
- f"omc3[optional]>={ABOUT_OMC3_GUI['__omc3_version__']}",
- "PyQt5>=5.15.7", # Keep PyQT5 for now, until acc-py updates
-]
-
-# Extra dependencies
-EXTRA_DEPENDENCIES = {
- "cern": [
- f"omc3[cern]>={ABOUT_OMC3_GUI['__omc3_version__']}",
- # "accwidgets[app_frame,rbac,log_console,screenshot,graph]",
- ],
- "test": [
- "pytest>=5.2",
- "pytest-cov>=2.7",
- "pytest-timeout>=1.4",
- ],
- "doc": [
- "sphinx",
- "sphinx_rtd_theme",
- ],
-}
-EXTRA_DEPENDENCIES.update(
- {"all": [elem for list_ in EXTRA_DEPENDENCIES.values() for elem in list_]}
-)
-
-
-setuptools.setup(
- name=ABOUT_OMC3_GUI["__title__"],
- version=ABOUT_OMC3_GUI["__version__"],
- description=ABOUT_OMC3_GUI["__description__"],
- long_description=long_description,
- long_description_content_type="text/markdown",
- author=ABOUT_OMC3_GUI["__author__"],
- author_email=ABOUT_OMC3_GUI["__author_email__"],
- url=ABOUT_OMC3_GUI["__url__"],
- packages=setuptools.find_packages(exclude=["tests*", "doc"]),
- include_package_data=True,
- python_requires=">=3.7",
- license=ABOUT_OMC3_GUI["__license__"],
- classifiers=[
- "Intended Audience :: Science/Research",
- f"License :: OSI Approved :: {ABOUT_OMC3_GUI['__license__']}",
- "Natural Language :: English",
- "Programming Language :: Python",
- "Programming Language :: Python :: 3 :: Only",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
- "Programming Language :: Python :: 3.10",
- "Programming Language :: Python :: 3.11",
- "Topic :: Scientific/Engineering :: Physics",
- "Topic :: Scientific/Engineering :: Visualization",
- ],
- install_requires=DEPENDENCIES,
- tests_require=EXTRA_DEPENDENCIES["test"],
- extras_require=EXTRA_DEPENDENCIES,
-)
diff --git a/tests/test_dummy.py b/tests/test_dummy.py
new file mode 100644
index 0000000..1e3bfed
--- /dev/null
+++ b/tests/test_dummy.py
@@ -0,0 +1,13 @@
+"""
+Dummy Tests
+-----------
+
+We do not have any tests so far for `omc3_gui` and we are not planning
+to intensively testing the interface via automated tests.
+
+But feel free to add unit tests as you please!
+"""
+
+def test_dummy():
+ for i in range(50):
+ print("There are no Tests in OMC3-GUI")