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")