diff --git a/.github/workflows/mod-art.yml b/.github/workflows/mod-art.yml new file mode 100644 index 0000000..9acc1fa --- /dev/null +++ b/.github/workflows/mod-art.yml @@ -0,0 +1,53 @@ +name: Mod-ART Method CI + +on: + push: + branches: + - main + - develop + pull_request: + types: [opened, synchronize, reopened] + +jobs: + uv-test: + name: Pytest for the mod-art runner + runs-on: ubuntu-latest + continue-on-error: true + + strategy: + matrix: + python-version: + - "3.11" + - "3.12" + - "3.13" + - "3.14" + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libglu1-mesa libgl1 mesa-utils + + - name: Run test for modart method + run: | + cd modart_method + uv run --extra tests pytest tests/ + + build-docker-image: + name: Build Docker Image + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - uses: actions/checkout@v6 + + - name: Build Docker Image + run: | + docker build -f modart_method/Dockerfile . --platform=linux/amd64 -t modart-method-test:latest diff --git a/example_settings/modart_setting.json b/example_settings/modart_setting.json new file mode 100644 index 0000000..a4b95f3 --- /dev/null +++ b/example_settings/modart_setting.json @@ -0,0 +1,130 @@ +{ + "type": "simulationSettings", + "options": [ + { + "name": "Response duration", + "description": "Duration of the returned impulse response, in seconds.", + "id": "durat", + "type": "float", + "display": "text", + "min": 1e-1, + "max": 1e1, + "default": 1e0, + "step": 1e-1, + "endAdornment": "s" + }, + { + "name": "Echogram sample rate", + "description": "Sample rate used to compute the echogram (energy response envelope). COMPLETELY UNRELATED TO THE AURALIZATION SAMPLE RATE. Higher values will produce more accurate results, but the decomposition will take longer.", + "id": "f_e", + "type": "float", + "display": "text", + "min": 5e1, + "max": 5e4, + "default": 5e3, + "step": 5e1, + "endAdornment": "Hz" + }, + { + "name": "Auralization sample rate", + "description": "Sample rate used to produce the auralization RIR.", + "id": "sampling_rate", + "type": "float", + "display": "text", + "min": 5e1, + "max": 5e4, + "default": 44.1e3, + "step": 1e2, + "endAdornment": "Hz" + }, + { + "name": "T60 threshold", + "description": "The decomposition (in each band) will stop after locating all energy modes with longer reverberation times than this.", + "id": "T60", + "type": "float", + "display": "text", + "min": 1e-3, + "max": 1e1, + "default": 1e-1, + "step": 1e-2, + "endAdornment": "s" + }, + { + "name": "Max slopes", + "description": "The decomposition (in each band) will stop after locating this many energy modes, even if the T60 threshold has not been reached.", + "id": "slopes", + "type": "int", + "display": "text", + "min": 1, + "max": 100, + "default": 10, + "step": 1 + }, + { + "name": "Air humidity", + "id": "humi", + "type": "float", + "display": "text", + "min": 0.0, + "max": 100.0, + "default": 50.0, + "step": 5.0, + "endAdornment": "%" + }, + { + "name": "Air temperature", + "id": "temp", + "type": "float", + "display": "text", + "min": -100.0, + "max": 100.0, + "default": 20.0, + "step": 1.0, + "endAdornment": "°C" + }, + { + "name": "Atmospheric pressure", + "id": "pres", + "type": "float", + "display": "text", + "min": 50.0, + "max": 150.0, + "default": 100.0, + "step": 0.1, + "endAdornment": "kPa" + }, + { + "name": "Points per square meter", + "description": "Density of surface sample points used for the numerical area integration.", + "id": "ppsm", + "type": "float", + "display": "text", + "min": 0, + "max": 100.0, + "default": 30.0, + "step": 0.1 + }, + { + "name": "Rays per hemisphere", + "description": "Density of sample directions used for the numerical solid angle integration.", + "id": "rays", + "type": "int", + "display": "text", + "min": 10, + "max": 10000, + "default": 1000, + "step": 100 + }, + { + "name": "Max parallel processes", + "description": "Cap to the number of processes spawned for the numerical integration.", + "id": "pool", + "type": "int", + "display": "text", + "min": 1, + "max": 10000, + "default": 4, + "step": 1 + } + ] +} diff --git a/methods-config.json b/methods-config.json index 5414375..a34e785 100644 --- a/methods-config.json +++ b/methods-config.json @@ -48,5 +48,15 @@ "entryFile":"sparrowpy_interface.py", "repositoryURL":"https://github.com/sparrow-acoustics/sparrowpy", "documentationURL":"https://sparrowpy.readthedocs.io/en/stable" + }, + { + "simulationType": "MoDART", + "containerImage": "modart_image:latest", + "envVars": {}, + "label": "MoD-ART", + "entryFile":"modart_interface.py", + "settings":"modart_setting.json", + "repositoryURL":"https://github.com/IoSR-Surrey/MoD-ART/", + "documentationURL":"https://github.com/IoSR-Surrey/MoD-ART/blob/master/example%20usage/Tutorial.ipynb" } ] diff --git a/modart_method/Dockerfile b/modart_method/Dockerfile new file mode 100644 index 0000000..b766d96 --- /dev/null +++ b/modart_method/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11.13-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies for mesh generation and scientific computing +RUN apt-get update && apt-get install -y \ + git \ + build-essential \ + gmsh \ + && rm -rf /var/lib/apt/lists/* + +# Copy method package directory +COPY modart_method /app/modart_method + +# Install the method package +RUN pip install --no-cache-dir /app/modart_method + +WORKDIR /app/modart_method + +# Default command to run the containerized MoDART method +CMD ["python", "-m", "modart_interface"] diff --git a/modart_method/LICENSE b/modart_method/LICENSE new file mode 100644 index 0000000..ed990f8 --- /dev/null +++ b/modart_method/LICENSE @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/modart_method/modart_interface/__cli__.py b/modart_method/modart_interface/__cli__.py new file mode 100644 index 0000000..721876c --- /dev/null +++ b/modart_method/modart_interface/__cli__.py @@ -0,0 +1,34 @@ +"""CLI module for MoDART method.""" +import os +from .modart_interface import MoDARTMethod +import json +import sys + + +def main() -> None: + """Run the MoDART method simulation.""" + # JSON path in the uploads folder. This variable is set for the + # container when it is started up. + json_file_path = os.environ.get("JSON_PATH") + + print(f"Running MoDART method with JSON_PATH={json_file_path}") + modart_method_object = MoDARTMethod(json_file_path) + try: + modart_method_object.run_simulation() + except Exception as e: + # Write error to result JSON so backend can read it + with open(json_file_path) as f: + data = json.load(f) + data['error'] = {'type': type(e).__name__, 'message': str(e)} + with open(json_file_path, 'w') as f: + json.dump(data, f, indent=4) + + # Ensure the container exits with exit code 1 to indicate failure + # The status code is used by the backend to determine if the simulation + # was successful or not. + sys.exit(1) + + # Save the results to a separate file + modart_method_object.save_results() + + print("MoDART container finished.") diff --git a/modart_method/modart_interface/__init__.py b/modart_method/modart_interface/__init__.py new file mode 100644 index 0000000..6937cdd --- /dev/null +++ b/modart_method/modart_interface/__init__.py @@ -0,0 +1,8 @@ +"""MoDARTMethod package.""" +from .__main__ import main +from .modart_interface import MoDARTMethod + +__all__ = [ + "main", + "MoDARTMethod" +] diff --git a/modart_method/modart_interface/__main__.py b/modart_method/modart_interface/__main__.py new file mode 100644 index 0000000..fe0bf36 --- /dev/null +++ b/modart_method/modart_interface/__main__.py @@ -0,0 +1,5 @@ +"""Main module for MoDART method.""" +from .__cli__ import main + +if __name__ == "__main__": + main() diff --git a/modart_method/modart_interface/definition.py b/modart_method/modart_interface/definition.py new file mode 100644 index 0000000..0031729 --- /dev/null +++ b/modart_method/modart_interface/definition.py @@ -0,0 +1,92 @@ +"""Base class implementation of the SimulationMethod interface class.""" +from abc import ABC, abstractmethod +from pathlib import Path +import time + +import requests + + +class SimulationMethod(ABC): + """Abstract base class for simulation methods. + + This class serves as a template for methods required to run a simulation + and return results to the simulation service executor. + + """ + + def __init__(self, input_json_path: str | Path | None): + """Initialize the simulation method. + + Parameters + ---------- + input_json_path : str | Path | None, optional + The path to the input JSON file, by default None + + Raises + ------ + FileNotFoundError + If the input JSON file does not exist. + + """ + if input_json_path is None or ( + isinstance(input_json_path, str) and input_json_path == ""): + raise FileNotFoundError("input_json_path cannot be None or empty") + + input_path = Path(input_json_path) + if not input_path.exists(): + raise FileNotFoundError( + f"Input JSON file not found: {input_json_path}") + + self._input_json_path = input_json_path + + @property + def input_json_path(self) -> str | Path: + """The input JSON file.""" + return self._input_json_path + + @abstractmethod + def run_simulation(self): + """Run the simulation for the given a JSON file.""" + pass + + def save_results( + self, + url="http://host.docker.internal:5001/receive", + max_retries=5, + delay=2, + ): + """Return the results back to the simulation service executor. + + Parameters + ---------- + url : str, optional + The URL of the results server, + by default "http://host.docker.internal:5001/receive" which + is the default address for local executrion via Docker. + max_retries : int, optional + The maximum number of retries if the request fails, by default 5 + delay : int, optional + The delay in seconds between retries, by default 2 + + """ + + json_tmp_file = self.input_json_path + for attempt in range(1, max_retries + 1): + try: + with open(json_tmp_file, "rb") as f: + response = requests.post(url, files={"file": f}) + + if response.status_code == 200: + print("Successfully sent file.") + return True + + print( + f"Attempt {attempt}: ", + f"Server returned {response.status_code}") + except requests.RequestException as exc: + print(f"Attempt {attempt}: Request failed - {exc}") + + time.sleep(delay) + + print("Max retries reached. Giving up.") + return False diff --git a/modart_method/modart_interface/modart_interface.py b/modart_method/modart_interface/modart_interface.py new file mode 100644 index 0000000..d4be415 --- /dev/null +++ b/modart_method/modart_interface/modart_interface.py @@ -0,0 +1,609 @@ +"""Module implementing a CHORAS interface for MoDART. +""" +import sys +import json +import gmsh +import numpy as np +from pathlib import Path + +from raves import compute_ART, compute_MoDART, run_MoDART +from raves.src.utils import sanitize_ascii + +from numpy.random import default_rng +from scipy.signal import butter, sosfilt +from scipy.interpolate import make_interp_spline + +from .definition import SimulationMethod + + +def save_converted_mesh(geo_file_path: str | Path, + output_folder_path: str | Path) -> None: + """ + Read a .geo file (using gmsh), triangulate its surfaces, and save the result + in Wavefront format (.obj + .mtl) as expected by MoD-ART. + + Parameters + ---------- + geo_file_path : str or Path + Path to the .geo file to be read. + output_folder_path : str or Path + Path to the folder where converted mesh files will be saved. + """ + if not Path(geo_file_path).is_file(): + raise FileNotFoundError('The specified .geo file could not be found.') + if not Path(output_folder_path).is_dir(): + raise NotADirectoryError('The specified output folder could not be found.') + + # Run the gmsh session in a "protected" scope to make sure it gets finalized no matter what. + gmsh.initialize() + try: + # This factor scales the size of triangles generated by gmsh. + # The triangulation has no effect on the results, it only slows things down; + # make it as large as possible. + gmsh.option.setNumber('Mesh.MeshSizeFactor', 100) + + # Read the input file... + gmsh.open(geo_file_path) + # ...and mesh its surfaces (2D elements). + gmsh.model.mesh.generate(2) + + # Retrieve the integer IDs of all surface materials (2D physical groups). + material_tags = gmsh.model.getPhysicalGroups(dim=2) + # Retrieve the names of surface materials, as strings. + material_names = [gmsh.model.getPhysicalName(dim, tag) + for (dim, tag) in material_tags] + # Retrieve the lists of "entities" characterized by each of the retrieved surface materials. + surface_entities = {name: [tag for dim, tag in gmsh.model.getEntitiesForPhysicalName(name)] + for name in material_names} + + # Retrieve the 3D coordinates of mesh vertices, and format them as an (N, 3) array. + node_tags_all, coords_all, _ = gmsh.model.mesh.getNodes() + vertices = coords_all.reshape((len(node_tags_all), 3)) + num_vertices = vertices.shape[0] + + # Reference object for gmsh elements of "triangle" type. + gmsh_triangle_type = gmsh.model.mesh.getElementType('triangle', 1) + + # Prepare the lists which will form the Wavefront-formatted faces. + triangles = list() + triangle_materials = list() + triangle_patch_ids = list() + num_patches = 0 + + # Cycle over the detected surface materials. + for material_name, entity_tags_list in surface_entities.items(): + # For each surface material, we found a list of "entities" which may be + # different polygons made of that material. + # TODO: Confirm if it actually works like that. + for tag in entity_tags_list: + # Find the triangles which form the surface entity. + face_nodes = gmsh.model.mesh.getElementFaceNodes(gmsh_triangle_type, 3, tag=tag) + num_triangles_in_element = len(face_nodes) // 3 + # Just in case a non-triangular surface element snuck in: ignore it. + if num_triangles_in_element == 0: + continue + # This forms an (M, 3) integer-valued array of vertex indices (1-indexed). + faces = np.reshape(face_nodes, (num_triangles_in_element, 3)) + + # For the purpose of MoD-ART, each "entity" is used as a surface patch. + # TODO: Allow automatic segmentation with maximum area threshold (will use code from our upcoming paper). + num_patches += 1 + for face in faces: + triangles.append(face) + # Note: the material name is "sanitized" to only latin letters, digits, and underscores. + triangle_materials.append(sanitize_ascii(material_name)) + triangle_patch_ids.append(num_patches) + finally: + # Close the gmsh session even in case of an exception. + gmsh.finalize() + + # Convert the lists to arrays, for easier handling. + triangles = np.array(triangles) + num_triangles = triangles.shape[0] + triangle_patch_ids = np.array(triangle_patch_ids) + + # The surface normals are inverted w.r.t. what MoD-ART expects. Flip them (by inverting the triangle winding). + # TODO: How can this be tested, to prevent breaking changes in the future? MoD-ART does not raise any errors if the normals are flipped. + triangles = triangles[:, ::-1] + + # Make sure all vertex indices are valid. + assert np.all(triangles <= num_vertices), 'The triangle definitions include vertex indices out of range.' + + # Prepare all of the lines to be written in each of the Wavefront files. + obj_output_lines = list() + mtl_output_lines = list() + + # The .obj always starts by specifying the .mtl. + obj_output_lines.append('mtllib mesh.mtl\n') + + # List all of the vertex coordinates, in order, in the .obj. + for v in vertices: + line = 'v ' + ' '.join([str(c) for c in v]) + '\n' + obj_output_lines.append(line) + + # List all of the triangles in the .obj, and add their materials to the .mtl. + latest_patch_name = None + for i in range(num_triangles): + # Consider the "dummy" material name assigned to this triangle. + patch_name = f'Patch_{triangle_patch_ids[i]}_Mat_{triangle_materials[i]}' + # If it is different from the latest material specified in the .obj, it needs to be added. + if patch_name != latest_patch_name: + obj_output_lines.append(f'usemtl {patch_name}\n') + + # It also needs to be added to the .mtl, as it is a new material definition. + mtl_output_lines.append(f'newmtl {patch_name}\n') + # Assign the "dummy" material a random RGB color. + r, g, b = np.round(np.random.uniform(size=3), 3) + mtl_output_lines.append(f'Kd {r} {g} {b}\n') + + # All following triangles in the .obj implicitly use this material. + latest_patch_name = patch_name + + # Add the vertex indices forming the triangle. + # Note: these are already 1-indexed, as they should be for the Wavefront format. + obj_output_lines.append('f ' + ' '.join([str(v) for v in triangles[i]]) + '\n') + + # Appending filenames using " / " requires this to be a Path object, not a plain string. + if type(output_folder_path) == str: + output_folder_path = Path(output_folder_path) + + with open(str(output_folder_path / 'mesh.obj'), mode='w') as file: + for line in obj_output_lines: + file.write(line) + with open(str(output_folder_path / 'mesh.mtl'), mode='w') as file: + for line in mtl_output_lines: + file.write(line) + + +def save_materials_file(json_file_path: str | Path) -> None: + """ + Read the JSON settings file and save surface material data in the .csv format + expected by MoD-ART, in the folder spacified in the JSON. + + Parameters + ---------- + json_file_path : str or Path + Path to the JSON file to be read. + """ + if not Path(json_file_path).is_file(): + raise FileNotFoundError('The specified JSON file could not be found.') + + # Load the input JSON file. + with open(json_file_path, 'r') as json_file: + result_container = json.load(json_file) + + # The formatted data needs to be saved in the specified folder. + if 'MoDART_data_subfolder' not in result_container: + raise ValueError('The MoD-ART data folder is not specified in the JSON.') + output_folder_path = Path(result_container['MoDART_data_subfolder']) + if not Path(output_folder_path).is_dir(): + raise NotADirectoryError('The MoD-ART data folder could not be found.') + + # TODO: For now, we assume that the length of each + # result_container['absorption_coefficients'].values() + # is the same as the length of each + # result_container['results'][res_idx]['frequencies'] + # Eventually this will change. This portion of the code will need to be updated. + freq_bands = None + for res in result_container['results']: + freqs = np.array(res['frequencies'], dtype=float) + if freq_bands is None: + freq_bands = freqs + else: + assert np.allclose(freq_bands, freqs) + + # Arrange the coefficients into a dict. + absorptions = dict() + for material, coeff_string in result_container['absorption_coefficients'].items(): + coeffs = np.array(coeff_string.replace(',', '').split(' '), dtype=float) + if freq_bands is None: + raise RuntimeError('The frequencies should be known before the coefficients are read.') + else: + assert len(freq_bands) == len(coeffs) + # Note: the material name is "sanitized" to only latin letters, digits, and underscores, + # MATCHING THE NAME REPORTED IN THE CONVERTED MESH FILES. + absorptions[sanitize_ascii(material)] = coeffs + + # Save the data in .csv format. + with open(str(output_folder_path / 'materials.csv'), mode='w') as file: + line = 'Frequencies, ' + ', '.join([str(f) for f in freq_bands]) + '\n' + file.write(line) + + for material, coeffs in absorptions.items(): + line = material + ', ' + ', '.join([str(c) for c in coeffs]) + '\n' + file.write(line) + + # TODO: For now, scattering coefficients are arbitrarily set to 0.3. + # Eventually the true values will be specified in the JSON. + line = material + ', 0.3\n' + file.write(line) + + +def noise_shaping(fs: int | float, + band_centers: np.ndarray, + envelopes: np.ndarray, + order: int = 5) -> np.ndarray: + """ + Generate impulse responses based on band-wise signal envelopes, through the + amplitude modulation of stochastic signals. + + Parameters + ---------- + fs : int + Audio sample rate of the envelopes AND the final response, in Hertz. + band_centers : np.ndarray + One-dimensional array containing the center frequency of each band, in Hertz. + envelopes : np.ndarray + Four-dimensional array containing the modulation envelopes. + It has shape (S, R, B, T), where: + - S is the number of sound sources + - R is the number of receivers + - B is the number of frequency bands (must match length of "band_centers") + - T is the duration in samples. + order : int + Order passed to the "butter" bandpass filtering function. + Flat responses are achieved with odd numbers greater than three. + + Returns + ------- + response : np.ndarray + Three-dimensional array containing the room impulse responses. + It has shape (S, R, T), matching the energy envelopes (all bands are combined). + + Notes + ----- + The provided envelopes are assumed to be AMPLITUDE, i.e., square-rooted energy. + The stochastic signal used for modulation is a Poisson process with lambda=0.5. + The signal is generated only once and used for all sources, receivers, and + frequency bands (band-passed appropriately for each). + The band-passing occurs BEFORE the amplitude modulation, so out-of-band artefacts + may be introduced if the envelopes are very jagged. + """ + assert band_centers.ndim == 1 + assert envelopes.ndim == 4 + assert envelopes.shape[2] == band_centers.shape[0] + + num_bands = band_centers.shape[0] + duration_in_samples = envelopes.shape[3] + + # Random number generator for the stochastic signal to be modulated. + rng = default_rng() + + # White noise + # noise_signal = rng.normal(size=duration_in_samples) + # Poisson process + noise_signal = rng.poisson(lam=0.05, size=duration_in_samples).astype(float) + + # Ensure the noise signal has unit energy per second, matching the + # convention used to generate the echograms. + noise_signal *= np.sqrt(duration_in_samples * fs / np.sum(noise_signal**2)) + + # Factor for octave-band boundaries. + # TODO: These may become third-octave bands at some point. + band_bound = np.sqrt(2) + + # Consider the frequency band centers provided alongside the input data. + # Ensure that all frequencies support filtering with the given sample rate. + if np.any(band_centers * band_bound >= fs): + print('Warning: the audio sample rate is too low for some frequency bands.') + # Select only the acceptable bands. + band_centers = band_centers[band_centers * band_bound < fs] + # Update the number of rendered bands. + num_bands = len(band_centers) + # Drop unused bands from the echogram, to preserve the right shape. + envelopes = envelopes[:, :, :num_bands] + + # Prepare an array for the band-pass filtered signals. + filtered_noise_signals = np.zeros((num_bands, duration_in_samples)) + + for b in range(num_bands): + # Prepare the suitable band-pass filter... + sos = butter(order, (band_centers[b] / band_bound, + band_centers[b] * band_bound), + btype='bandpass', output='sos', fs=fs) + # ...and apply it to the stochastic signal. + filtered_noise_signals[b] = sosfilt(sos, noise_signal) + + # The envelope array has shape (S, R, B, T), the noise signals have shape (B, T): + # we need to add two "leading" dimensions, which is done using [None, None]. + modulated_noise_signals = envelopes * filtered_noise_signals[None, None] + + # The dimension of index 2 holds the separate frequency bands. + # Sum the array along that dimension to obtain the complete impulse responses. + return np.sum(modulated_noise_signals, axis=2) + + +def schroeder_curves(fs: int | float, + band_centers: np.ndarray, + echograms: np.ndarray) -> np.ndarray: + """ + Convert a set of echograms into backward-integrated energy curves, with bandwitdh correction. + + Parameters + ---------- + fs : int + Audio sample rate used for the bandwidth correction, in Hertz. + band_centers : np.ndarray + One-dimensional array containing the center frequency of each band, in Hertz. + echograms : np.ndarray + Four-dimensional array containing the energy envelopes. + It has shape (S, R, B, T), where: + - S is the number of sound sources + - R is the number of receivers + - B is the number of frequency bands (must match length of "band_centers") + - T is the duration in samples. + + Returns + ------- + response : np.ndarray + Array of the same shape as "echograms", containing the energy decay curves. + """ + assert band_centers.ndim == 1 + assert echograms.ndim == 4 + assert echograms.shape[2] == band_centers.shape[0] + + # Factor for octave-band boundaries. + # TODO: These may become third-octave bands at some point. + band_bound = np.sqrt(2) + lower_thres = band_centers / band_bound + upper_thres = band_centers * band_bound + band_widths = upper_thres - lower_thres + + nyquist = fs / 2 + echograms *= band_widths[None, None, :, None] / nyquist + + return np.cumsum(echograms[:, :, :, ::-1], axis=-1)[:, :, :, ::-1] + + +class MoDARTMethod(SimulationMethod): + """Interface class to run the MoDART method. + + The class implements a method to run the calculations for the + MoD-ART analysis and generate responses using its parameters. + All required configuration parameters are expected to be provided + in the input JSON file passed during initialization. + """ + # TODO: Add more "failure" tests: make sure the correct exception is raised, e.g., + # "with pytest.raises(FileNotFoundError, match='part of error message'):" + # TODO: Fill out metrics like T30? It will be done by the backend eventually. + + def __init__(self, input_json_path: str | Path): + """Initialize the MoD-ART interface for the given JSON file. + + Parameters + ---------- + json_file_path : str | Path + Path to the JSON configuration file. + """ + super().__init__(input_json_path) + + def run_simulation(self) -> None: + """Prepare the input data and run the method proper. + """ + # Load the JSON configuration file. + with open(self.input_json_path, 'r') as json_file: + result_container = json.load(json_file) + + # Create a folder for the "temporary" ART and MoD-ART data. + geo_path = Path(result_container['geo_path']) + temp_subfolder = geo_path.parent / (str(geo_path.stem) + '_MoDART_data') + result_container['MoDART_data_subfolder'] = str(temp_subfolder) + if not temp_subfolder.is_dir(): + Path.mkdir(temp_subfolder) + + # Save the updated JSON (with the added MoDART_data_subfolder field). + with open(self.input_json_path, 'w') as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + # Convert the .geo file into the format expected by MoD-ART. + try: + save_converted_mesh(result_container['geo_path'], temp_subfolder) + except Exception as exc: + raise RuntimeError('Failed to reformat the input mesh as required.') from exc + + # Save the material information into the .csv file expected by MoD-ART. + try: + save_materials_file(self.input_json_path) + except Exception as exc: + raise RuntimeError('Failed to reformat the material properties as required.') from exc + + # Run a one-shot MoD-ART simulation. + try: + self._modart_method() + except Exception as exc: + raise RuntimeError('Failed to run the MoD-ART method.') from exc + + def _modart_method(self) -> None: + """ + Run a one-shot MoD-ART simulation, comprising: + - preparation of ART model (surface integrals); + - analysis of ART model (modal decomposition); + - generation of energy envelopes from MoD-ART parameters; + - generation of impulse responses from energy envelopes. + """ + # Load the JSON file and extract its relevant contents. + with open(self.input_json_path, 'r') as json_file: + result_container = json.load(json_file) + + environment_folder = result_container['MoDART_data_subfolder'] + response_duration = result_container['simulationSettings']['durat'] + echogram_sample_rate = result_container['simulationSettings']['f_e'] + audio_sample_rate = result_container['simulationSettings']['sampling_rate'] + multiprocess_pool_size = result_container['simulationSettings']['pool'] + humidity = result_container['simulationSettings']['humi'] + temperature = result_container['simulationSettings']['temp'] + pressure = result_container['simulationSettings']['pres'] + points_per_square_meter = result_container['simulationSettings']['ppsm'] + rays_per_hemisphere = result_container['simulationSettings']['rays'] + T60_threshold = result_container['simulationSettings']['T60'] + max_slopes_per_band = result_container['simulationSettings']['slopes'] + + # Each element of "results" is a simulation request for a different source in the same environment, + # which is analyzed only once. All share the same settings and the same list of receivers. + num_srcs = len(result_container['results']) + if num_srcs == 0: + raise ValueError('No source positions specified.') + + source_positions = np.zeros((num_srcs, 3)) + for src_idx in range(num_srcs): + src_dict = result_container['results'][src_idx] + + source_positions[src_idx] = np.array([src_dict['sourceX'], + src_dict['sourceY'], + src_dict['sourceZ']]) + + these_receiver_positions = np.array([[pos['x'], pos['y'], pos['z']] + for pos in src_dict['responses']]) + + if src_idx == 0: + receiver_positions = these_receiver_positions + elif these_receiver_positions.shape != receiver_positions.shape: + raise ValueError('The list of receiver positions is not the same for all sources.') + elif not np.allclose(receiver_positions, these_receiver_positions): + raise ValueError('The list of receiver positions is not the same for all sources.') + + num_rcvs = receiver_positions.shape[0] + if num_rcvs == 0: + raise ValueError('No receiver positions specified.') + + # Run the pre-processing (shared by all sources, receivers). + # Step 1: Prepare the ART model. + try: + compute_ART(folder_path=environment_folder, + multiprocess_pool_size=multiprocess_pool_size, + humidity=humidity, temperature=temperature, pressure=pressure, + points_per_square_meter=points_per_square_meter, + rays_per_hemisphere=rays_per_hemisphere) + except Exception as exc: + raise RuntimeError('Failed to create the ART model (environment pre-processing).') from exc + + # Claim that the ART model constitutes 40% of the overall progress (very arbitrary). + for src_idx in range(num_srcs): + result_container['results'][src_idx]['percentage'] = 40 + # Save the updated JSON. + with open(self.input_json_path, 'w') as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + # Step 2: Analyze the ART model. + try: + compute_MoDART(folder_path=environment_folder, + echogram_sample_rate=echogram_sample_rate, + T60_threshold=T60_threshold, + max_slopes_per_band=max_slopes_per_band, + skip_T60_plots=True, + raise_on_failure=True) + except Exception as exc: + raise RuntimeError('Failed to run the modal analysis (environment pre-processing).') from exc + + # Claim that the MoD-ART analysis constitutes another 40% of the overall progress (very arbitrary). + for src_idx in range(num_srcs): + result_container['results'][src_idx]['percentage'] = 80 + # Save the updated JSON. + with open(self.input_json_path, 'w') as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + # Generate the echograms with MoD-ART. + try: + MoDART_tuple = run_MoDART(environment_folder, + source_positions, receiver_positions, + echogram_duration=response_duration, + echogram_sample_rate=echogram_sample_rate, + humidity=humidity, temperature=temperature, pressure=pressure, + num_rays=rays_per_hemisphere) + MoDART_echograms, frequencies, MoDART_data = MoDART_tuple + except Exception as exc: + raise RuntimeError(f'Failed to generate echograms for simulation #{src_idx+1}.') from exc + + # Some MoD-ART parameters will be saved in the JSON. + # For now, this is just for debugging/testing. + result_container['MoDART_data'] = { + 'T60': MoDART_data['T60'].tolist(), + 'Band idx': MoDART_data['Band idx'].tolist(), + 'Eigenvector shape': MoDART_data['V_hat'].shape + } + + for src_idx in range(num_srcs): + # Noise-shaping code. TODO: may need revising. + # Claim that the response generation constitutes the last 5% of the overall progress (very arbitrary). + result_container['results'][src_idx]['percentage'] = 95 + # Save the updated JSON. + with open(self.input_json_path, 'w') as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + # Prepare the audio-rate time intervals at which we'll evaluate the upsampled echogram. + echogram_time_axis = np.arange(0, response_duration, 1 / echogram_sample_rate) + audio_time_axis = np.arange(0, response_duration, 1 / audio_sample_rate) + + # We use a linear interpolation, because any other upsampling algorithm risks introducing negative values. + linear_spline = make_interp_spline(echogram_time_axis, MoDART_echograms, k=1, axis=-1) + upsampled_echograms = linear_spline(audio_time_axis) + + # Normalize w.r.t. the new sample rate, to preserve the energy-per-second definition of echogram values. + upsampled_echograms *= echogram_sample_rate / audio_sample_rate + + # Translate the energy envelopes to amplitude envelopes. + envelopes = np.sqrt(upsampled_echograms) + + # Amplitude-modulate band-passed stochastic signals to produce an impulse response. + try: + responses = noise_shaping(audio_sample_rate, + frequencies, envelopes) + except Exception as exc: + raise RuntimeError(f'Failed to generate responses for simulation #{src_idx+1}.') from exc + + # Write results back to JSON. + for rcv_idx in range(num_rcvs): + # Note that the first index of "responses" is for the single source position. + result_container['results'][src_idx]['responses'][rcv_idx]['receiverResults'] = responses[0, rcv_idx].tolist() + + # EDCs = schroeder_curves(audio_sample_rate, frequencies, MoDART_echograms) + # EDCs = np.clip(EDCs, 1e-20, None) + # EDCs = 10 * np.log10(EDCs) + + # time_axis = np.arange(0, response_duration, 1 / echogram_sample_rate) + + # for rcv_idx in range(num_rcvs): + # for freq_idx, freq in enumerate(frequencies): + # result_container['results'][src_idx]['responses'][rcv_idx]['receiverResults'].append( + # { + # 'data': EDCs[src_idx, rcv_idx, freq_idx].tolist(), + # 't': time_axis.tolist(), + # 'frequency': freq, + # 'type': 'edc', + # } + # ) + + result_container['results'][src_idx]['percentage'] = 100 + # Save the updated JSON. + with open(self.input_json_path, 'w') as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + print('MoDART simulation completed successfully!') + + +def test_noise_shaping_flatness(): + from matplotlib import pyplot as plt + + for order in range(1, 12, 2): + resp = np.zeros(4097) + for i in range(100): + responses = noise_shaping(44100, + np.array([2.5e2, 5e2, 1e3, 2e3, 4e3, 8e3]), + np.ones((1, 1, 6, 88200)), + order) + response = responses.squeeze() + + transf = np.fft.rfft(response, n=2**13) + resp += np.abs(transf) + resp /= 100 + + plt.plot(resp, label=order) + + plt.xscale('log') + plt.yscale('log') + plt.legend() + plt.show() + + +# if __name__ == "__main__": +# test_noise_shaping_flatness() diff --git a/modart_method/pyproject.toml b/modart_method/pyproject.toml new file mode 100644 index 0000000..f538a88 --- /dev/null +++ b/modart_method/pyproject.toml @@ -0,0 +1,74 @@ +[project] +name = "modart_interface" +version = "0.1.0" +description = "Python package implementing Acoustic Radiance Transfer and its Modal Decomposition." +requires-python = ">=3.11" +authors = [ + { name = "Matteo Scerbo", email = "matteo.scerbo@gmail.com" }, +] +keywords = [ + "acoustic simulation", + "geometrical acoustics", + "acoustic radiance transfer", +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +dependencies = [ + "raves>=0.2.2", + "gmsh==4.13.1", + "requests", + "numpy<2.5.0", +] + +[project.optional-dependencies] +deploy = [ + "twine", + "wheel", + "build", + "setuptools", + "bump-my-version", +] + +tests = [ + "pytest", + "pytest-cov", + "watchdog", + "ruff", + "coverage", +] + +docs = [ + "sphinx", + "autodocsumm>=0.2.14", + "pydata-sphinx-theme", + "sphinx_mdinclude", + "sphinx-design", + "sphinx-favicon", + "sphinx-reredirects", +] + +dev = ["modart_interface[deploy,tests,docs]"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["modart_interface"] + +[project.scripts] +modart_interface = "modart_interface:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/modart_method/tests/conftest.py b/modart_method/tests/conftest.py new file mode 100644 index 0000000..46e0f21 --- /dev/null +++ b/modart_method/tests/conftest.py @@ -0,0 +1,179 @@ +import json +import os +import pytest +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock + + +def default_data_path(): + """Get the path to the default data folder.""" + return os.path.join( + os.path.dirname(os.path.abspath(__file__))) + + +def load_default_input_data(): + """Load the example input data.""" + with open(os.path.join( + default_data_path(), + "test_input_modart.json"), 'r') as f: + data = json.load(f) + + return data + + +@pytest.fixture +def default_input_data(): + """Fixture to load the example input data.""" + return load_default_input_data() + + +@pytest.fixture +def create_temporary_input_file(): + """Fixture to create a temporary input JSON file for testing. + + Can be reused to write results to. + """ + + input_tmp = load_default_input_data() + geo_file = os.path.join( + default_data_path(), "test_room_modart.geo") + + with tempfile.TemporaryDirectory() as tmpdirname: + tmp_path = Path(tmpdirname) / "temp_input.json" + shutil.copy(geo_file, Path(tmpdirname)) + input_tmp['geo_path'] = os.path.join( + tmpdirname, "test_room_modart.geo") + with open(tmp_path, 'w') as f: + json.dump(input_tmp, f) + + yield str(tmp_path) + + return str(tmp_path) + + +def create_modified_settings_input_file_multi_factory(**settings): + """Factory to create a modified input file with multiple settings changed. + + Parameters + ---------- + **settings : dict + Key-value pairs to update in simulationSettings. + + Returns + ------- + callable + A generator function that yields the path to the temporary file. + + Examples + -------- + >>> factory = create_modified_settings_input_file_multi_factory( + ... slopes=2, + ... pool=1 + ... ) + >>> gen = factory() + >>> json_path = next(gen) + """ + + def _create_modified_settings_input_file(): + input_tmp = load_default_input_data() + + if 'absorption_coefficients' in settings: + modified_coefficients = settings['absorption_coefficients'] + + # Ensure absorption_coefficients exists + if 'absorption_coefficients' not in input_tmp: + input_tmp['absorption_coefficients'] = {} + + # Update all provided coefficients + input_tmp['absorption_coefficients'].update(modified_coefficients) + + if 'simulationSettings' in settings: + modified_settings = settings['simulationSettings'] + + # Ensure simulationSettings exists + if 'simulationSettings' not in input_tmp: + input_tmp['simulationSettings'] = {} + + # Update all provided settings + input_tmp['simulationSettings'].update(modified_settings) + + geo_file = os.path.join( + default_data_path(), "test_room_modart.geo") + + with tempfile.TemporaryDirectory() as tmpdirname: + tmp_path = Path(tmpdirname) / "temp_input.json" + shutil.copy(geo_file, Path(tmpdirname)) + input_tmp['geo_path'] = os.path.join( + tmpdirname, "test_room_modart.geo") + with open(tmp_path, 'w') as f: + json.dump(input_tmp, f) + + yield str(tmp_path) + + return _create_modified_settings_input_file + + +@pytest.fixture +def json_file_factory(): + """Factory fixture to create JSON files with custom simulation settings. + + This fixture returns a callable that creates temporary JSON files with + modified simulation settings. Use this in your test files with + @pytest.mark.parametrize for flexible parametrization. + + Returns + ------- + callable + A function that takes **settings and returns a generator that yields + the JSON file path. + + Examples + -------- + In your test file: + + >>> @pytest.fixture + >>> def config_file(request, json_file_factory): + ... gen = json_file_factory(**request.param) + ... return next(gen) + >>> + >>> @pytest.mark.parametrize('config_file', [ + ... {'slopes': 2, 'pool': 1}, + ... {'slopes': 3, 'pool': 4}, + ... ], indirect=True) + >>> def test_something(config_file): + ... interface = MoDARTMethod(config_file) + ... # test logic... + """ + def _factory(**settings): + return create_modified_settings_input_file_multi_factory(**settings)() + + return _factory + + +@pytest.fixture +def create_modified_input_file(request, json_file_factory): + """Fixture that creates a JSON file based on test parameters.""" + gen = json_file_factory(**request.param) + json_path = next(gen) + # Make sure that the generator is properly closed after the test function + # finalizes to ensure cleanup of temporary files + try: + yield json_path + finally: + gen.close() + + + +@pytest.fixture +def mock_requests_post(): + """Fixture to mock requests.post for CLI tests. + + Returns the mock object so tests can make assertions on it. + """ + with patch("modart_interface.definition.requests.post") as mock_post: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_post.return_value = mock_response + yield mock_post diff --git a/modart_method/tests/test_definition.py b/modart_method/tests/test_definition.py new file mode 100644 index 0000000..d7797ec --- /dev/null +++ b/modart_method/tests/test_definition.py @@ -0,0 +1,37 @@ +"""Test the SimulationMethod base class for modart method.""" +import pytest +from unittest.mock import patch +from pathlib import Path + +from modart_interface.definition import SimulationMethod + + +@patch.multiple(SimulationMethod, __abstractmethods__=set()) +def test_simulation_method_with_valid_file(create_temporary_input_file): + """Test SimulationMethod initialization with a valid file.""" + method = SimulationMethod(create_temporary_input_file) + assert method.input_json_path == create_temporary_input_file + + +@pytest.mark.parametrize("empty_path", [None, ""]) +@patch.multiple(SimulationMethod, __abstractmethods__=set()) +def test_simulation_method_with_none_path(empty_path): + """Test SimulationMethod initialization with None path.""" + with pytest.raises(FileNotFoundError, match="input_json_path cannot be None or empty"): + SimulationMethod(empty_path) + + +@patch.multiple(SimulationMethod, __abstractmethods__=set()) +def test_simulation_method_with_nonexistent_file(): + """Test SimulationMethod initialization with a non-existent file.""" + nonexistent_path = "/tmp/nonexistent_file_that_does_not_exist.json" + with pytest.raises(FileNotFoundError, match="Input JSON file not found"): + SimulationMethod(nonexistent_path) + + +@patch.multiple(SimulationMethod, __abstractmethods__=set()) +def test_simulation_method_with_path_object(create_temporary_input_file): + """Test SimulationMethod initialization with a Path object.""" + path_obj = Path(create_temporary_input_file) + method = SimulationMethod(path_obj) + assert method.input_json_path == path_obj diff --git a/modart_method/tests/test_fixtures.py b/modart_method/tests/test_fixtures.py new file mode 100644 index 0000000..6db77d1 --- /dev/null +++ b/modart_method/tests/test_fixtures.py @@ -0,0 +1,23 @@ +"""Test fixtures for MoDART method tests.""" +import pytest + + +def test_default_input_data_structure(default_input_data): + """Test that the default input data has the expected structure.""" + assert "results" in default_input_data + assert len(default_input_data["results"]) > 0 + for i in range(len(default_input_data["results"])): + assert "sourceX" in default_input_data["results"][i] + assert "sourceY" in default_input_data["results"][i] + assert "sourceZ" in default_input_data["results"][i] + assert "responses" in default_input_data["results"][i] + assert len(default_input_data["results"][i]["responses"]) > 0 + assert "geo_path" in default_input_data + assert "absorption_coefficients" in default_input_data + + +def test_create_temporary_input_file_fixture(create_temporary_input_file): + """Test that the temporary input file fixture works correctly.""" + import os + assert os.path.exists(create_temporary_input_file) + assert create_temporary_input_file.endswith(".json") diff --git a/modart_method/tests/test_input_modart.json b/modart_method/tests/test_input_modart.json new file mode 100644 index 0000000..3c78bcd --- /dev/null +++ b/modart_method/tests/test_input_modart.json @@ -0,0 +1,63 @@ +{ + "absorption_coefficients": { + "walls": "0.02, 0.03, 0.04, 0.08, 0.15", + "floor": "0.11, 0.22, 0.42, 0.57, 0.63", + "ceiling": "0.01, 0.01, 0.01, 0.01, 0.01" + }, + "geo_path": "tests/test_room_modart.geo", + "results": [ + { + "label": "Source 1", + "orderNumber": 1, + "percentage": 0, + "sourcePointId": "fbc3e3f4-62ba-409b-87c4-8ad6da93713d", + "sourceX": 0.3, + "sourceY": 0.3, + "sourceZ": 0.3, + "resultType": "MoDART", + "frequencies": [ + 125, + 250, + 500, + 1000, + 2000 + ], + "responses": [ + { + "label": "Receiver 1", + "orderNumber": 1, + "x": 0.6, + "y": 0.6, + "z": 0.3, + "pointId": "66821432-a860-4051-acf7-f3e719e479a3", + "parameters": { + "edt": [], + "t20": [], + "t30": [], + "c80": [], + "d50": [], + "ts": [], + "spl_t0_freq": [] + }, + "receiverResults": [] + } + ] + } + ], + "task_id": "6511d180-1431-4d89-bcd5-a81dceab6f3a", + "fs_auralization": 44100, + "simulationSettings": { + "durat": 1, + "f_e": 10000, + "T60": 0.1, + "slopes": 2, + "humi": 50, + "temp": 20, + "pres": 100, + "ppsm": 30, + "rays": 1000, + "pool": 4, + "sampling_rate": 44100 + }, + "settingsPreset": "Default" +} diff --git a/modart_method/tests/test_modart_cli.py b/modart_method/tests/test_modart_cli.py new file mode 100644 index 0000000..871064b --- /dev/null +++ b/modart_method/tests/test_modart_cli.py @@ -0,0 +1,180 @@ +"""Test the MoDART method CLI.""" +import os +import json +import pytest +import numpy as np + +from modart_interface import main + + +""" +DEFAULT VALUES: +{'absorption_coefficients': {'walls': '0.02, 0.03, 0.04, 0.08, 0.15', + 'floor': '0.11, 0.22, 0.42, 0.57, 0.63', + 'ceiling': '0.01, 0.01, 0.01, 0.01, 0.01'}, + 'simulationSettings': {'durat': 1, + 'f_e': 10000, + 'T60': 0.1, + 'slopes': 2, + 'humi': 50, + 'temp': 20, + 'pres': 100, + 'ppsm': 30, + 'rays': 1000, + 'pool': 4}}, +""" +@pytest.mark.parametrize('create_modified_input_file', [ + {'simulationSettings': {'pool': 1}}, + {'simulationSettings': {'pool': 2}}, + {'simulationSettings': {'slopes': 1}}, + {'simulationSettings': {'slopes': 3}}, + {'absorption_coefficients': {'walls': '0, 0, 0, 0, 0', + 'floor': '0, 0, 0, 0, 0', + 'ceiling': '0, 0, 0, 0, 0'}, + 'simulationSettings': {'slopes': 1, 'humi': 0, 'temp': 0}}, + {'absorption_coefficients': {'walls': '1, 1, 1, 1, 1', + 'floor': '1, 1, 1, 1, 1', + 'ceiling': '1, 1, 1, 1, 1'}, + 'simulationSettings': {'slopes': 1}}, +], indirect=True) +def test_modart_method_cli(mock_requests_post, create_modified_input_file): + """Test the MoDART method CLI.""" + # Set JSON_PATH environment variable and call main() directly + os.environ["JSON_PATH"] = create_modified_input_file + + with open(create_modified_input_file, 'r') as f: + input_data = json.load(f) + + assert 'simulationSettings' in input_data + settings = input_data['simulationSettings'] + assert len(settings) > 0 + + assert 'absorption_coefficients' in input_data + coeffs = input_data['absorption_coefficients'] + assert len(coeffs) > 0 + + if all(coeff == '1, 1, 1, 1, 1' + for coeff in coeffs.values()): + # When all material absorptions are exactly 1, reverberation time is 0. + # The state transition matrix becomes singular, and decomposition is impossible. + # with pytest.raises(RuntimeError, match='Failed to run the modal analysis'): + # main() + + # https://stackoverflow.com/a/73478360 + with pytest.raises(SystemExit) as excinfo: + main() + assert excinfo.value.code == 1 + + with open(create_modified_input_file, 'r') as f: + output_data = json.load(f) + + assert 'error' in output_data + error = output_data['error'] + assert 'type' in error + assert 'message' in error + assert error['type'] == 'RuntimeError' + assert 'Failed to run' in error['message'] + else: + # With other settings, the decomposition should not have any issues. + # N.B.: All remaining tests are in this scope, where "main()" is successful. + main() + + with open(create_modified_input_file, 'r') as f: + output_data = json.load(f) + + #### ASSERTIONS FOR ALL TEST CASES #### + + assert 'results' in output_data + results = output_data['results'] + assert len(results) > 0 + for res in results: + assert 'responses' in res + responses = res['responses'] + assert len(responses) > 0 + for resp in responses: + assert 'receiverResults' in resp + rec_res = resp['receiverResults'] + assert rec_res is not None + assert len(rec_res) > 0 + + assert np.any(np.abs(rec_res) > 0) + + # for rir in rec_res: + # assert len(r) == 4 + # assert 'data' in r + # assert 't' in r + # assert 'frequency' in r + # assert 'type' in r + # assert r['type'] == 'edc' + # assert len(r['data']) == len(r['t']) + + # assert np.all(np.isfinite(r['data'])) + + # The function returns a few MoD-ART parameters for debugging/testing. + assert 'MoDART_data' in output_data + MoDART_data = output_data['MoDART_data'] + assert len(MoDART_data) > 0 + + # The simulation settings ask for a number of slopes in 5 frequency bands, + # so there should be this many in total. + expected_modes = settings['slopes'] * 5 + + assert len(MoDART_data['T60']) == expected_modes + assert len(MoDART_data['Band idx']) == expected_modes + + # The band indices of the detected slopes should range from 0 to 4, + # and there should be a set number of each. + unique, unique_counts = np.unique(MoDART_data['Band idx'], return_counts=True) + assert np.all(unique == np.arange(5)) + assert np.all(unique_counts == settings['slopes']) + + # The test room has 6 patches with full visibility, so there should be 6*(6-1)=30 paths. + # If the second dimension is != 30, that means something went wrong in the mesh decoding. + assert MoDART_data['Eigenvector shape'] == [expected_modes, 30] + + #### ASSERTIONS SPECIFIC TO EACH TEST CASE #### + + if all(coeff == '0, 0, 0, 0, 0' + for coeff in coeffs.values()): + if settings['slopes'] != 1: + raise NotImplementedError('I did not prepare that much reference data.') + + # When all material absorptions are 0, reverberation time is only governed by air absorption. + assert np.allclose(MoDART_data['T60'], + [194.175, 184.681, 179.758, 169.130, 137.950], + rtol=0.05, atol=0.05) + + else: + # Confirm that the T60 values are reasonably close to a reference run. + if settings['slopes'] == 1: + assert np.allclose(MoDART_data['T60'], + [0.56, 0.32, 0.19, 0.13, 0.1], + rtol=0.05, atol=0.05) + elif settings['slopes'] == 2: + assert np.allclose(MoDART_data['T60'], + [0.56, 0.03, 0.32, 0.03, 0.19, 0.03, 0.13, 0.03, 0.1, 0.02], + rtol=0.05, atol=0.05) + elif settings['slopes'] == 3: + assert np.allclose(MoDART_data['T60'], + [0.56, 0.03, 0.03, + 0.32, 0.03, 0.03, + 0.19, 0.03, 0.03, + 0.13, 0.03, 0.03, + 0.1, 0.02, 0.02], + rtol=0.05, atol=0.05) + else: + raise NotImplementedError('I did not prepare that much reference data.') + + # Verify that requests.post was called (save_results was executed) + mock_requests_post.assert_called_once() + + +def test_modart_method_cli_missing_json_path(mock_requests_post): + """Test the MoDART method CLI with missing JSON_PATH.""" + # Clear JSON_PATH environment variable + if "JSON_PATH" in os.environ: + del os.environ["JSON_PATH"] + + # Expect FileNotFoundError from SimulationMethod.__init__ + with pytest.raises(FileNotFoundError, match="input_json_path cannot be None or empty"): + main() diff --git a/modart_method/tests/test_room_modart.geo b/modart_method/tests/test_room_modart.geo new file mode 100644 index 0000000..f2048e9 --- /dev/null +++ b/modart_method/tests/test_room_modart.geo @@ -0,0 +1,48 @@ +Point(1) = { 0.0, 0.0, 0.0, 1.0 }; +Point(2) = { 0.0, 0.0, 0.66, 1.0 }; +Point(3) = { 0.0, 1.02, 0.0, 1.0 }; +Point(4) = { 0.0, 1.02, 0.66, 1.0 }; +Point(5) = { 1.104, 0.0, 0.0, 1.0 }; +Point(6) = { 1.104, 0.0, 0.66, 1.0 }; +Point(7) = { 1.242, 0.8, 0.0, 1.0 }; +Point(8) = { 1.242, 0.8, 0.66, 1.0 }; + +Line(1) = { 3, 7 }; +Line(2) = { 5, 7 }; +Line(3) = { 1, 5 }; +Line(4) = { 1, 3 }; +Line(5) = { 4, 8 }; +Line(6) = { 7, 8 }; +Line(7) = { 3, 4 }; +Line(8) = { 2, 6 }; +Line(9) = { 6, 8 }; +Line(10) = { 2, 4 }; +Line(11) = { 1, 2 }; +Line(12) = { 5, 6 }; + +Line Loop(1) = { 1, -2, -3, 4 }; +Line Loop(2) = { 5, -6, -1, 7 }; +Line Loop(3) = { 8, 9, -5, -10 }; +Line Loop(4) = { -8, -11, 3, 12 }; +Line Loop(5) = { 10, -7, -4, 11 }; +Line Loop(6) = { 2, 6, -9, -12 }; + +Plane Surface(1) = { 1 }; +Plane Surface(2) = { 2 }; +Plane Surface(3) = { 3 }; +Plane Surface(4) = { 4 }; +Plane Surface(5) = { 5 }; +Plane Surface(6) = { 6 }; + +Surface Loop(1) = { 1, 2, 3, 4, 5, 6 }; +Physical Surface("floor") = { 1 }; +Physical Surface("ceiling") = { 3 }; +Physical Surface("walls") = { 2, 4, 5, 6 }; +Volume(1) = { 1 }; +Physical Volume("RoomVolume") = { 1 }; +Physical Line("default") = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }; +Mesh.Algorithm = 6; +Mesh.Algorithm3D = 1; // Delaunay3D, works for boundary layer insertion. +Mesh.Optimize = 1; // Gmsh smoother, works with boundary layers (netgen version does not). +Mesh.CharacteristicLengthFromPoints = 1; +// Recombine Surface "*";