diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..5677300 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,29 @@ +# https://coverage.readthedocs.io/en/latest/config.html +# .coveragerc to control coverage.py +[run] +branch = True +omit = + cartoreader_lite/output/* + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code: + def __repr__ + if self\.debug + + # Don't complain if tests don't hit defensive assertion code: + #raise AssertionError + #raise NotImplementedError + + # Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: + +ignore_errors = True + +[html] +directory = coverage_html diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..1cf791a --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,59 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: CI Tests + +on: + push: + branches: [ public ] + pull_request: + branches: [ public ] + +jobs: + + test_lib_pip_ubuntu: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [3.9] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install with pip + run: | + python -m pip install --upgrade pip + pip install -e .[tests] + python tests/generate_test_data.py + - name: Test with pytest + run: | + python -m pytest --cov-config=.coveragerc --cov=cartoreader_lite tests/ + bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} + + test_lib_pip_windows: + + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: [3.9] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install with pip + run: | + python -m pip install --upgrade pip + pip install -e .[tests] + python tests/generate_test_data.py + - name: Test with pytest + run: | + python -m pytest --cov-config=.coveragerc --cov=cartoreader_lite tests/ diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..f0e9f5d --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,36 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build -s + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb342fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*__pycache__* +*.pyc +*.egg-info* +docs/_* +cartoreader_lite/output/* +.pytest_cache* +.vscode* +coverage_html* +dist* +.coverage diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..21a2127 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# CARTOreader - lite + +[![CI Tests](https://github.com/thomgrand/cartoreader-lite/actions/workflows/python-package.yml/badge.svg)](https://github.com/thomgrand/cartoreader-lite/actions/workflows/python-package.yml) +[![codecov](https://codecov.io/gh/thomgrand/cartoreader-lite/branch/main/graph/badge.svg?token=4A7DD8DWXW)](https://codecov.io/gh/thomgrand/cartoreader-lite) + +This repository is an inofficial reader to easily process exported [CARTO3 data](https://www.jnjmedicaldevices.com/en-US/product/carto-3-system) in Python. +It does not provide the more extensive capabilities to analyze the signals, such as [OpenEP](https://openep.io/), but is rather meant as a simple reader to import CARTO data. +The loaded time data is imported in [pandas](https://pandas.pydata.org) and the meshes in [VTK](https://vtk.org/) provided through [PyVista](https://www.pyvista.org), allowing for easy access, export and interoperatibility with existing software. + +# Installation + +To install `cartoreader_lite`, you have to clone the repository and install the libary using `pip`. + +```bash +git clone https://github.com/thomgrand/cartoreader-lite +cd cartoreader-lite +pip install -e . +``` + +# Usage + +To test the library, you first need to get CARTO3 data. +None is provided with this repository, but you can download the testing data provided by [OpenEP](https://openep) to quickly try out the library (make sure the libary was installed first): + +```bash +python tests/generate_test_data.py +``` + +```python +from cartoreader_lite import CartoStudy +study_dir = "openep-testingdata/Carto/Export_Study-1-11_25_2021-15-01-32" +study_name = "Study 1 11_25_2021 15-01-32.xml" +study = CartoStudy(study_dir, study_name, + carto_map_kwargs={"discard_invalid_points": False} #All points of the example are outside the WOI, which would be by default discarded + ) +ablation_points = pv.PolyData(np.stack(study.ablation_data.session_avg_data["pos"].to_numpy())) +ablation_points.point_data["RFIndex"] = study.ablation_data.session_avg_data["RFIndex"] +plotter = pv.Plotter() +plotter.add_mesh(ablation_points, cmap="jet") +plotter.add_mesh(study.maps[2].mesh) +plotter.show() +``` + +You should see the recorded map of the [OpenEP](https://openep.io) example, together with its recorded points like below. + +![openep-example](docs/figures/openep-example.png) + +# Documentation + +[https://cartoreader-lite.readthedocs.io/en/latest](https://cartoreader-lite.readthedocs.io/en/latest) + +# Citation + +If you use the library in your scientific projects, please cite the associated Zenodo archive: https://www.zenodo.org. + +```bibtex +%TODO: FIM +@software{thomas_grandits_2021_5594452, + author = {Thomas Grandits}, + title = {A Fast Iterative Method Python package}, + month = oct, + year = 2021, + publisher = {Zenodo}, + version = {1.1}, + doi = {10.5281/zenodo.5594452}, + url = {https://doi.org/10.5281/zenodo.5594452} +} +``` diff --git a/cartoreader_lite/__init__.py b/cartoreader_lite/__init__.py new file mode 100644 index 0000000..06940a7 --- /dev/null +++ b/cartoreader_lite/__init__.py @@ -0,0 +1,4 @@ +from .high_level.study import CartoStudy, CartoAuxMesh, CartoMap, CartoPointDetailData, AblationSites + +__version__ = "1.0.0" +__author__ = "Thomas Grandits" diff --git a/cartoreader_lite/high_level/__init__.py b/cartoreader_lite/high_level/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cartoreader_lite/high_level/study.py b/cartoreader_lite/high_level/study.py new file mode 100644 index 0000000..e07672d --- /dev/null +++ b/cartoreader_lite/high_level/study.py @@ -0,0 +1,353 @@ +from __future__ import annotations #recursive type hinting +import logging as log +import pickle +from typing import Dict, List, Tuple, IO, Union + +from cartoreader_lite.low_level.utils import convert_fname_to_handle, simplify_dataframe_dtypes, unify_time_data, xyz_to_pos_vec +from ..low_level.study import CartoLLStudy, CartoLLMap, CartoAuxMesh +import pandas as pd +import numpy as np +import re +import gzip +from os import PathLike +import os +import pyvista as pv +from ..postprocessing.geometry import project_points + +dtype_simplify_dict = {"InAccurateSeverity": np.int8, + "ChannelID": np.int32, + "MetalSeverity": np.int8, + "NeedZeroing": np.int8, + "ChannelID": np.int16, + "TagIndexStatus": np.int8, + "Valid": np.int8} + +#np.iinfo(np.int8) +#np.issubdtype(np.int8, np.integer) + +class AblationSites(): + """High level class for easily referencing ablation sites read from the visitag files. + Automatically generated from :class:`.CartoStudy`. + + Parameters + ---------- + visitag_data : Dict[str, pd.DataFrame] + Visitag data read by the low level function :ref:`.low_level.visitags.read_visitag_dir`. + resample_unified_time : bool, optional + If true, the visitag file with different time intervals and frequencies will be resampled into a single pandas Dataframe with unified time steps. + By default True + position_to_vec : bool, optional + If true, the entries X, Y, Z of the dataframes will be unified in the resulting tables to a single 3D vector pos. + By default, True + """ + + session_avg_data : pd.DataFrame #: Contains average data of each ablation session, such as :term:`RFIndex`, average force and position + session_time_data : List[Tuple[int, pd.DataFrame]] #: List of time data associated with each Ablation session. Each item contains the session ID + the ablation data over the course of the session + session_rf_data : List[Tuple[int, pd.DataFrame]] = None #: Ablation data (impedance, power, temperature, ...) provided by the low level classes. Only present if `resample_unified_time` was False + session_force_data : List[Tuple[int, pd.DataFrame]] = None #: Force data provided by the low level classes. Only present if `resample_unified_time` was False + + def __init__(self, visitag_data : Dict[str, pd.DataFrame], resample_unified_time=True, + position_to_vec=True) -> None: + + if resample_unified_time: + #Contact force data uses a different time label, but the timings look the same as the other data + contact_force_data = visitag_data["ContactForceData"].rename(columns={"Time": "TimeStamp"}) + time_data = unify_time_data([visitag_data["RawPositions"], visitag_data["AblationData"], contact_force_data], time_k="TimeStamp", + time_interval=100, kind="quadratic") + self.session_time_data = list(simplify_dataframe_dtypes(time_data, dtype_simplify_dict).groupby("Session")) + + else: + self.session_time_data = list(simplify_dataframe_dtypes(visitag_data["RawPositions"], dtype_simplify_dict).groupby("Session")) + self.session_rf_data = list(simplify_dataframe_dtypes(visitag_data["AblationData"], dtype_simplify_dict).groupby("Session")) + self.session_force_data = list(simplify_dataframe_dtypes(visitag_data["ContactForceData"], dtype_simplify_dict).groupby("Session")) + + self.session_avg_data = simplify_dataframe_dtypes(visitag_data["Sites"], dtype_simplify_dict) + + if position_to_vec: + self.session_avg_data = xyz_to_pos_vec(self.session_avg_data) + self.session_time_data = [(session_id, xyz_to_pos_vec(time_data)) for session_id, time_data in self.session_time_data] + +ecg_gain_re = re.compile("\s*Raw ECG to MV \(gain\)\s*\=\s*(-?\d+\.?\d*)\s*") +ecg_labels = ["I", "II", "III", "aVR", "aVL", "aVF"] + [f"V{i+1}" for i in range(6)] +ecg_labels_re = [re.compile(l + "\(\d+\)") for l in ecg_labels] +egm_label_re = re.compile("(.+)\((\d+)\)") + +class CartoPointDetailData(): + """Detailed data associated to a CARTO3 point. + + Parameters + ---------- + main_data : pd.Series + Metadata of the point, such as ID and mean position + raw_data : Tuple[Dict[str, Dict], Dict[str, Dict]] + Raw data provided by :class:`cartoreader_lite.low_level.study.CartoLLMap`, including details such as :term:`EGMs`. + remove_egm_header_numbers : bool, optional + If true, the :term:`EGM` header numbers will be discarded (e.g. I(110) -> I). + By default True + """ + + id : int #: Carto generated ID of the point + pos : np.ndarray #: Position of the point in 3D + cath_orientation : np.ndarray #: 3D-Orientation of the catheter while recording the point + cath_id : int #: ID of the Catheter + woi : np.ndarray #: :term:`WOI`. Only recordings located in this window are deemed valid. + start_time : int #: System start time of the recording + ref_annotation : int #: Reference annotation to synchronize all recordings + map_annotation : int #: Annotation of the activation of this point, also often called :term:`LAT` + uni_volt : float #: Unipolar voltage magnitdue + bip_volt : float #: Bipolar voltage magnitdue + connectors : List[str] #: List of the recorded connector names + ecg_gain : float #: Gain of the recorded :term:`ECGs` + ecg_metadata : object #: Additional provided metadata regarding the :term:`ECGs` or :term:`EGMs` + surface_ecg : pd.DataFrame #: Recorded surface :term:`ECG`. Has type np.int16 and needs to be multiplied by :attr:`~CartoPointDetailData.ecg_gain` to get the ECG in Volts + egm : pd.DataFrame #: Recorded electrograms at the point through the connectors. Naming and columns differ for each setup + + def __init__(self, main_data : pd.Series, raw_data : Tuple[Dict[str, Dict], Dict[str, Dict]], + remove_egm_header_numbers=True) -> None: + + #Read the easy metadata first + self.id = main_data["Id"] + self.pos = main_data["Position3D"] + self.cath_orientation = main_data["CathOrientation"] + self.cath_id = main_data["Cath_Id"] + + #Metadata is the first tuple object, actual data in the second tuple + #WOI + self.woi = np.array([float(raw_data[0]["WOI"]["From"]), float(raw_data[0]["WOI"]["To"])]) + self.start_time = int(raw_data[0]["Annotations"]["StartTime"]) + self.ref_annotation = int(raw_data[0]["Annotations"]["Reference_Annotation"]) + self.map_annotation = int(raw_data[0]["Annotations"]["Map_Annotation"]) + + #Voltages + self.uni_volt = float(raw_data[0]["Voltages"]["Unipolar"]) + self.bip_volt = float(raw_data[0]["Voltages"]["Bipolar"]) + + #Connectors + self.connectors = list(raw_data[1]["connector_data"].keys()) + + #Contact Forces + if "contact_force_data" in raw_data[1]: + self.contact_force_metadata, self.contact_force_data = raw_data[1]["contact_force_data"] + self.contact_force_data = simplify_dataframe_dtypes(self.contact_force_data, dtype_simplify_dict) + + #TODO: Not yet sure what the OnAnnotation file does + + #ECG + ecg_gain_str = raw_data[1]["ecg"][0][1] #Pass ECG metadata + self.ecg_gain = float(ecg_gain_re.match(ecg_gain_str).group(1)) + self.ecg_metadata = raw_data[1]["ecg"][0][2] + #self.ecg_export_version_number = data[1]["ecg"][0][0]"" + ecg_data = raw_data[1]["ecg"][1] + assert np.iinfo(np.int16).min <= ecg_data.min().min() and np.iinfo(np.int16).max >= ecg_data.max().max(), "ECG can not be simplified to np.in16" + ecg_data = ecg_data.astype(np.int16) + + #Find the surface ECGs in the data and split the data into surface and other EGMs + surface_ecg_inds = [[j for j, c in enumerate(ecg_data.columns) if l.match(c) is not None] for l in ecg_labels_re] + assert all([len(l) == 1 for l in surface_ecg_inds]), "12-lead ECG not present in the point data" + surface_ecg_inds = [l[0] for l in surface_ecg_inds] + self.surface_ecg = ecg_data.iloc[:, surface_ecg_inds] + self.egm = ecg_data.drop(ecg_data.columns[surface_ecg_inds], axis=1) + + #Remove the numbers after electrode description numbering + if remove_egm_header_numbers: + self.surface_ecg.columns = np.array(ecg_labels) + self.egm.columns = np.array([egm_label_re.match(col).group(1) for col in self.egm.columns]) + + @property + def main_point_pd_row(self): + pd_attrs = ["id", "pos", "cath_orientation", "cath_id", "woi", "start_time", "ref_annotation", + "map_annotation", "uni_volt", "bip_volt", "connectors", + ] #"surface_ecg", "egm"] + + #This represents a single row of the returning pandas DataFrame + return {**{k: getattr(self, k) for k in pd_attrs}, **{"detail": self}} + +class CartoMap(): + + """High level container for carto maps with the associated point data and mesh. + + Parameters + ---------- + ll_map : CartoLLMap + The low level study to load and simplify + """ + + points : pd.DataFrame #: Recorded point data associated with this map. The column `detail` returns the associated :class:`CartoPointDetailData` where the ECGs and EGMs can be found. + mesh : pv.UnstructuredGrid #: Mesh associated with the map + + def _simplify(self, ll_map : CartoLLMap, discard_invalid_points=True, remove_egm_header_numbers=True, + proj_points=True): + """Function to simplify the data given by the lower level ll_map. + + Parameters + ---------- + ll_map : CartoLLMap + Data of the low level map to be simplified into this high level map + discard_invalid_points : bool, optional + If true, points with :term:`LAT` outside the :term:`WOI` will be automatically discarded. + By default True + """ + self.name = ll_map.name + + #Point data + if len(ll_map.points_main_data) > 0: + self._points_raw = np.array([CartoPointDetailData(main_data, raw_data, remove_egm_header_numbers) for (row_i, main_data), raw_data in zip(ll_map.points_main_data.iterrows(), ll_map.point_raw_data)]) + self.points = pd.DataFrame([p.main_point_pd_row for p in self._points_raw]) + + if discard_invalid_points: + corrected_woi = np.stack((self.points.woi + self.points.ref_annotation).to_numpy()) + lat = self.points.map_annotation + valid_mask = (lat >= corrected_woi[..., 0]) & (lat <= corrected_woi[..., 1]) + log.info(f"Discarding {np.sum(~valid_mask)}/{valid_mask.size} invalid points in map {self.name} (LAT outside WOI)") + self._points_raw = self._points_raw[valid_mask] + self.points = self.points[valid_mask].reset_index(drop=True) + else: + self.points = self._points_raw = [] + + + #Mesh data + self.mesh = ll_map.mesh + self.mesh_affine = np.fromstring(ll_map.mesh_metadata["Matrix"], sep=" ").reshape([4, 4]) + assert self.mesh.n_points == int(ll_map.mesh_metadata["NumVertex"]), "Metadata and mesh mismatch" + assert self.mesh.n_cells == int(ll_map.mesh_metadata["NumTriangle"]), "Metadata and mesh mismatch" + + #Project points onto the geometry + if proj_points: + if len(self.points) > 0: + proj_points, proj_dist = project_points(self.mesh, np.stack(self.points["pos"].to_numpy()))[:2] + self.points["proj_pos"] = proj_points.tolist() + self.points["proj_dist"] = proj_dist + + elif type(self.points) == pd.DataFrame: #Add empty columns just to be consistent + self.points["proj_pos"] = [] + self.points["proj_dist"] = [] + + @property + def nr_points(self): + return len(self.points) + + def __init__(self, ll_map : CartoLLMap, *simplify_args, **simplify_kwargs) -> None: + #self.ll_map = ll_map + self._simplify(ll_map, *simplify_args, **simplify_kwargs) + #del self.ll_map + +class CartoStudy(): + """High level class to easily read Carto3 archives, directories or buffered studies. + + Parameters + ---------- + arg1 : str + A path to either + + * A directory containing the study + * A zip file with the study inside + * A path to a previously saved study + * A :class:`cartoreader_lite.low_level.study.CartoLLStudy` instance + arg2 : str, optional + The name of the study to load, contained inside the directory or zip file. + Has to be None when loading a pickled study. + Will default to either the zip name, bottom most directory name or None, depending on your choice of arg1. + ablation_sites_kwargs : Dict + Optional keyword arguments to be passed to :class:`AblationSites` + carto_map_kwargs : Dict + Optional keyword arguments to be passed to :class:`CartoMap` + """ + + name : str #: The name of the study + ablation_data : AblationSites #: Detailed information about the ablation sites and their readings over time + maps : List[CartoMap] #: All recorded maps associated with this study + aux_meshes : List[CartoAuxMesh] #: Auxiliary meshes generated by the CARTO system, not associated with any specific map, e.g. CT segmentations from `CARTOSeg`_. + aux_mesh_reg_mat : np.ndarray #: 4x4 affine registration matrix to map the auxiliary meshes. + + + def _simplify(self, ll_study : CartoLLStudy, ablation_sites_kwargs : Dict, carto_map_kwargs : Dict): + """Function to simplify the data given by the lower level ll_study. + + Parameters + ---------- + ll_study : CartoLLStudy + Data of the low level study to be simplified into this high level study + ablation_sites_kwargs : Dict + Optional keyword arguments to be passed to :class:`AblationSites` + carto_map_kwargs : Dict + Optional keyword arguments to be passed to :class:`CartoMap` + """ + self.ablation_data = AblationSites(ll_study.visitag_data, **ablation_sites_kwargs) + self.maps = [CartoMap(m, **carto_map_kwargs) for m in ll_study.maps] + self.name = ll_study.name + self.aux_meshes = ll_study.aux_meshes + self.aux_mesh_reg_mat = ll_study.aux_mesh_reg_mat + + def __init__(self, arg1, arg2 = None, ablation_sites_kwargs=None, carto_map_kwargs=None) -> None: + + if ablation_sites_kwargs is None: + ablation_sites_kwargs = {} + if carto_map_kwargs is None: + carto_map_kwargs = {} + + if issubclass(type(arg1), str) and os.path.isfile(arg1) and arg1.endswith(".pkl.gz") and arg2 is None: + loaded_study = CartoStudy.load_pickled_study(arg1) + + #https://stackoverflow.com/questions/2709800/how-to-pickle-yourself + self.__dict__.update(loaded_study.__dict__) + elif issubclass(type(arg1), CartoLLStudy) and arg2 is None: + ll_study = arg1 + self._simplify(ll_study, ablation_sites_kwargs, carto_map_kwargs) + + else: + ll_study = CartoLLStudy(arg1, arg2) + self._simplify(ll_study, ablation_sites_kwargs, carto_map_kwargs) + + @property + def nr_maps(self): + return len(self.maps) + + def save(self, file : Union[IO, PathLike] = None): + """Backup the current study into a pickled and compressed file or buffer. + + Parameters + ---------- + file : Union[IO, PathLike], optional + Target to write the study to. Can be on of the following: + + * Name of the file which the study will be written to + * A file, or buffer handle to write to + + Will default to the study name with the ending `.pkl.gz` + """ + if file is None: + file = self.name + ".pkl.gz" + + assert not issubclass(type(file), str) or file.endswith("pkl.gz"), "Only allowed file type is currently pkl.gz" + file, is_fname = convert_fname_to_handle(file, "wb") + + with gzip.GzipFile(fileobj=file, mode="wb", compresslevel=2) as g_h: + pickle.dump(self, g_h) + + if is_fname: + file.close() + + @staticmethod + def load_pickled_study(file : Union[IO, PathLike]) -> CartoStudy: + """Will load a pickled study either from + + Parameters + ---------- + file : Union[IO, PathLike] + [description] + + Returns + ------- + CartoStudy + [description] + """ + assert not issubclass(type(file), str) or file.endswith("pkl.gz"), "Only allowed file type is currently pkl.gz" + file, is_fname = convert_fname_to_handle(file, "rb") + with gzip.GzipFile(fileobj=file, mode="rb") as f: + data = pickle.load(f) + + if is_fname: + file.close() + + assert issubclass(type(data), CartoStudy), "Unexpected unpickling result" + return data diff --git a/cartoreader_lite/low_level/__init__.py b/cartoreader_lite/low_level/__init__.py new file mode 100644 index 0000000..dd81cf6 --- /dev/null +++ b/cartoreader_lite/low_level/__init__.py @@ -0,0 +1,3 @@ +"""This module implements the routines to read CARTO3 files on a low level. +Most classes here read the XML and associated data files into a pandas DataFrame. +""" \ No newline at end of file diff --git a/cartoreader_lite/low_level/read_mesh.py b/cartoreader_lite/low_level/read_mesh.py new file mode 100644 index 0000000..7f6df1c --- /dev/null +++ b/cartoreader_lite/low_level/read_mesh.py @@ -0,0 +1,147 @@ +"""This file provides routines to read .mesh files from CARTO3 +""" + +from io import StringIO +import os +from typing import Iterable, List, Tuple, Union +import pandas as pd +from pandas.errors import EmptyDataError +import numpy as np +import re +import pyvista as pv +import vtk +import logging as log + +section_re_expr_str = "\[(\S+)\]" +section_re_expr = re.compile(section_re_expr_str) + +attribute_re_expr_str = "(\S+)\s+=\s*(.*)" +attribute_re_expr = re.compile(attribute_re_expr_str) + +blank_line_expr = re.compile("\s+") + +def read_section(lines : Iterable[str]) -> pd.DataFrame: + """Reads a single section of the .mesh files + + Parameters + ---------- + lines : Iterable[str] + The lines of the section + + Returns + ------- + pd.DataFrame + The section data in a dataframe + """ + vertices_str = "\n".join(lines) + verts_io = StringIO(vertices_str) + df = pd.read_csv(verts_io, comment=";", header=None, sep="\s+") + valid_line_i = [i for i, l in enumerate(lines) if not l.strip().startswith(";")][0] #Find first non-comment line + assert valid_line_i > 0, "No header row found" + names = [name for name in re.split("\s+", lines[valid_line_i-1]) if name not in ["", ";"]] + return df, names + +def read_vertices(data : pd.DataFrame): + points = np.stack([data[n] for n in ["X", "Y", "Z"]], axis=-1) + normals = np.stack([data[f"Normal{n:s}"] for n in ["X", "Y", "Z"]], axis=-1) + group_id = np.array(data["GroupID"]) + + return points, normals, group_id + +def read_tris(data : pd.DataFrame) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Converts the dataframe of the TrianglesSection into multiple numpy array + + Parameters + ---------- + data : pd.DataFrame + The data containing the TrianglesSection + + Returns + ------- + Tuple[np.ndarray, np.ndarray, np.ndarray] + Returns the triplet (faces [Mx3], normals [Mx3], group-ID [M]) + """ + tris = np.stack([data[f"Vertex{i:d}"] for i in range(3)], axis=-1) + normals = np.stack([data[f"Normal{n:s}"] for n in ["X", "Y", "Z"]], axis=-1) + group_id = np.array(data["GroupID"]) + + return tris, normals, group_id + + +def read_mesh_file(fname : str) -> Tuple[Union[pv.UnstructuredGrid, pv.PolyData], dict]: + """Reads a single mesh file in CARTO3 mesh format and returns it as a pyvista object + + Parameters + ---------- + fname : str + Filename of the mesh file + + Returns + ------- + Tuple[Union[pv.UnstructuredGrid, pv.PolyData], dict] + Returns the constructred mesh from the data, along with the header data as a dictionary + """ + with open(fname, "r", errors="replace") as f: + lines = f.readlines() + + section_inds = [(i, section_re_expr.match(lines[i])) for i in np.arange(len(lines)) if section_re_expr.match(lines[i]) is not None] + + assert len(section_inds) > 1, "Not enough section headers found in the file" + assert section_inds[0][1].group(1) == "GeneralAttributes", "Expected attributes first" + + section_inds.append((len(lines), None)) #To ease iteration + + header = {} + for line in lines[section_inds[0][0]+1:section_inds[1][0]]: + if not line.startswith(";") and blank_line_expr.match(line) is None: + match = attribute_re_expr.match(line) + assert match is not None, f"Could not parse header line '{line:s}'" + header[match.group(1)] = match.group(2) + + points = vert_normals = vert_groups = None + tris = tri_normals = tri_groups = None + for i in range(1, len(section_inds) - 1): + sec = section_inds[i] + next_sec = section_inds[i+1] + section_name = sec[1].group(1) + + section_lines = lines[sec[0]:next_sec[0]] + + try: + df, header_names = read_section(section_lines[1:]) + assert np.all(df[0] == np.arange(len(df))), f"Non continuous index sequence in section {section_name:s}" + assert np.all(df[1] == "="), f"Equality sign expected in section {section_name:s}" + + df = df[range(2, len(df.columns))] #Actual dataframe + if len(df.columns) > len(header_names): + log.warn(f"Error while reading mesh {fname}, section {section_name}: Mismatch between number of given column headers and data columns." + f"Expected {len(df.columns)}, but got {len(header_names)}. Later headers will be 'N/A'") + header_names += ["N/A"] * (len(df.columns) - len(header_names)) + if section_name != "VerticesAttributesSection": + df.columns = header_names + + if section_name == "VerticesSection": + points, vert_normals, vert_groups = read_vertices(df) + elif section_name == "TrianglesSection": + tris, tri_normals, tri_groups = read_tris(df) + elif section_name == "VerticesAttributesSection": + log.info("Skipping VerticesAttributesSection (not yet implemented)") + except EmptyDataError as err: + print(f"Skipping section {section_name}. Section is empty.") + print("Original error: ", err) + + + assert points is not None, "No vertices found in the file" + + if tris is None: + mesh = pv.PolyData(points) + + else: + mesh = pv.UnstructuredGrid({vtk.VTK_TRIANGLE: tris}, points) + mesh.cell_data["normals"] = tri_normals + mesh.cell_data["group_id"] = tri_groups + + mesh.point_data["normals"] = vert_normals + mesh.point_data["group_id"] = vert_groups + + return mesh, header diff --git a/cartoreader_lite/low_level/study.py b/cartoreader_lite/low_level/study.py new file mode 100644 index 0000000..519570e --- /dev/null +++ b/cartoreader_lite/low_level/study.py @@ -0,0 +1,254 @@ +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, wait +import xml.etree.ElementTree as ET +from xml.etree.ElementTree import Element +import os +import pandas as pd + +from cartoreader_lite.low_level.read_mesh import read_mesh_file +from cartoreader_lite.low_level.visitags import read_visitag_dir +from .utils import camel_to_snake_case, read_point_data, xml_elem_to_dict, xml_to_dataframe +import numpy as np +from itertools import repeat +import tempfile +import zipfile +import pyvista as pv +from typing import Dict + +_parallelize_pool = ProcessPoolExecutor + +class CartoLLMap: + + """Low level CARTO3 map container for all point associated data. + + Parameters + ---------- + xml_h : Element + XML element holding the map data + path_prefix : str + Prefix of the path to load from + """ + + def import_raw_points(self, path_prefix : str): + """Imports all points and its detailed data of the current map + + Parameters + ---------- + path_prefix : str + Prefix of the path to load from + """ + + #read_f = lambda point_id: read_point_data(self.name, point_id, path_prefix) + with _parallelize_pool() as pool: + self.point_raw_data = list(pool.map(read_point_data, repeat(self.name), + self.points_main_data["Id"], repeat(path_prefix), + chunksize=5)) + pass + + def __init__(self, xml_h : Element, path_prefix : str) -> None: + + for k, v in xml_h.items(): + setattr(self, camel_to_snake_case(k), v) + + for elem in xml_h: + if elem.tag == "CartoPoints": + self.points_main_data = xml_to_dataframe(elem) + if "Position3D" in self.points_main_data: + self.points_main_data["Position3D"] = self.points_main_data["Position3D"].apply(lambda x: np.fromstring(x, sep=" ")) + if "CathOrientation" in self.points_main_data: + self.points_main_data["CathOrientation"] = self.points_main_data["CathOrientation"].apply(lambda x: np.fromstring(x, sep=" ")) + + self.virtual_points = [[xml_elem_to_dict(sub_elem) for sub_elem in point if sub_elem.tag == "VirtualPoint"] for point in elem] + self.point_tags = [[{**sub_elem.attrib, **{"value": int(sub_elem.text)}} for sub_elem in point if sub_elem.tag == "Tags"] for point in elem] + elif elem.tag == "TagsTable": + self.tags = xml_to_dataframe(elem) + elif elem.tag == "RefAnnotationConfig": + self.ref_annotation_config = xml_elem_to_dict(elem) + elif elem.tag == "ColoringRangeTable": + self.coloring_range_table = xml_to_dataframe(elem) + + #Import mesh + if "FileNames" in xml_h.keys(): + self.mesh, self.mesh_metadata = read_mesh_file(os.path.join(path_prefix, self.file_names)) + + if "Id" in self.points_main_data: + self.import_raw_points(path_prefix) + +class CartoAuxMesh: + """Class that holds auxiliary meshes of the CARTO system, e.g. generated by `CartoSeg`_. + + Parameters + ---------- + xml_h : Element + XML entry point from where to read the auxiliary meshes + path_prefix : str + Path prefix pointing to the directory to read from + load : bool, optional + If true, the meshes will be read and buffered immediately. + If false, only the names will be loaded and :meth:`load_mesh` will need to be called later. + By default True + """ + + mesh_path : str #: Full path to the mesh file + name : str #: Name of the mesh + mesh_data : pv.UnstructuredGrid #: The loaded mesh + metadata : Dict[str, str] #: Metadata associated with the mesh, given by the XML tags + affine : np.ndarray #: 4x4 affine transformation matrix given by CARTO + + + def __init__(self, xml_h : Element, path_prefix : str, load=True) -> None: + for k, v in xml_h.items(): + setattr(self, camel_to_snake_case(k), v) + + self.mesh_path = os.path.join(path_prefix, xml_h.attrib["FileName"]) + self.name = os.path.splitext(self.file_name)[0] + + if load: + self.load_mesh() + + def load_mesh(self): + """Loads the mesh with the given name into the memory + """ + self.mesh_data, self.metadata = read_mesh_file(self.mesh_path) + if "Matrix" in self.metadata: + self.affine = np.fromstring(self.metadata["Matrix"], sep=" ").reshape([4, 4]) + +class CartoLLStudy: + """Low level CARTO study class that reads all information found in the CARTO3 study and saves it. + + Parameters + ---------- + arg1 : str + A path to either + + * A directory containing the study + * A zip file with the study inside + arg2 : str, optional + The name of the study to load, contained inside the directory or zip file. + Will default to either the zip name or bottom most directory name. + """ + + aux_mesh_reg_mat : np.ndarray = None + + def _parse_meshes(self, xml_h : Element, path_prefix : str): + """Parses and loads the axuiliary meshes given in the study + + Parameters + ---------- + xml_h : Element + The XML element containing the mesh metadata + path_prefix : str + Path prefix pointing to the directory to read from + """ + self.aux_meshes = [] + + with ProcessPoolExecutor() as pool: + for elem in xml_h: + if elem.tag == "RegistrationMatrix": + self.aux_mesh_reg_mat = np.fromstring(elem.text, sep=" ").reshape([4, 4]) #Affine matrix + elif elem.tag == "Mesh": + self.aux_meshes.append(pool.submit(CartoAuxMesh, elem, path_prefix)) #CartoMesh(elem, path_prefix)) + elif elem.tag == "RegistrationData": + self.aux_mesh_reg_data = xml_elem_to_dict(elem) + + self.aux_meshes = [m.result() for m in self.aux_meshes] + + + def _parse_maps(self, maps : Element, path_prefix : str): + """Parses and loads the maps given in the study + + Parameters + ---------- + maps : Element + The XML element containing the map data + path_prefix : str + Path prefix pointing to the directory to read from + """ + self.maps = [] + with _parallelize_pool() as pool: + for elem in maps: + if elem.tag == "Map": + self.maps.append(pool.submit(CartoLLMap, elem, path_prefix)) #self.maps.append(CartoLLMap(elem, path_prefix)) + elif elem.tag == "TagsTable": + self.tags_table = xml_to_dataframe(elem) + elif elem.tag == "ColoringTable": + self.coloring_table = xml_to_dataframe(elem) + + self.maps = [res.result() for res in self.maps] + + + def _read_xml(self, xml_h : ET, path_prefix : str): + """Read the XML data of the study and parses all the data in it + + Parameters + ---------- + xml_h : ET + The XML root element of the study + path_prefix : str + Path prefix pointing to the directory to read from + """ + root = xml_h.getroot() + self.name = root.attrib["name"] + futures = [] + with ThreadPoolExecutor() as pool: + for elem in root: + if elem.tag == "Maps": + futures.append(pool.submit(self._parse_maps, elem, path_prefix)) #self._parse_maps(elem, path_prefix) + elif elem.tag == "Meshes": + futures.append(pool.submit(self._parse_meshes, elem, path_prefix)) + + [res.result() for res in futures] + + def _from_zip(self, zip_fname : str, study_name : str = None): + """Loads the study from a zipped file by extracting it first and then calling :meth:`._from_dir` + + Parameters + ---------- + zip_fname : str + The name of the zip file + study_name : str, optional + The name of the XML study inside the zip file. Can also contain sub-folder paths. + Will default to the name of the zip file. + """ + with tempfile.TemporaryDirectory() as tmp_dir_name: + with zipfile.ZipFile(zip_fname, "r") as zip_f: + zip_f.extractall(tmp_dir_name) + + self._from_dir(tmp_dir_name, study_name) + + def _from_dir(self, dir_name : str, study_name : str = None): + """Loads the study from a directory file + + Parameters + ---------- + dir_name : str + Name of the directory to read from + study_name : str, optional + The name of the XML study inside the zip file. + Will default to the name of the bottom-most directory. + """ + if study_name is None: + study_name = os.path.basename(os.path.normpath(dir_name)) + + if not study_name.endswith(".xml"): + study_name += ".xml" + + #Move any folder directives from the study name to the dir name + dir_name = os.path.join(dir_name, os.path.split(study_name)[0]) + study_name = os.path.split(study_name)[1] + + self.aux_meshes = [] + full_fname = os.path.join(dir_name, study_name) + # Pass the path of the xml document + study_xml = ET.parse(full_fname) + self._read_xml(study_xml, dir_name) + #study_root = study_xml.getroot() + self.visitag_data = read_visitag_dir(os.path.join(dir_name, "VisiTagExport")) + + def __init__(self, arg1 : str, arg2 : str = None) -> None: + assert issubclass(type(arg1), str), "Given arguments not (yet) supported" + if os.path.isdir(arg1): + self._from_dir(arg1, arg2) + elif os.path.isfile(arg1) and arg1.endswith(".zip"): #Possible second argument: study name + self._from_zip(arg1, arg2) + else: + assert False, "Given arguments not (yet) supported" diff --git a/cartoreader_lite/low_level/utils.py b/cartoreader_lite/low_level/utils.py new file mode 100644 index 0000000..1de462c --- /dev/null +++ b/cartoreader_lite/low_level/utils.py @@ -0,0 +1,235 @@ +"""Utility functions to more easily read and write the CARTO3 files on a low level. +""" + +from typing import Iterable, List, Dict, Tuple, IO, Union +import pandas as pd +import xml.etree.ElementTree as ET +from xml.etree.ElementTree import Element +import os +from collections import defaultdict +import re +from os import PathLike +import numpy as np +from scipy.interpolate import interp1d +from scipy.spatial import cKDTree + +multi_whitespace_re = re.compile("\s\s+") + +def read_connectors(xml_elem : Element, path_prefix : str) -> Dict[str, List[pd.DataFrame]]: + """Reads connector data from the main XML element, pointing to multiple files with the attached connector data. + + Parameters + ---------- + xml_elem : Element + The XML Connector element where the data will be found + path_prefix : str + The path prefix where to search for the connector files + + Returns + ------- + Dict[str, List[pd.DataFrame]] + A dictionary mapping from the connector names to the associated pandas DataFrames that will contain the connector data. + """ + connectors = defaultdict(lambda: []) + for connector in xml_elem: + assert connector.tag == f"Connector", "Non connector XML element found ({connector.tag})" + assert len(connector.attrib.keys()) == 1, f"More than one attribute found for connector: {connector.attrib:s}" + k, fname = list(connector.items())[0] + full_fname = os.path.join(path_prefix, fname) + with open(full_fname, "r") as f: + metadata = (os.path.splitext(fname)[0], f.readline().strip()) + connectors[k].append((metadata, pd.read_csv(f, sep="\s+"))) #skiprows=1))) #Not necessary to skiprows if we don't seek to the beginning + + return dict(connectors) + +def read_contact_force(fname : str) -> pd.DataFrame: + with open(fname, "r") as f: + metadata = [f.readline().strip() for i in range(7)] + data = pd.read_csv(f, sep="\s+") + + return metadata, data + +def read_point_data(map_name : str, point_id : int, path_prefix : str = None) -> Tuple[Dict, Dict]: + """Reads all the available point data for given map and point ID, along with its metadata. + + Parameters + ---------- + map_name : str + Name of the map + point_id : int + Point ID to read + path_prefix : str, optional + Path prefix used while looking for files. + Will default to the current directory + + Returns + ------- + Tuple[Dict, Dict] + A tuple containing both a dictionary of metadata and the actual data + """ + + #e.g. 1-1-ReLA_P1380_Point_Export.xml + #print(f"Reading point {point_id}") + xml_fname = f"{map_name:s}_P{point_id:d}_Point_Export.xml" + if path_prefix is not None: + xml_fname = os.path.join(path_prefix, xml_fname) + + point_xml = ET.parse(xml_fname) + xml_root = point_xml.getroot() + + metadata = {} + data = {} + for elem in xml_root: + if elem.tag == "Positions": + data["connector_data"] = read_connectors(elem, path_prefix) + elif elem.tag == "ECG": + #ecg_metadata = + ecg_fname = os.path.join(path_prefix, elem.attrib["FileName"]) + with open(ecg_fname, "r") as ecg_f: + ecg_metadata = [ecg_f.readline().strip() for i in range(3)] + ecg_header = ecg_f.readline() + ecg_header = [elem for elem in re.split(multi_whitespace_re, ecg_header) if len(elem) > 0] #Two or more whitespaces as delimiters + #ecg_f.seek(0) + ecg_data = pd.read_csv(ecg_f, #skiprows=4, #Not necessary if we don't seek to the beginning + header=None, sep="\s+", names=ecg_header, dtype=np.int16) + data["ecg"] = (ecg_metadata, ecg_data) + elif elem.tag == "ContactForce": + data["contact_force_data"] = read_contact_force(os.path.join(path_prefix, elem.attrib["FileName"])) + else: + metadata[elem.tag] = xml_elem_to_dict(elem) + + return metadata, data + +def convert_df_dtypes(df : pd.DataFrame, inplace=True) -> pd.DataFrame: + if not inplace: + df = df.copy() + + #Convert to numerical values wherever possible + for k in df: + df[k] = pd.to_numeric(df[k], errors="ignore") + + return df + +def xml_elem_to_dict(xml_elem : Element) -> dict: + return xml_elem.attrib + + +def xml_to_dataframe(xml_elem : Element, attribs=None) -> pd.DataFrame: + dataframe_dict = defaultdict(lambda: []) + + for single_elem in xml_elem: + for k, v in single_elem.items(): #XML-Attributes + dataframe_dict[k].append(v) + + df = pd.DataFrame.from_dict(dataframe_dict) + return convert_df_dtypes(df) + +_camel_pattern = re.compile(r"(? str: + return _camel_pattern.sub('_', name).lower() + +#https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case +def snake_to_camel_case(name : str, capitalize=False) -> str: + ret = ''.join(word.title() for word in name.split('_')) + if not capitalize: + ret = ret[0].lower() + ret[1:] + + return ret + +def convert_fname_to_handle(file : Union[IO, PathLike], mode : str): + is_fname = issubclass(type(file), str) + if is_fname: + #assert file.endswith("pkl.gz"), "Only allowed file type is currently pkl.gz" + file = open(file, mode) + + return file, is_fname + + +def simplify_dataframe_dtypes(df : pd.DataFrame, dtype_dict : dict, inplace=True, double_to_float = False) -> pd.DataFrame: + if not inplace: + df = df.copy() + + for k in df: + if k in dtype_dict: + #TODO: Check for min/max values + df[k] = df[k].astype(dtype_dict[k]) + + elif double_to_float and df[k].dtype == np.float64: #np.issubdtype(df[k].dtype, np.floating) + df[k] = df[k].astype(np.float32) + + return df + +def interp1d_dtype(x : np.ndarray, y : np.ndarray, *args, **kwargs): + + #For complex objects (e.g. strings), just take the closest object + if y.dtype == np.object0: + kdtree = cKDTree(x[:, np.newaxis]) + interp_f = lambda x_query: y[kdtree.query(x_query[:, np.newaxis])[1]] + else: + interp_f = interp1d(x, y, *args, **kwargs) + + if np.issubdtype(y.dtype, np.integer): #Integer values will be rounded back + _interp_f = interp_f + interp_f = lambda x_query: np.round(_interp_f(x_query)).astype(y.dtype) + + return interp_f + +def interpolate_time_data(dfs : Iterable[pd.DataFrame], time_k, time_steps, **interp_kwargs) -> pd.DataFrame: + interp_fs = [] + for df in dfs: + interp_fs.append({}) + for col_i, col_name in enumerate(df): + #series = df.iloc[:, col_i] + + interp_fs[-1][col_name] = interp1d_dtype(df[time_k].to_numpy(), df.iloc[:, col_i].to_numpy(), **interp_kwargs) + #if series.dtype == np.object0: + # kdtree = cKDTree(df[time_k].to_numpy()[:, np.newaxis]) + # interp_fs[-1][col_name] = lambda x: df.iloc[kdtree.query(x[:, np.newaxis])[1], col_i] + #else: + # interp_fs[-1][col_name] = interp1d(df[time_k], df.iloc[:, col_i], **interp_kwargs) + + #Build the unique columns + all_columns = np.concatenate([df.columns for df in dfs]) + #Set the time as the first key + unique_columns = np.concatenate([[time_k], np.setdiff1d(np.unique(all_columns), [time_k])]) + new_df_dict = {time_k: time_steps} + for col_i, col_name in enumerate(unique_columns[1:]): #First column is the timing key + for df, df_interp in zip(dfs, interp_fs): + if col_name in df_interp: + interpolated_val = df_interp[col_name](time_steps) + if col_name in new_df_dict: #Already present -> Multiple dataframes contain the data + assert np.allclose(new_df_dict[col_name], interpolated_val, rtol=1e-1), f"Dataframe values of column {col_name} are not matching" + else: + new_df_dict[col_name] = interpolated_val + + merged_data = pd.DataFrame(new_df_dict) + return merged_data + + + +def unify_time_data(dfs : Iterable[pd.DataFrame], time_k, time_interval, domain="intersection", **interp_kwargs) -> pd.DataFrame: + assert domain in ["intersection"], "Selected domain not available or not yet implemented" + assert all([time_k in df for df in dfs]), f"Selected time key {time_k} not present in all dataframes." + + if domain == "intersection": + time_extent = (max([df[time_k].min() for df in dfs]), min([df[time_k].max() for df in dfs])) + + timespan = time_extent[1] - time_extent[0] + nr_samples = int(np.ceil(timespan / time_interval)) + time_samples = np.arange(time_extent[0], time_extent[1], time_interval) + if time_samples.size != nr_samples + 1: + time_samples = np.concatenate([time_samples, [time_extent[1]]]) + assert time_samples.size == nr_samples + 1 + + return interpolate_time_data(dfs, time_k, time_samples, **interp_kwargs) + +def xyz_to_pos_vec(data : pd.DataFrame, pos_label : str = "pos") -> pd.DataFrame: + xyz_strings = ["X", "Y", "Z"] + assert all([n in data for n in xyz_strings]), f"Can not convert dataframe position from XYZ to a vector, since not all columns were found. Available columns {data.columns}" + + pos_vec = np.stack([data[n] for n in xyz_strings], axis=-1).tolist() + data = data.drop(labels=xyz_strings[1:], axis=1) + data["X"] = pos_vec + return data.rename(columns={"X": pos_label}) diff --git a/cartoreader_lite/low_level/visitags.py b/cartoreader_lite/low_level/visitags.py new file mode 100644 index 0000000..1967e3e --- /dev/null +++ b/cartoreader_lite/low_level/visitags.py @@ -0,0 +1,50 @@ +from concurrent.futures import ThreadPoolExecutor +from pandas.errors import ParserError +from typing import Dict, Iterable, List, Union, IO +import pandas as pd +from os import PathLike +import os +import re + +visitag_misc_data_re_i = re.compile("^\s+(\w+)=\s+(-?\d+)") +visitag_misc_data_re_f = re.compile("^\s+(\w+)=\s+(-?\d+\.\d+)") +visitag_misc_data_re = re.compile("^\s+(\w+)=\s+(\w+)") + +def parse_misc_visitag_data(file_h : Union[IO, PathLike]): + with open(file_h, "r") as f: + lines = f.readlines() + data = {} + for line in lines: + for re_try, dtype in [(visitag_misc_data_re_i, int), (visitag_misc_data_re_f, float), (visitag_misc_data_re, str)]: + match = re_try.match(line) + if match is not None: + data[match.group(1)] = dtype(match.group(2)) + break + + return data + +def parse_visitag_file(file_h : Union[IO, PathLike], *args, **kwargs) -> Union[pd.DataFrame, Dict[str,str]]: + try: + return pd.read_csv(file_h, *args, **kwargs) + except ParserError as err: + return parse_misc_visitag_data(file_h) + +def parse_visitag_files(file_hs : Iterable[Union[IO, PathLike]]) -> List[pd.DataFrame]: + data = [] + with ThreadPoolExecutor() as pool: + for file_h in file_hs: + data.append(pool.submit(parse_visitag_file, file_h, sep="\s+")) + + return [d.result() for d in data] + +def read_visitag_dir(dir_path : str) -> Dict[str, pd.DataFrame]: + #visitag_data = {} + visitag_fnames = [] + for root, dirs, files in os.walk(dir_path): + for file in files: + if file.endswith(".txt"): + visitag_fnames.append(os.path.join(root, file)) + + data = parse_visitag_files(visitag_fnames) + visitag_data = {os.path.splitext(os.path.basename(file))[0]: d for file, d in zip(visitag_fnames, data)} + return visitag_data \ No newline at end of file diff --git a/cartoreader_lite/postprocessing/__init__.py b/cartoreader_lite/postprocessing/__init__.py new file mode 100644 index 0000000..2707249 --- /dev/null +++ b/cartoreader_lite/postprocessing/__init__.py @@ -0,0 +1,2 @@ +"""Module containing the few postprocessing steps implemented in the library +""" \ No newline at end of file diff --git a/cartoreader_lite/postprocessing/geometry.py b/cartoreader_lite/postprocessing/geometry.py new file mode 100644 index 0000000..7f3ea49 --- /dev/null +++ b/cartoreader_lite/postprocessing/geometry.py @@ -0,0 +1,52 @@ +import pyvista as pv +import numpy as np +import vtk +import trimesh +from trimesh.proximity import ProximityQuery +from typing import Tuple, Union + +def create_tri_mesh(mesh : pv.UnstructuredGrid) -> trimesh.Trimesh: + """Creates a Trimesh from an unstructured grid + + Parameters + ---------- + mesh : pv.UnstructuredGrid + mesh to convert. Will be automatically triangulated + + Returns + ------- + trimesh.Trimesh + The converted trimesh + """ + mesh = mesh.triangulate() + assert vtk.VTK_TRIANGLE in mesh.cells_dict and len(mesh.cells_dict) == 1, "Triangulation of the mesh failed" + + verts = mesh.points + faces = mesh.cells_dict[vtk.VTK_TRIANGLE] + return trimesh.Trimesh(verts, faces) + +def project_points(mesh : Union[trimesh.Trimesh, pv.UnstructuredGrid], points : np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Projects a set of points onto a triangulated surface mesh + + Parameters + ---------- + mesh : Union[trimesh.Trimesh, pv.UnstructuredGrid] + A mesh to project on. Will be converted to a trimesh, if it is not one already (see :func:`create_tri_mesh`) + points : np.ndarray + Points to project [Nx3] + + Returns + ------- + Tuple[np.ndarray, np.ndarray, np.ndarray] + The returned triplet will consist of: + + * The projected points [Nx3] + * The projection distance [N] + * The triangle index on which the projection ended up [N] + """ + if type(mesh) == pv.UnstructuredGrid: + mesh = create_tri_mesh(mesh) + + prox = ProximityQuery(mesh) + points_proj, proj_dist, tri_i = prox.on_surface(points) + return points_proj, proj_dist, tri_i diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..083bbdc --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,69 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) +import cartoreader_lite + +# -- Project information ----------------------------------------------------- + +project = 'Cartoreader Lite' +copyright = '2022, Thomas Grandits' +author = 'Thomas Grandits' + +# The full version, including alpha/beta/rc tags +release = cartoreader_lite.__version__ + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.coverage', 'sphinx.ext.napoleon' +] + +#https://stackoverflow.com/questions/56693832/should-sphinx-be-able-to-document-instance-attributes-in-a-class +autodoc_default_options = { + 'members': True, + 'member-order': 'bysource', + #'special-members': '__init__', +} + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'links.rst'] + +#https://stackoverflow.com/questions/61688403/can-i-keep-all-my-external-links-in-a-separate-file-with-sphinx +#This part makes external links available to all documentation files +rst_epilog ="" +with open("links.rst", "r") as f: + rst_epilog += f.read() + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +#html_theme = 'alabaster' +html_theme = "pydata_sphinx_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] \ No newline at end of file diff --git a/docs/design.rst b/docs/design.rst new file mode 100644 index 0000000..427342d --- /dev/null +++ b/docs/design.rst @@ -0,0 +1,59 @@ +.. _file_format: + +CARTO3 File Format +================== + +This document contains information and a few pointers on the terminology used both inside the CARTO3 file format and also in this library. +There's also a replicating python class associated to each of these parts that will hold the information after reading (listed at the bottom of the section). + +.. warning:: + + The information provided in this document is not an official documentation of the proprietary data format. + As such, the information is merely meant as a guideline to better understand the available data for scientific purposes. + It is in no way a complete and error free documentation that can be used in production, yet alone medical devices. + **No** warranty is provided that the given information is complete or error-free. + +Study +---------- + +The highest abstraction level of the CARTO3 system is a study and the entry point of `cartoreader-lite itself`. +It can contain an arbitrary combination of multiple: + + - `Maps <#map>`_ + - `Visitags <#visitag>`_ + - `Auxiliary Meshes <#auxiliary-mesh>`_ + +Associated class: :class:`.CartoStudy`. + +Map +---------- + +During a study, multiple maps a generated according to the need of the attending staff (e.g. pre- and post ablation). +Each map contains: + + - A mesh with which to associate the recordings + - Multiple `points <#point>`_ with electrical recordings. These are not registered to the mesh + +Associated class: :class:`.CartoMap`. + +Point +----------- + +Each point represents an electrical recording at a single location. +A point contains not just the position, but also :term:`ECG` and :term:`EGM` readings over time. + +Associated class: :class:`.CartoPointDetailData`. + +Visitag +---------- + +Visitag sites store information about the ablation sites. + +Associated class: :class:`.AblationSites` + +Auxiliary Mesh +------------------ + +Additional meshes exported by CARTO3, e.g. by `CartoSeg`_. + +Associated class :class:`.CartoAuxMesh` diff --git a/docs/example.inc b/docs/example.inc new file mode 100644 index 0000000..bff3e3d --- /dev/null +++ b/docs/example.inc @@ -0,0 +1,46 @@ +To test the library, you first need to get CARTO3 data. +None is provided with this repository, but you can download the testing data provided by `OpenEP`_ to quickly try out the library (make sure the libary was installed first): + +.. code-block:: bash + + python tests/generate_test_data.py + + +.. code-block:: python + + from cartoreader_lite import CartoStudy + study_dir = "openep-testingdata/Carto/Export_Study-1-11_25_2021-15-01-32" + study_name = "Study 1 11_25_2021 15-01-32.xml" + study = CartoStudy(study_dir, study_name, + carto_map_kwargs={"discard_invalid_points": False} #All points of the example are outside the WOI, which would be by default discarded + ) + ablation_points = pv.PolyData(np.stack(study.ablation_data.session_avg_data["pos"].to_numpy())) + ablation_points.point_data["RFIndex"] = study.ablation_data.session_avg_data["RFIndex"] + plotter = pv.Plotter() + plotter.add_mesh(ablation_points, cmap="jet") + plotter.add_mesh(study.maps[2].mesh) + plotter.show() + +You should see the recorded map of the `OpenEP`_ example, together with its recorded points like below. + +.. image:: figures/openep-example.png + +`cartoreader_lite` also offers the possibility to directly load the CARTO3 exported zip-files. +For the zipped `OpenEP`_ testing data, this would like the following: + +.. code-block:: python + + study_dir = "openep-testingdata.zip" + study_name = "Carto/Export_Study-1-11_25_2021-15-01-32/Study 1 11_25_2021 15-01-32.xml" + study = CartoStudy(study_dir, study_name) + +Buffering +----------- +Loading the CARTO3 study is heavy on the CPU, which is why it is often useful to backup your studies. +This can be easily achieved through the :meth:`cartoreader_lite.CartoStudy.save` method + +.. code-block:: python + + bak_name = "Study 1 11_25_2021 15-01-32.pkl.gz" + study.save(bak_name) + study_bak = CartoStudy(bak_name) diff --git a/docs/figures/openep-example.png b/docs/figures/openep-example.png new file mode 100644 index 0000000..8a8c4a0 Binary files /dev/null and b/docs/figures/openep-example.png differ diff --git a/docs/generate_doc_figs.py b/docs/generate_doc_figs.py new file mode 100644 index 0000000..375ca9f --- /dev/null +++ b/docs/generate_doc_figs.py @@ -0,0 +1,18 @@ +import pyvista as pv +import numpy as np +from cartoreader_lite import CartoStudy + +if __name__ == "__main__": + study_dir = "openep-testingdata/Carto/Export_Study-1-11_25_2021-15-01-32" + study_name = "Study 1 11_25_2021 15-01-32.xml" + study = CartoStudy(study_dir, study_name, + carto_map_kwargs={"discard_invalid_points": False} #The testing data only contains invalid points (LAT outside WOI) + ) + + ablation_points = pv.PolyData(np.stack(study.ablation_data.session_avg_data["pos"].to_numpy())) + ablation_points.point_data["RFIndex"] = study.ablation_data.session_avg_data["RFIndex"] + plotter = pv.Plotter(off_screen=True) + plotter.add_mesh(ablation_points, cmap="jet") + plotter.add_mesh(study.maps[2].mesh) + plotter.show(screenshot="docs/figures/openep-example.png") + #plotter.show() diff --git a/docs/glossary.rst b/docs/glossary.rst new file mode 100644 index 0000000..5fdfddc --- /dev/null +++ b/docs/glossary.rst @@ -0,0 +1,22 @@ +Glossary +----------- + +The following acronyms will be used throughout the documentation. + +.. glossary:: + + ECG + The **Electrocardiogram** is usually used for electrical recordings of the heart. In this documentation, the term is used for the recordings on the skin. + + EGM + An **Electrogram** is the general term used for electrical recordings over time. In this documentation, this term is mainly reserved for internal recordings. + + LAT + The **Local Activation Time** of a point is defined as the point in time at which it is activated. + + RFIndex + **Radio Frequency Index**, or more commonly ablation index, is a numerical marker associated to each ablation site, used to better quantify the applied ablation force, power and duration according to studies. + For more info, see: ``_. + + WOI + CARTO3 defines the **Window Of Interest** as the time in which recordings are considered valid (e.g. to avoid far field measurements). diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..1bd84ea --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,89 @@ +.. Cartoreader Lite documentation master file, created by + sphinx-quickstart on Wed Jan 19 14:57:28 2022. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Cartoreader Lite's documentation +====================================== + +.. contents:: Quick Start + :depth: 3 + + +Introduction +------------- + +This repository is an inofficial reader to easily process exported `CARTO3 data `_ in Python. +It does not provide the more extensive capabilities to analyze the signals, such as `OpenEP`_, but is rather meant as a simple reader to import CARTO data. +The loaded time data is imported in `pandas `_ and the meshes in `VTK `_ provided through `PyVista `_, allowing for easy access, export and interoperatibility with existing software. + + +CARTO3 System +--------------- +The read file format is the output of the CARTO3 system, which is used as an electrical mapping device for guiding catheter ablation and catheter synchronization therapy. +For more details, please read the official website of Biosense Webster (https://www.jnjmedicaldevices.com/en-US/product/carto-3-system). + + **Carto3 System** + + *CARTO 3 System Version 7 and the CARTO PRIMEĀ® Module offers advancement in 3D mapping technology. From signals to diagnosis, across a wide range of electrophysiology procedures, we are reframing the future of electrophysiology.* + + +A short description can also be found in this documentation at :ref:`file_format`. + +Installation +------------- + +.. include:: installation.inc + +Usage +------ + +.. include:: example.inc + +Citation +------------ + +If you use the library in your scientific projects, please cite the associated `Zenodo archive `_. + +.. code-block:: bibtex + + %TODO + @software{thomas_grandits_2021_5594452, + author = {Thomas Grandits}, + title = {A Fast Iterative Method Python package}, + month = oct, + year = 2021, + publisher = {Zenodo}, + version = {1.1}, + doi = {10.5281/zenodo.5594452}, + url = {https://doi.org/10.5281/zenodo.5594452} + } + + +Detailed Content +----------------- +.. toctree:: + :maxdepth: 2 + + interface.rst + design.rst + +Module API +-------------- + +.. autosummary:: + :toctree: _autosummary + :recursive: + :caption: Module API + + cartoreader_lite + +.. include:: + glossary.rst + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.inc b/docs/installation.inc new file mode 100644 index 0000000..9abd343 --- /dev/null +++ b/docs/installation.inc @@ -0,0 +1,7 @@ +To install `cartoreader_lite`, you have to clone the repository and install the libary using `pip`. + +.. code-block:: bash + + git clone https://github.com/thomgrand/cartoreader-lite + cd cartoreader-lite + pip install -e . diff --git a/docs/interface.rst b/docs/interface.rst new file mode 100644 index 0000000..d6773ca --- /dev/null +++ b/docs/interface.rst @@ -0,0 +1,22 @@ +Interface Methods +================= + +Here you will find some information on the data and classes that are available after loading a CARTO3 study. +:class:`cartoreader_lite.CartoStudy` is the high level entry point to read CARTO3 studies. + +.. autoclass:: cartoreader_lite.CartoStudy + +Internal High Level Classes +---------------------------- + +These classes do not need to be manually created, but should be automatically created when generating a :class:`.CartoStudy`. +Their most important members are described below. + +.. autoclass:: cartoreader_lite.CartoMap + +.. autoclass:: cartoreader_lite.CartoPointDetailData + +.. autoclass:: cartoreader_lite.AblationSites + +.. autoclass:: cartoreader_lite.CartoAuxMesh + diff --git a/docs/links.rst b/docs/links.rst new file mode 100644 index 0000000..997d188 --- /dev/null +++ b/docs/links.rst @@ -0,0 +1,7 @@ +.. + Link from the documentation to the CARTOSeg tool +.. _CartoSeg: https://www.jnjmedicaldevices.com/en-US/product/cartoseg-ct-segmentation-module + +.. _OpenEP: https://openep.io/ + +.. _PyPI: https://pypi.org/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1b68d94 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a720e12 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +from setuptools import setup +from distutils.core import setup +import os + + +with open(os.path.join(os.path.dirname(__file__), 'README.md'), 'r') as readme: + long_description = readme.read() + +setup(name="cartoreader-lite", + version="1.0.0", + description="Cartoreader-lite provides a simplified and easy low-level access to CARTO3 studies.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/thomgrand/cartoreader-lite", + packages=["cartoreader_lite", "cartoreader_lite.high_level", "cartoreader_lite.low_level"], + install_requires=["numpy", "pyvista>=0.33", "vtk", "pandas", "scipy", + "trimesh", "rtree"], #Geometric postprocessing + classifiers=[ + "Programming Language :: Python :: 3" + ], + python_requires='>=3.8', + author="Thomas Grandits", + author_email="tomdev@gmx.net", + license="AGPL", + extras_require = { + "tests": ["pytest", "pytest-cov"], + "docs": ["sphinx", "pydata_sphinx_theme"] + } +) + diff --git a/tests/generate_test_data.py b/tests/generate_test_data.py new file mode 100644 index 0000000..025becc --- /dev/null +++ b/tests/generate_test_data.py @@ -0,0 +1,17 @@ +import os +import subprocess +import zipfile +import shutil + +if __name__ == "__main__": + subprocess.check_call(["git", "clone", "https://github.com/thomgrand/openep-testingdata"]) + + #Unzip the large file + visitag_dir = "openep-testingdata/Carto/Export_Study-1-11_25_2021-15-01-32/VisiTagExport/" + with zipfile.ZipFile(os.path.join(visitag_dir, "AllPositionInGrids.zip"), "r") as zip_f: + zip_f.extractall(visitag_dir) + + shutil.make_archive("openep-testingdata", "zip", "openep-testingdata") + + + diff --git a/tests/test_high_level_read.py b/tests/test_high_level_read.py new file mode 100644 index 0000000..cc75c98 --- /dev/null +++ b/tests/test_high_level_read.py @@ -0,0 +1,95 @@ +import pytest +from cartoreader_lite import CartoStudy +from cartoreader_lite.low_level.study import _parallelize_pool, CartoLLStudy +from concurrent.futures import ThreadPoolExecutor +from io import BytesIO +import pandas as pd +import pickle + +""" +def prepare_lib(): + global _parallelize_pool + print("Setup") + _parallelize_pool = ThreadPoolExecutor + yield None + print("Teardown") +""" + +def compare_studies(study1 : CartoStudy, study2 : CartoStudy): + assert len(study1.maps) == len(study2.maps) + assert all([m1.name == m2.name for m1, m2 in zip(study1.maps, study2.maps)]) + +class TestHighLevelCartoReader(): + + def test_openep_example(self): + #prepare_lib() + study_dir = "openep-testingdata/Carto/Export_Study-1-11_25_2021-15-01-32" + study_name = "Study 1 11_25_2021 15-01-32.xml" + study = CartoStudy(study_dir, study_name, carto_map_kwargs={"discard_invalid_points": False}) + + study.save("test_study.pkl.gz") + study_restored = CartoStudy.load_pickled_study("test_study.pkl.gz") + compare_studies(study, study_restored) + + study_restored = CartoStudy("test_study.pkl.gz") #Test direct constructor + compare_studies(study, study_restored) + + #Default arguments + study.save() + study_restored = CartoStudy.load_pickled_study(f"{study.name}.pkl.gz") + compare_studies(study, study_restored) + + #Save into a stream + with BytesIO() as bytes: + study.save(bytes) + bytes.seek(0) + study_restored = CartoStudy.load_pickled_study(bytes) + + compare_studies(study, study_restored) + + def test_openep_optional_kwargs(self): + #prepare_lib() + study_dir = "openep-testingdata/Carto/Export_Study-1-11_25_2021-15-01-32" + study_name = "Study 1 11_25_2021 15-01-32" #Try without the file ending + ll_study = CartoLLStudy(study_dir, study_name) + ll_study_bak = pickle.dumps(ll_study) + study = CartoStudy(ll_study) + + points = study.maps[2].points + assert type(points) == pd.DataFrame and len(points) == 0 #All points were discarded + + #Don't unify time + ll_study = pickle.loads(ll_study_bak) + study = CartoStudy(ll_study, ablation_sites_kwargs={"resample_unified_time": False}) + assert hasattr(study.ablation_data, "session_rf_data") and type(study.ablation_data.session_rf_data) == list and len(study.ablation_data.session_rf_data) > 0 and type(study.ablation_data.session_rf_data[0][1]) == pd.DataFrame + assert hasattr(study.ablation_data, "session_force_data") and type(study.ablation_data.session_force_data) == list and len(study.ablation_data.session_force_data) > 0 and type(study.ablation_data.session_force_data[0][1]) == pd.DataFrame + assert "pos" in study.ablation_data.session_time_data[0][1] + + #Leave the position vector as X, Y, Z + ll_study = pickle.loads(ll_study_bak) + study = CartoStudy(ll_study, ablation_sites_kwargs={"resample_unified_time": False, "position_to_vec": False}) + assert hasattr(study.ablation_data, "session_time_data") and type(study.ablation_data.session_time_data) == list and len(study.ablation_data.session_time_data) > 0 and type(study.ablation_data.session_time_data[0][1]) == pd.DataFrame + assert all([n in study.ablation_data.session_time_data[0][1] for n in ["X", "Y", "Z"]]) + + #Leave the EGM header numbers in the frame + ll_study = pickle.loads(ll_study_bak) + study = CartoStudy(ll_study, carto_map_kwargs={"remove_egm_header_numbers": False, "discard_invalid_points": False}) + egms = study.maps[2].points.detail[0].egm + ecgs = study.maps[2].points.detail[0].surface_ecg + assert all(["(" in col for col in egms.columns]) + assert all(["(" in col for col in ecgs.columns]) + + ll_study = pickle.loads(ll_study_bak) + study = CartoStudy(ll_study, carto_map_kwargs={"remove_egm_header_numbers": True, "discard_invalid_points": False}) + egms = study.maps[2].points.detail[0].egm + ecgs = study.maps[2].points.detail[0].surface_ecg + assert all(["(" not in col for col in egms.columns]) + assert all(["(" not in col for col in ecgs.columns]) + assert "proj_pos" in points #Check for the projection of the points + assert "proj_dist" in points + + ll_study = pickle.loads(ll_study_bak) + study = CartoStudy(ll_study, carto_map_kwargs={"discard_invalid_points": False, "proj_points": False}) + points = study.maps[2].points + assert "proj_pos" not in points #Check that the projection was not performed + assert "proj_dist" not in points diff --git a/tests/test_low_level_read.py b/tests/test_low_level_read.py new file mode 100644 index 0000000..6781c15 --- /dev/null +++ b/tests/test_low_level_read.py @@ -0,0 +1,46 @@ +from cartoreader_lite.low_level.study import CartoLLStudy, _parallelize_pool +from concurrent.futures import ThreadPoolExecutor +import pyvista as pv +import numpy as np +import pytest + +""" +@pytest.fixture() +def prepare_lib(): + global _parallelize_pool + _parallelize_pool = ThreadPoolExecutor + yield None +""" + +def low_level_sanity_check(study): + assert len(study.maps) == 4 + assert study.maps[0].name == "1-Map" + + map2 = study.maps[2] + assert len(map2.point_raw_data) == 2 + point_data = map2.points_main_data + assert "Position3D" in point_data + assert "CathOrientation" in point_data + assert hasattr(map2, "mesh") and type(map2.mesh) == pv.UnstructuredGrid and map2.mesh.n_cells >= 5e3 and map2.mesh.n_points >= 2e3 + assert hasattr(map2, "mesh_metadata") and "MeshName" in map2.mesh_metadata + +class TestLowLevelCartoReader(): + + def test_from_dir(self): + study_dir = "openep-testingdata/Carto/Export_Study-1-11_25_2021-15-01-32" + study_name = "Study 1 11_25_2021 15-01-32.xml" + study = CartoLLStudy(study_dir, study_name) + low_level_sanity_check(study) + + def test_from_zip(self): + study_dir = "openep-testingdata.zip" + study_name = "Carto/Export_Study-1-11_25_2021-15-01-32/Study 1 11_25_2021 15-01-32.xml" + study = CartoLLStudy(study_dir, study_name) + low_level_sanity_check(study) + + def test_invalid_args(self): + with pytest.raises(Exception): + study = CartoLLStudy([5, 4, 3]) + + with pytest.raises(Exception): + study = CartoLLStudy("N/A File") diff --git a/tests/test_postprocessing.py b/tests/test_postprocessing.py new file mode 100644 index 0000000..53a5c96 --- /dev/null +++ b/tests/test_postprocessing.py @@ -0,0 +1,32 @@ +import numpy as np +import pyvista as pv +import vtk +from cartoreader_lite.postprocessing.geometry import project_points, create_tri_mesh +import trimesh + +class TestPostprocessing(): + + def test_create_tri_mesh(self): + mesh = pv.UnstructuredGrid({vtk.VTK_TRIANGLE: np.array([[0, 1, 2]])}, + np.array([[0, 0, 0], [1.0, 0., 0.], [0, 1, 0]])) + tri_mesh = create_tri_mesh(mesh) + assert type(tri_mesh) == trimesh.Trimesh + assert len(tri_mesh.vertices) == 3 + assert len(tri_mesh.faces) == 1 + + def test_projection(self): + mesh = pv.UnstructuredGrid({vtk.VTK_TRIANGLE: np.array([[0, 1, 2]])}, + np.array([[0, 0, 0], [1.0, 0., 0.], [0, 1, 0]])) + + proj_pos, proj_dist, tri_i = project_points(mesh, np.array([[0.5, 0.5, 1]])) + assert np.allclose(proj_pos, np.array([[0.5, 0.5, 0]])) + assert np.isclose(proj_dist, 1) + assert np.all(tri_i == 0) + + #Test with the already converted mesh + proj_pos, proj_dist, tri_i = project_points(create_tri_mesh(mesh), np.array([[0.5, 0.5, 1]])) + assert np.allclose(proj_pos, np.array([[0.5, 0.5, 0]])) + assert np.isclose(proj_dist, 1) + assert np.all(tri_i == 0) + + diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..406e2d4 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,38 @@ +from cartoreader_lite.low_level.utils import snake_to_camel_case, simplify_dataframe_dtypes, convert_df_dtypes +import pandas as pd +import numpy as np + +def test_snake_to_camel_case(): + test_strings = ["camel_case", "imp_ort_ant_var"] + expected_strings = ["camelCase", "impOrtAntVar"] + assert all([snake_to_camel_case(t) == e for t, e in zip(test_strings, expected_strings)]) + assert all([snake_to_camel_case(t, capitalize=True) == (e[0].upper() + e[1:]) for t, e in zip(test_strings, expected_strings)]) + +def test_simplify_dataframe_dtypes(): + df = pd.DataFrame({"a": np.arange(3, dtype=np.int64), "b": np.array([0, 0.1, 2.], dtype=np.float64), "c": ["a", "b", "c"]}) + + df_conv = simplify_dataframe_dtypes(df, {"a": np.float16}, inplace=False, double_to_float=True) + assert df_conv.a.dtype == np.float16 + assert df_conv.b.dtype == np.float32 + assert df.a.dtype == np.int64 + assert df.b.dtype == np.float64 + + #df_orig = df.copy() + simplify_dataframe_dtypes(df, {"a": np.float16}, inplace=True, double_to_float=True) + assert df.a.dtype == np.float16 + assert df.b.dtype == np.float32 + +def test_convert_df_dtypes(): + df = pd.DataFrame({"a": np.arange(3, dtype=np.int64), "b": np.array([0, 0.1, 2.], dtype=np.float64), "c": ["a", "b", "c"]}) + df.a = df.a.astype(str) + df.b = df.b.astype(str) + + df_conv = convert_df_dtypes(df, inplace=False) + assert np.issubdtype(df_conv.a.dtype, np.integer) + assert np.issubdtype(df_conv.b.dtype, np.floating) + assert np.issubdtype(df.a.dtype, np.object0) + assert np.issubdtype(df.b.dtype, np.object0) + + convert_df_dtypes(df, inplace=True) + assert np.issubdtype(df.a.dtype, np.integer) + assert np.issubdtype(df.b.dtype, np.floating) \ No newline at end of file