From 48ebfca73845879afdb02d42418da72d58582d72 Mon Sep 17 00:00:00 2001 From: timon Date: Tue, 16 Jun 2026 22:55:59 +0200 Subject: [PATCH 01/11] Implementation of misuka interface still with debug to find svg NO FILE FOUND error --- common/exampleInput_misuka.json | 63 + example_settings/misuka_setting.json | 162 ++ methods-config.json | 10 + misuka_method/Dockerfile | 78 + misuka_method/LICENSE | 97 + misuka_method/misuka_interface/__cli__.py | 19 + misuka_method/misuka_interface/__init__.py | 8 + misuka_method/misuka_interface/__main__.py | 5 + misuka_method/misuka_interface/definition.py | 92 + .../misuka_interface/misuka_interface.py | 684 ++++++ misuka_method/pyproject.toml | 86 + misuka_method/tests/conftest.py | 73 + misuka_method/tests/test_definition.py | 37 + misuka_method/tests/test_fixtures.py | 23 + misuka_method/tests/test_input_misuka.json | 63 + misuka_method/tests/test_misuka_cli.py | 330 +++ misuka_method/tests/test_room_misuka.geo | 51 + misuka_method/tests/test_room_misuka.msh | 2116 +++++++++++++++++ 18 files changed, 3997 insertions(+) create mode 100644 common/exampleInput_misuka.json create mode 100644 example_settings/misuka_setting.json create mode 100644 misuka_method/Dockerfile create mode 100644 misuka_method/LICENSE create mode 100644 misuka_method/misuka_interface/__cli__.py create mode 100644 misuka_method/misuka_interface/__init__.py create mode 100644 misuka_method/misuka_interface/__main__.py create mode 100644 misuka_method/misuka_interface/definition.py create mode 100644 misuka_method/misuka_interface/misuka_interface.py create mode 100644 misuka_method/pyproject.toml create mode 100644 misuka_method/tests/conftest.py create mode 100644 misuka_method/tests/test_definition.py create mode 100644 misuka_method/tests/test_fixtures.py create mode 100644 misuka_method/tests/test_input_misuka.json create mode 100644 misuka_method/tests/test_misuka_cli.py create mode 100644 misuka_method/tests/test_room_misuka.geo create mode 100644 misuka_method/tests/test_room_misuka.msh diff --git a/common/exampleInput_misuka.json b/common/exampleInput_misuka.json new file mode 100644 index 0000000..de67c8f --- /dev/null +++ b/common/exampleInput_misuka.json @@ -0,0 +1,63 @@ +{ + "geo_path": "test_room_misuka.geo", + "msh_path": "test_room_misuka.msh", + "absorption_coefficients": { + "floor": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15", + "wall1": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15", + "ceiling": "0.4, 0.5, 0.6, 0.7, 0.8, 0.9", + "wall2": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15", + "wall3": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15", + "wall4": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15" + }, + "simulationSettings": { + "rays_per_frequency": 1048576, + "max_time": 0.1, + "sampling_rate": 10000, + "sound_power_W": 1, + "speed_of_sound": 343, + "dynamic_range_db": 100, + "scattering_125": 0.1, + "scattering_250": 0.1, + "scattering_500": 0.1, + "scattering_1000": 0.1, + "scattering_2000": 0.1, + "scattering_4000": 0.1 + }, + "results": [ + { + "simulationMethodId": 1, + "resultType": "IR", + "simulationId": 1, + "sourceX": 1.0, + "sourceY": 2.0, + "sourceZ": 1.5, + "frequencies": [ + 125, + 250, + 500, + 1000, + 2000, + 4000 + ], + "percentage": 0, + "responses": [ + { + "responseId": 1, + "x": 5.0, + "y": 3.5, + "z": 1.5, + "parameters": { + "edt": [], + "t20": [], + "t30": [], + "c80": [], + "d50": [], + "ts": [], + "spl_t0_freq": [] + }, + "receiverResults": [] + } + ] + } + ] +} diff --git a/example_settings/misuka_setting.json b/example_settings/misuka_setting.json new file mode 100644 index 0000000..056d808 --- /dev/null +++ b/example_settings/misuka_setting.json @@ -0,0 +1,162 @@ +{ + "type": "simulationSettings", + "options": [ + { + "name": "Rays per Frequencies", + "id": "rays_per_frequency", + "type": "integer", + "display": "text", + "min": 1000, + "max": 4194304, + "step": 524288, + "default": 1048576 + }, + { + "name": "Maximum Time to Simulate", + "id": "max_time", + "type": "float", + "display": "text", + "min": 0.01, + "max": 15, + "step": 1.0, + "default": 0.1, + "endAdornment": "s" + }, + { + "name": "Time Bins", + "id": "sampling_rate", + "type": "integer", + "display": "text", + "min": 100, + "max": 30000, + "step": 1000, + "default": 1000, + "endAdornment": "Hz" + }, + { + "name": "Sound Power of Source", + "id": "sound_power_W", + "display": "text", + "type": "float", + "min": 0.1, + "max": 50, + "step": 5, + "default": 1, + "endAdornment": "W" + }, + { + "name": "Speed of Sound", + "id": "speed_of_sound", + "display": "text", + "type": "float", + "min": 200, + "max": 360, + "step": 5, + "default": 343, + "endAdornment": "m/s" + }, + { + "name": "Dynamic Range", + "id": "dynamic_range_db", + "display": "text", + "type": "integer", + "min": 5, + "max": 200, + "step": 10, + "default": 100, + "endAdornment": "dB" + }, + { + "name": "Scattering for 125 Hz", + "id": "scattering_125", + "display": "text", + "type": "float", + "min": 0, + "max": 1, + "step": 0.1, + "default": 0.1, + "endAdornment": "" + }, + { + "name": "Scattering for 250 Hz", + "id": "scattering_250", + "display": "text", + "type": "float", + "min": 0, + "max": 1, + "step": 0.1, + "default": 0.1, + "endAdornment": "" + }, + { + "name": "Scattering for 500 Hz", + "id": "scattering_500", + "display": "text", + "type": "float", + "min": 0, + "max": 1, + "step": 0.1, + "default": 0.1, + "endAdornment": "" + }, + { + "name": "Scattering for 1000 Hz", + "id": "scattering_1000", + "display": "text", + "type": "float", + "min": 0, + "max": 1, + "step": 0.1, + "default": 0.1, + "endAdornment": "" + }, + { + "name": "Scattering for 2000 Hz", + "id": "scattering_2000", + "display": "text", + "type": "float", + "min": 0, + "max": 1, + "step": 0.1, + "default": 0.1, + "endAdornment": "" + }, + { + "name": "Scattering for 4000 Hz", + "id": "scattering_4000", + "display": "text", + "type": "float", + "min": 0, + "max": 1, + "step": 0.1, + "default": 0.1, + "endAdornment": "" + } + ], + + "_future_options": [ + { + "name": "Maximum Energy Loss", + "id": "max_energy_loss", + "type": "integer", + "display": "text", + "min": 0, + "max": 100, + "step": 1, + "default": 50, + "endAdornment": "%" + }, + { + "name": "Processing Unit", + "id": "comp_backend", + "type": "string", + "display": "radio", + "options": + { + "CPU": "cpu", + "CUDA GPU": "gpu" + }, + "default": "cpu" + } + ] +} \ No newline at end of file diff --git a/methods-config.json b/methods-config.json index 557376a..731ac75 100644 --- a/methods-config.json +++ b/methods-config.json @@ -38,5 +38,15 @@ "entryFile": "__main__.py", "repositoryURL": "https://github.com/LCAV/pyroomacoustics", "documentationURL": "https://pyroomacoustics.readthedocs.io" + }, + { + "simulationType": "misuka", + "containerImage": "misuka_image:latest", + "envVars": {}, + "label": "misuka", + "entryFile":"misukaInterface.py", + "settings":"misuka_setting.json", + "repositoryURL":"https://github.com/misuka-renderer/misuka.git", + "documentationURL":"https://www.tu.berlin/en/ak/research/projects/differentiable-acoustic-path-tracing" } ] diff --git a/misuka_method/Dockerfile b/misuka_method/Dockerfile new file mode 100644 index 0000000..e9fc1fc --- /dev/null +++ b/misuka_method/Dockerfile @@ -0,0 +1,78 @@ +FROM ubuntu:22.04 +#python 3.11 image statt ubuntu + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + software-properties-common \ + wget \ + gnupg \ + lsb-release \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Add LLVM repo for Clang 17 +RUN wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc && \ + echo "deb http://apt.llvm.org/jammy/ llvm-toolchain-jammy-17 main" \ + > /etc/apt/sources.list.d/llvm.list + +RUN add-apt-repository ppa:deadsnakes/ppa && \ + apt-get update && apt-get install -y \ + python3.11 \ + python3.11-dev \ + python3.11-distutils \ + python3-pip \ + clang-17 \ + libc++-17-dev \ + libc++abi-17-dev \ + cmake \ + ninja-build \ + git \ + build-essential \ + zlib1g-dev \ + libpng-dev \ + libjpeg-dev \ + gmsh \ + && rm -rf /var/lib/apt/lists/* + +ENV CC=clang-17 +ENV CXX=clang++-17 + +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 +RUN python3.11 -m pip install --upgrade pip + +# Install typing_extensions so stub generator works on Python < 3.11 +RUN python3.11 -m pip install typing_extensions + +# Clone misuka with ALL submodules +RUN git clone --recursive \ + https://github.com/misuka-renderer/misuka.git \ + /opt/misuka && \ + cd /opt/misuka && \ + git checkout 9845db9359ed7f489b5633195df3ce0acc09afe4 && \ + git submodule update --init --recursive + +# Build misuka from source, explicitly pointing cmake to Python 3.10 +RUN cd /opt/misuka && \ + mkdir build && cd build && \ + cmake -GNinja .. \ + -DMI_DEFAULT_VARIANTS="scalar_rgb;llvm_ad_rgb;scalar_acoustic;llvm_ad_acoustic" \ + -DCMAKE_BUILD_TYPE=Release \ + -DPython_EXECUTABLE=/usr/bin/python3.11 \ + -DPython3_EXECUTABLE=/usr/bin/python3.11 && \ + ninja + +# Add misuka build output to Python path +ENV PYTHONPATH="/opt/misuka/build/python" + +# Install remaining Python dependencies +RUN python3.11 -m pip install numpy requests gmsh meshio pyfar==0.8.0 pyrato matplotlib + +WORKDIR /app + +COPY misuka_method /app/misuka_method +RUN python3.11 -m pip install --no-deps /app/misuka_method + +WORKDIR /app/misuka_method + +CMD ["python3.11", "-m", "misuka_interface"] \ No newline at end of file diff --git a/misuka_method/LICENSE b/misuka_method/LICENSE new file mode 100644 index 0000000..a4c634d --- /dev/null +++ b/misuka_method/LICENSE @@ -0,0 +1,97 @@ +---------------------------------------------------------------------- +Combined Licensing Note +---------------------------------------------------------------------- + +This repository is based on code from **Mitsuba 3**, originally developed by +Wenzel Jakob and licensed under the terms listed at the bottom of this page. + +All modifications and additions made for **misuka** by the misuka contributors +are licensed under the GNU Lesser General Public License (LGPL), version 3 +or (at your option) any later version. + +When using, modifying, or redistributing this software, you must comply with +both licenses as they apply to the respective portions of the code. + +---------------------------------------------------------------------- +File Level Licensing Rules +---------------------------------------------------------------------- + +1. New files created for misuka + All files authored specifically for misuka and not derived from Mitsuba + code are licensed under the GNU Lesser General Public License (LGPL), + version 3 or any later version. + +2. Files originating from Mitsuba 3 + Files that come from the Mitsuba 3 codebase remain under the original + Mitsuba 3 license. + +3. Modified Mitsuba files + For files derived from Mitsuba code that contain modifications by misuka + contributors: + - The original Mitsuba portions remain under the original Mitsuba license. + - Any modifications licensed under the LGPL must be clearly marked in the + file header. + - If no such note is present, the entire file remains under the original + Mitsuba license. + + +---------------------------------------------------------------------- +Code for misuka (LGPL) +---------------------------------------------------------------------- + +Copyright (c) 2025 The misuka contributors + +All modifications and additions to this software made for Misuka are licensed +under the terms of the GNU Lesser General Public License (LGPL), version 3 or +(at your option) any later version. + +You may obtain a copy of the LGPL at: +https://www.gnu.org/licenses/lgpl-3.0.html + +This means: +- You may use and redistribute this code under the terms of the LGPL. +- You must make source code available for any changes to LGPL-covered parts. +- You may link this library with proprietary software under the conditions + specified in the LGPL. + + +---------------------------------------------------------------------- +Original Code from Mitsuba 3 +---------------------------------------------------------------------- + +Copyright (c) 2017 Wenzel Jakob , All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +You are under no obligation whatsoever to provide any bug fixes, patches, or +upgrades to the features, functionality or performance of the source code +("Enhancements") to anyone; however, if you choose to make your Enhancements +available either publicly, or directly to the author of this software, without +imposing a separate written license agreement for such Enhancements, then you +hereby grant the following license: a non-exclusive, royalty-free perpetual +license to install, use, modify, prepare derivative works, incorporate into +other computer software, distribute, and sublicense such enhancements or +derivative works thereof, in binary and source code form. \ No newline at end of file diff --git a/misuka_method/misuka_interface/__cli__.py b/misuka_method/misuka_interface/__cli__.py new file mode 100644 index 0000000..8862871 --- /dev/null +++ b/misuka_method/misuka_interface/__cli__.py @@ -0,0 +1,19 @@ +"""CLI module for misuka method.""" +import os +from .misuka_interface import misukaMethod + + +def main() -> None: + """Run the misuka 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 misuka method with JSON_PATH={json_file_path}") + misuka_method_object = misukaMethod(json_file_path) + misuka_method_object.run_simulation() + + # Save the results to a separate file + misuka_method_object.save_results() + + print("misuka container finished.") diff --git a/misuka_method/misuka_interface/__init__.py b/misuka_method/misuka_interface/__init__.py new file mode 100644 index 0000000..beb4d2c --- /dev/null +++ b/misuka_method/misuka_interface/__init__.py @@ -0,0 +1,8 @@ +"""misukaMethod package.""" +from .__main__ import main +from .misuka_interface import misukaMethod + +__all__ = [ + "main", + "misukaMethod" +] diff --git a/misuka_method/misuka_interface/__main__.py b/misuka_method/misuka_interface/__main__.py new file mode 100644 index 0000000..555f320 --- /dev/null +++ b/misuka_method/misuka_interface/__main__.py @@ -0,0 +1,5 @@ +"""Main module for misuka method.""" +from .__cli__ import main + +if __name__ == "__main__": + main() diff --git a/misuka_method/misuka_interface/definition.py b/misuka_method/misuka_interface/definition.py new file mode 100644 index 0000000..0031729 --- /dev/null +++ b/misuka_method/misuka_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/misuka_method/misuka_interface/misuka_interface.py b/misuka_method/misuka_interface/misuka_interface.py new file mode 100644 index 0000000..c33a0a2 --- /dev/null +++ b/misuka_method/misuka_interface/misuka_interface.py @@ -0,0 +1,684 @@ +"""Module implementing a CHORAS interface for misuka.""" + +import json +from pathlib import Path +import gmsh +import meshio +import mitsuba as mi +import numpy as np +import pyfar as pf +import pyrato +import pandas as pd + + +mi.set_variant("cuda_acoustic", "llvm_acoustic", "cuda_ad_acoustic", "llvm_ad_acoustic") + +from mitsuba import ScalarTransform4f as tf + +from .definition import SimulationMethod + + + +RESULT_PARAMETER_KEYS = ( + "edt", + "t20", + "t30", + "c80", + "d50", + "ts", + "spl_t0_freq", +) + + +class misukaMethod(SimulationMethod): + """Interface class to run the misuka method. + + The class implements method to run the calculations for the + misuka simulation method. All required configuration parameters + are expected to be provided in the input JSON file passed during + initialization. + + """ + + def __init__(self, input_json_path: str | Path | None = None): + """Initialize the misuka method interface for the given JSON file.""" + super().__init__(input_json_path) + + def run_simulation(self) -> None: + """Run the simulation. + + Parameters + ---------- + json_file_path : str | Path | None, optional + Path to the JSON file. If not provided, uses the path from initialization. + """ + self._misuka_method(self.input_json_path) + + + def _misuka_method(self, json_file_path: str | Path) -> None: + """ + Run misuka simulation for acoustic wave propagation. + + Args: + json_file_path: Path to the JSON configuration file + """ + + + # ---------------------------------------------------------------- + # ------- fetching simulation settings and initialization ------- + # ---------------------------------------------------------------- + + # Load the input JSON file + with open(json_file_path, "r") as json_file: + result_container = json.load(json_file) + + try: + simulation_settings = result_container["simulationSettings"] + speed_of_sound = simulation_settings["speed_of_sound"] + max_time = simulation_settings["max_time"] + time_bins = simulation_settings["sampling_rate"] + rpf = simulation_settings["rays_per_frequency"] + sound_power_W = simulation_settings["sound_power_W"] + n_receivers = len(result_container["results"][0]["responses"]) + frequencies = result_container["results"][0]["frequencies"] + msh_path = result_container["msh_path"] + absorption_map = result_container["absorption_coefficients"] + dynamic_range_db = simulation_settings["dynamic_range_db"] + except KeyError as e: + raise KeyError(f"Could not fetch the simulation settings for misuka T.T: {e}") + + source_intensity = sound_power_W / (4.0 * np.pi) # omnidirectional point source + + n_bands = len(frequencies) + frequency_range = (float(np.min(frequencies)), float(np.max(frequencies))) + f_center, f_lower, f_upper = pf.constants.fractional_octave_frequencies_exact(1, frequency_range) + assert np.all(np.abs(f_center-frequencies)/frequencies < 1e-2), "Frequency mismatch between input frequencies and pyrato's fractional octave frequencies" + frequencies_misuka = ", ".join(str(f) for f in frequencies) + + #TODO: currently using scattering specified by misuka_setting / User... for future use from msh file + scattering_map = [ + simulation_settings[f"scattering_{int(f)}"] + for f in frequencies + ] + source_coords = [ + result_container["results"][0]["sourceX"], + result_container["results"][0]["sourceY"], + result_container["results"][0]["sourceZ"], + ] + receiver_coords = [ + [ + result_container["results"][0]["responses"][i]["x"], + result_container["results"][0]["responses"][i]["y"], + result_container["results"][0]["responses"][i]["z"], + ] + for i in range(n_receivers) + ] + + # updating percentage of progress + set_progress_and_save(25, result_container, json_file_path) + + # ---------------------------------------------------------------- + # --- initializing scene parameters, microphone and integrator --- + # ---------------------------------------------------------------- + + scene = create_acoustic_scene( + msh_file=msh_path, + frequencies=frequencies, + absorption_map=absorption_map, + source_position=source_coords, + source_intensity=source_intensity, + scattering_map=scattering_map, + ) + + # set up the integrator + integrator_acoustic = mi.load_dict( + { + "type": "acoustic_path", + "speed_of_sound": speed_of_sound, + "max_depth": -1, # maximum path depth (-1 = no limit, 1 = direct sound only, 2 = up to 1 reflection, etc.) + "max_time": max_time, # maximum propagation time in seconds + } + ) + + set_progress_and_save(35, result_container, json_file_path) + + # ---------------------------------------------------------------- + # ------------- calculating ETC for each receiver -------------- + # ---------------------------------------------------------------- + + etc = np.empty((n_receivers, n_bands, time_bins), dtype=np.float64) + + for i_rec, receiver_coord in enumerate(receiver_coords): + # set up the microphone sensor for this receiver + microphone_direction = ( + np.asarray(source_coords) - np.asarray(receiver_coord) + ) + distance_to_source = np.linalg.norm( + microphone_direction.astype(float) + ) + if distance_to_source == 0: + raise ValueError( + f"Receiver {i_rec} is at the same position as the source." + ) + microphone_direction = ( + microphone_direction / distance_to_source + ).tolist() + + microphone = mi.load_dict( + { + "type": "microphone", + "origin": receiver_coord, + "direction": microphone_direction, + "film": { + "type": "tape", + "frequencies": frequencies_misuka, # rendered frequencies + "time_bins": time_bins, # number of time bins + }, + } + ) + + # render the scene for this receiver and store the ETC + render_raw = mi.render( + scene, + integrator=integrator_acoustic, + sensor=microphone, + spp=rpf, + ) + # Convert Mitsuba tensor to float64 explicitly + render_array = np.asarray(render_raw, dtype=np.float64) / float(rpf) #TODO: if misuka already averages over rays, this division is not needed. Check this and remove if so. + render_array = np.squeeze(render_array) + + if render_array.shape != (time_bins, n_bands): + raise ValueError( + "Unexpected Misuka render shape after squeeze: " + f"{render_array.shape}; expected {(time_bins, n_bands)}" + ) + + etc[i_rec] = render_array.T.astype(np.float64) + + # update progress for each reciever in even steps to ~90% + set_progress_and_save(35 + int(55/(n_receivers+1) * (i_rec + 1)), result_container, json_file_path) + + # ---------------------------------------------------------------- + # ------------- Converting ETC to EDC -------------- + # ---------------------------------------------------------------- + + # Create time vector + times_vector = np.linspace(0, max_time, time_bins) + + # Ensure etc is float64 before processing + etc = np.asarray(etc, dtype=np.float64) + times_vector = np.asarray(times_vector, dtype=np.float64) + + np.nan_to_num(etc, copy=False, nan=0.0, posinf=0.0, neginf=0.0) + np.maximum(etc, 0.0, out=etc) + + # Create pf.TimeData with explicit float64 dtypes + etc = pf.TimeData(etc.astype(np.float64), times_vector.astype(np.float64)) + times = etc.times.tolist() + + prepare_result_responses(result_container) + for i_rec in range(n_receivers): + edc = etc_to_edc(etc[i_rec, :], f_lower, f_upper) + edc_db_normalized, edc_db_absolute = edc_to_db(edc, dynamic_range_db) + # Ensure edc_db arrays are float64 + edc_db_normalized = np.asarray(edc_db_normalized, dtype=np.float64) + edc_db_absolute = np.asarray(edc_db_absolute, dtype=np.float64) + + store_receiver_results( + result_container["results"][0]["responses"][i_rec], + edc_db_normalized, + times, + frequencies, + ) + + calculate_and_store_acoustic_parameters( + edc, edc_db_absolute, result_container, i_rec + ) + + #print(result_container) + + # Export pressure data for legacy auralization pipeline + pressure_csv_path = str(json_file_path).replace(".json", "_pressure.csv") + + pressure_data = [] + for time_idx, time_value in enumerate(times): + row = {"t": time_value} + for band_idx, frequency in enumerate(frequencies): + row[f"{int(frequency)}Hz"] = float( + etc.time[0, band_idx, time_idx] + ) + pressure_data.append(row) + + df = pd.DataFrame(pressure_data) + df.to_csv(pressure_csv_path, index=False) + print(f"Pressure CSV saved as {pressure_csv_path}") + + + # Save and update + set_progress_and_save(100, result_container, json_file_path) + + print(f'Files saved as {json_file_path}') + + +# ---------------------------------------------------------------- +# ------- helper methods ------- +# ---------------------------------------------------------------- + +def export_physical_surface_to_ply(surface_name: str, entity_tags, ply_path: str | Path) -> Path: + """Export only the triangular mesh faces of one physical surface to PLY.""" + ply_path = Path(ply_path) + node_tags_all, coords_all, _ = gmsh.model.mesh.getNodes() + coords_all = coords_all.reshape((len(node_tags_all), 3)) + node_tag_to_index = { + int(node_tag): index for index, node_tag in enumerate(node_tags_all) + } + + surface_faces = [] + for dim, entity_tag in entity_tags: + element_types, _, element_node_tags = gmsh.model.mesh.getElements( + dim, entity_tag + ) + for element_type, node_tags in zip(element_types, element_node_tags): + ( + element_name, + element_dim, + _order, + num_nodes, + _local_node_coord, + _num_primary_nodes, + ) = gmsh.model.mesh.getElementProperties(element_type) + if element_dim != 2 or num_nodes != 3: + continue + + faces = np.asarray(node_tags, dtype=np.int64).reshape((-1, num_nodes)) + surface_faces.append(faces) + + if not surface_faces: + raise ValueError( + f"Physical surface '{surface_name}' contains no triangular mesh faces." + ) + + surface_faces = np.concatenate(surface_faces, axis=0) + used_node_tags = np.unique(surface_faces) + used_coords = np.array( + [coords_all[node_tag_to_index[int(node_tag)]] for node_tag in used_node_tags] + ) + compact_node_index = { + int(node_tag): index for index, node_tag in enumerate(used_node_tags) + } + compact_faces = np.vectorize(lambda node_tag: compact_node_index[int(node_tag)])( + surface_faces + ) + + meshio.Mesh( + points=used_coords, + cells=[("triangle", compact_faces)], + ).write(str(ply_path)) + return ply_path + +def create_frequency_spectrum(frequencies: list, values) -> list: + """Create frequency-value tuples for Mitsuba spectrum format. + + Parameters + ---------- + frequencies : list + List of frequency values [125, 250, 500, ...] + values : list or str + Either a list of floats or a comma-separated string of values + + Returns + ------- + list + List of (frequency, value) tuples for Mitsuba spectrum + """ + # Parse values if string + if isinstance(values, str): + vals = [float(val.strip()) for val in values.split(",")] + else: + vals = [float(val) for val in values] + + # Pad with default value (0.1) if not enough values provided + while len(vals) < len(frequencies): + vals.append(0.1) + + return list(zip(frequencies, vals[:len(frequencies)])) + +def create_acoustic_scene( + msh_file, + frequencies, + absorption_map, + source_position, + source_intensity, + scattering_map, +): + """Create an acoustic scene with surfaces from the MSH file. + + Parameters + ---------- + msh_file : str | Path + Path to the MSH file + frequencies : list + List of frequency values + absorption_map : dict + Mapping of surface names to comma-separated absorption values + source_position : list + [x, y, z] source position + source_intensity : float + Source intensity value + scattering_map : list + List of scattering coefficients per frequency band + """ + + scene_dict = { + "type": "scene", + "source": { + "type": "point", + "position": source_position, + "intensity": { + "type": "spectrum", + "value": source_intensity, + }, + }, + } + + # Create frequency-scattering tuples for spectrum + scattering_spectrum = create_frequency_spectrum(frequencies, scattering_map) + + # Extract physical group information from gmsh + gmsh.initialize() + gmsh.open(str(msh_file)) + surfaces = gmsh.model.getPhysicalGroups(2) + + # For each surface, create a separate PLY file + for i, (dim, tag) in enumerate(surfaces): + surface_name = gmsh.model.getPhysicalName(dim, tag) + absorption_str = absorption_map.get(surface_name, "0.1, 0.1, 0.1, 0.1, 0.1, 0.1") + entity_tags = gmsh.model.getEntitiesForPhysicalName(surface_name) + + # Create output file for this surface + ply_file = f"surface_{surface_name}.ply" + + export_physical_surface_to_ply(surface_name, entity_tags, ply_file) + + # Create frequency-absorption tuples for spectrum + absorption_spectrum = create_frequency_spectrum(frequencies, absorption_str) + + scene_dict[f"shape_{i}"] = { + "type": "ply", + "filename": ply_file, + "bsdf": { + "type": "acousticbsdf", + "absorption": { + "type": "spectrum", + "value": absorption_spectrum, + }, + "scattering": { + "type": "spectrum", + "value": scattering_spectrum, + }, + }, + } + + gmsh.finalize() # Finalize AFTER the loop + + return mi.load_dict(scene_dict) + +def set_progress_and_save(percentage, result_container, json_file_path): + result = result_container["results"][0] + result["percentage"] = percentage + result.pop("percentages", None) + # Save the updated JSON + with open(json_file_path, "w") as json_output: + json_output.write(json.dumps(result_container, indent=4, allow_nan=False)) + + + +def prepare_result_responses(result_container: dict) -> None: + """Reset response result containers to the CHORAS EDC/parameter format.""" + for response in result_container["results"][0]["responses"]: + response["receiverResults"] = [] + response["parameters"] = {key: [] for key in RESULT_PARAMETER_KEYS} + +def finite_array(values, nan=0.0, posinf=0.0, neginf=0.0) -> np.ndarray: + return np.nan_to_num( + np.asarray(values, dtype=float), + nan=nan, + posinf=posinf, + neginf=neginf, + ) + +def json_scalar(value): + """Convert numpy scalar values to regular JSON scalar values.""" + if isinstance(value, np.generic): + return value.item() + return value + +def edc_to_db(edc: pf.TimeData, dynamic_range_db: float) -> tuple: + """Convert an EDC to dB and clip it to the requested dynamic range. + + Returns (edc_db, edc_db_absolute) where edc_db is normalized to peak=0dB + and edc_db_absolute uses absolute dB reference for SPL calculation. + """ + edc_energy = np.nan_to_num( + np.asarray(edc.time, dtype=float), + nan=0.0, + posinf=0.0, + neginf=0.0, + ) + + # For pyrato compatibility: keep energy form but ensure minimum values + # to avoid log(0) issues in pyrato functions + edc_energy_safe = np.maximum(edc_energy, 1e-15) + + # Calculate absolute dB (referenced to 1e-12 W/m²) + edc_db_absolute = 10 * np.log10(edc_energy_safe / 1e-12) + + # Normalize EDC to peak = 0 dB for display + peak_energy = np.max(edc_energy_safe, axis=-1, keepdims=True) + edc_normalized = edc_energy_safe / peak_energy + edc_db_normalized = 10 * np.log10(edc_normalized) + + # Clip to dynamic range + limit = -dynamic_range_db + edc_db_normalized = np.maximum(edc_db_normalized, limit) + + return edc_db_normalized, edc_db_absolute + +def store_receiver_results( + response: dict, + edc_db: np.ndarray, + times: list, + frequencies: np.ndarray, +) -> None: + """Store one EDC result object per frequency band.""" + # Ensure all inputs are proper numeric types + edc_db = np.asarray(edc_db, dtype=np.float64) + frequencies = np.asarray(frequencies, dtype=np.float64) + + response["receiverResults"] = [ + { + "data": finite_array(edc_db[i_frequency], nan=0.0, posinf=0.0, neginf=0.0).tolist(), + "t": times if isinstance(times, list) else times.tolist(), + "frequency": json_scalar(frequencies[i_frequency]), + "type": "edc", + } + for i_frequency in range(len(frequencies)) + ] + +def etc_to_edc( + etc: pf.TimeData, + lower_frequency_cutoffs: np.ndarray, + upper_frequency_cutoffs: np.ndarray, +) -> pf.TimeData: + """Convert energy time curve into energy decay curve. + + Parameters + ---------- + etc : pf.TimeData + energy time curve of cshape (..., n_bands). + lower_frequency_cutoffs : np.ndarray + lower cutoff frequencies from the frequency bands of shape (n_bands). + upper_frequency_cutoffs : np.ndarray + lower cutoff frequencies from the frequency bands of shape (n_bands). + + Results + ------- + edc : pf.TimeData + Resulting energy decay curve. + """ + full_frequency_range = np.max(upper_frequency_cutoffs) - np.min( + lower_frequency_cutoffs + ) + bandwidth = upper_frequency_cutoffs - lower_frequency_cutoffs + etc_eq = etc * (bandwidth / full_frequency_range) + edc = pyrato.edc.schroeder_integration(etc_eq, is_energy=True) + return edc + +def center_time(edc: pf.TimeData) -> np.ndarray: + edc_time = np.asarray(edc.time) + energy = np.empty_like(edc_time) + energy[..., :-1] = edc_time[..., :-1] - edc_time[..., 1:] + energy[..., -1] = edc_time[..., -1] + np.maximum(energy, 0, out=energy) + + denominator = np.sum(energy, axis=-1) + numerator = np.sum(energy * np.asarray(edc.times), axis=-1) + return np.divide( + numerator, + denominator, + out=np.zeros_like(denominator, dtype=float), + where=denominator > 0, + ) + +def calculate_reverberation_time_from_db(edc_db: np.ndarray, times: np.ndarray, db_start: float, db_end: float) -> np.ndarray: + """Calculate reverberation time from dB decay curve. + + Fit a line between db_start and db_end points and extrapolate to -60 dB. + + Parameters + ---------- + edc_db : np.ndarray + EDC in dB (shape: n_bands x n_times) + times : np.ndarray + Time array (shape: n_times) + db_start : float + Starting dB level for fitting (e.g., -5) + db_end : float + Ending dB level for fitting (e.g., -25 for T20, -35 for T30, -10 for EDT) + + Returns + ------- + np.ndarray + Reverberation times for each band (shape: n_bands) + """ + n_bands = edc_db.shape[0] + rt = np.zeros(n_bands) + + for i_band in range(n_bands): + decay = edc_db[i_band, :] + + # Find indices where decay is within [db_start, db_end] + mask = (decay >= db_end) & (decay <= db_start) + + if np.sum(mask) < 2: + # Not enough points to fit + rt[i_band] = 0.0 + continue + + # Fit a line to the decay in this range + valid_times = times[mask] + valid_decay = decay[mask] + + # Linear regression: decay = a * time + b + coeffs = np.polyfit(valid_times, valid_decay, 1) + slope = coeffs[0] # dB/second + + if slope >= 0: + # Not decaying properly + rt[i_band] = 0.0 + continue + + # Extrapolate to -60 dB: -60 = slope * t + b + # t = (-60 - b) / slope + intercept = coeffs[1] + t_60 = (-60.0 - intercept) / slope + + # Ensure positive and reasonable value + if t_60 > 0 and t_60 < 100: # Max 100 seconds is reasonable for indoor rooms + rt[i_band] = t_60 + else: + rt[i_band] = 0.0 + + return rt + + +def calculate_and_store_acoustic_parameters( + edc: pf.TimeData, + edc_db_absolute: np.ndarray, + result_container: dict, + i_rec: int) -> None: + """Calculate and store acoustic parameters for a receiver. + + Parameters + ---------- + edc : pf.TimeData + Energy decay curve (in energy domain, not dB). + edc_db_absolute : np.ndarray + Energy decay curve in absolute dB (for SPL at t=0). + result_container : dict + Result container to store parameters. + i_rec : int + Receiver index. + """ + response = result_container["results"][0]["responses"][i_rec] + parameters = response.setdefault( + "parameters", {key: [] for key in ["t20", "t30", "c80", "d50", "ts", "spl_t0_freq", "edt"]} + ) + + times = np.asarray(edc.times) + + try: + t20 = calculate_reverberation_time_from_db(edc_db_absolute, times, db_start=-5.0, db_end=-25.0) + parameters["t20"] = t20.tolist() + except Exception as e: + parameters["t20"] = [0.0] * len(edc.time) + + try: + t30 = calculate_reverberation_time_from_db(edc_db_absolute, times, db_start=-5.0, db_end=-35.0) + parameters["t30"] = t30.tolist() + except Exception as e: + parameters["t30"] = [0.0] * len(edc.time) + + try: + c80 = pyrato.parameters.clarity(edc, 80) + c80 = finite_array(c80, nan=0.0, neginf=0.0, posinf=0.0) + parameters["c80"] = c80.tolist() + except Exception as e: + parameters["c80"] = [0.0] * len(edc.time) + + try: + d50 = pyrato.parameters.definition(edc, 50) * 100 + d50 = finite_array(d50, nan=0.0, neginf=0.0, posinf=0.0) + parameters["d50"] = d50.tolist() + except Exception as e: + parameters["d50"] = [0.0] * len(edc.time) + + try: + ts = center_time(edc) * 1000 + ts = finite_array(ts, nan=0.0, neginf=0.0, posinf=0.0) + parameters["ts"] = ts.tolist() + except Exception as e: + parameters["ts"] = [0.0] * len(edc.time) + + try: + spl = edc_db_absolute[..., 0] + parameters["spl_t0_freq"] = finite_array(spl, nan=0.0, neginf=0.0, posinf=0.0).tolist() + except Exception as e: + parameters["spl_t0_freq"] = [0.0] * len(edc.time) + + try: + edt = calculate_reverberation_time_from_db(edc_db_absolute, times, db_start=0.0, db_end=-10.0) + parameters["edt"] = edt.tolist() + except Exception as e: + parameters["edt"] = [0.0] * len(edc.time) diff --git a/misuka_method/pyproject.toml b/misuka_method/pyproject.toml new file mode 100644 index 0000000..432ca08 --- /dev/null +++ b/misuka_method/pyproject.toml @@ -0,0 +1,86 @@ +[project] +name = "misuka_interface" +version = "0.1.0" +description = "" +requires-python = ">=3.9,<3.14" +authors = [ + { name = "Tobias Jüterbock", email = "a.jueterbock@tu-berlin.de" }, +] +keywords = [ + + "acoustic simulation", + + "geometrical acoustics", + + "open-source", + + "acoustic path tracing", + +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + + "Programming Language :: Python :: 3.9", + + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + + "Programming Language :: Python :: 3.14", + +] +dependencies = [ + "numpy>=1.23.0", + "requests", + "gmsh", + "meshio", + "pyfar", + "pyrato", + "pandas", +] + +[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 = ["misuka_interface[deploy,tests,docs]"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["misuka_interface"] + +[project.scripts] +misuka_interface = "misuka_interface:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/misuka_method/tests/conftest.py b/misuka_method/tests/conftest.py new file mode 100644 index 0000000..846e957 --- /dev/null +++ b/misuka_method/tests/conftest.py @@ -0,0 +1,73 @@ +"""Pytest configuration and fixtures.""" +import sys +import json +import os +import pytest +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +# Add parent directory to Python path so imports work +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +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_misuka.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 which can be reused to + write results to.""" + input_tmp = load_default_input_data() + geo_file = os.path.join( + default_data_path(), "test_room_misuka.geo") + msh_file = os.path.join( + default_data_path(), "test_room_misuka.msh") + + with tempfile.TemporaryDirectory() as tmpdirname: + tmp_path = Path(tmpdirname) / "temp_input.json" + shutil.copy(geo_file, Path(tmpdirname)) + shutil.copy(msh_file, Path(tmpdirname)) + input_tmp['geo_path'] = os.path.join( + tmpdirname, "test_room_misuka.geo") + input_tmp['msh_path'] = os.path.join( + tmpdirname, "test_room_misuka.msh") + with open(tmp_path, 'w') as f: + json.dump(input_tmp, f) + + yield str(tmp_path) + + return str(tmp_path) + + +@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("misuka_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/misuka_method/tests/test_definition.py b/misuka_method/tests/test_definition.py new file mode 100644 index 0000000..58b9c99 --- /dev/null +++ b/misuka_method/tests/test_definition.py @@ -0,0 +1,37 @@ +"""Test the SimulationMethod base class for misuka method.""" +import pytest +from unittest.mock import patch +from pathlib import Path + +from misuka_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/misuka_method/tests/test_fixtures.py b/misuka_method/tests/test_fixtures.py new file mode 100644 index 0000000..2a634b7 --- /dev/null +++ b/misuka_method/tests/test_fixtures.py @@ -0,0 +1,23 @@ +"""Test fixtures for misuka 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 + assert "sourceX" in default_input_data["results"][0] + assert "sourceY" in default_input_data["results"][0] + assert "sourceZ" in default_input_data["results"][0] + assert "responses" in default_input_data["results"][0] + assert len(default_input_data["results"][0]["responses"]) > 0 + assert "geo_path" in default_input_data + assert "msh_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/misuka_method/tests/test_input_misuka.json b/misuka_method/tests/test_input_misuka.json new file mode 100644 index 0000000..de67c8f --- /dev/null +++ b/misuka_method/tests/test_input_misuka.json @@ -0,0 +1,63 @@ +{ + "geo_path": "test_room_misuka.geo", + "msh_path": "test_room_misuka.msh", + "absorption_coefficients": { + "floor": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15", + "wall1": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15", + "ceiling": "0.4, 0.5, 0.6, 0.7, 0.8, 0.9", + "wall2": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15", + "wall3": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15", + "wall4": "0.05, 0.05, 0.08, 0.1, 0.12, 0.15" + }, + "simulationSettings": { + "rays_per_frequency": 1048576, + "max_time": 0.1, + "sampling_rate": 10000, + "sound_power_W": 1, + "speed_of_sound": 343, + "dynamic_range_db": 100, + "scattering_125": 0.1, + "scattering_250": 0.1, + "scattering_500": 0.1, + "scattering_1000": 0.1, + "scattering_2000": 0.1, + "scattering_4000": 0.1 + }, + "results": [ + { + "simulationMethodId": 1, + "resultType": "IR", + "simulationId": 1, + "sourceX": 1.0, + "sourceY": 2.0, + "sourceZ": 1.5, + "frequencies": [ + 125, + 250, + 500, + 1000, + 2000, + 4000 + ], + "percentage": 0, + "responses": [ + { + "responseId": 1, + "x": 5.0, + "y": 3.5, + "z": 1.5, + "parameters": { + "edt": [], + "t20": [], + "t30": [], + "c80": [], + "d50": [], + "ts": [], + "spl_t0_freq": [] + }, + "receiverResults": [] + } + ] + } + ] +} diff --git a/misuka_method/tests/test_misuka_cli.py b/misuka_method/tests/test_misuka_cli.py new file mode 100644 index 0000000..491411b --- /dev/null +++ b/misuka_method/tests/test_misuka_cli.py @@ -0,0 +1,330 @@ +"""Test the misuka method CLI.""" +import os +import json +import pytest +import numpy as np +from pathlib import Path +from unittest.mock import patch + +from misuka_interface import main, misukaMethod + + +class TestMisukaIntegration: + """Test suite for misuka integration.""" + + def test_parse_absorption_from_json(self): + """Test parsing absorption coefficients from JSON.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + frequencies = [100, 500, 20000] + absorption_coefficients = { + "floor": "0.1, 0.5, 0.1", + "wall": "0.2, 0.4, 0.3", + "ceiling": "0.15, 0.25, 0.35" + } + + surfaces, absorption_values = method.parse_absorption_from_json( + frequencies, + absorption_coefficients + ) + + assert len(surfaces) == 3 + assert surfaces == ["floor", "wall", "ceiling"] + assert len(absorption_values) == 3 + + # Check floor absorption values + assert absorption_values[0] == [(100, 0.1), (500, 0.5), (20000, 0.1)] + + # Check wall absorption values + assert absorption_values[1] == [(100, 0.2), (500, 0.4), (20000, 0.3)] + + def test_convert_msh_to_obj(self, tmp_path): + """Test MSH to OBJ conversion method exists.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + assert hasattr(method, 'convert_msh_to_obj') + assert callable(method.convert_msh_to_obj) + + def test_misuka_method_initialization(self, create_temporary_input_file): + """Test misukaMethod initialization.""" + method = misukaMethod(input_json_path=create_temporary_input_file) + + assert method is not None + assert method.input_json_path == create_temporary_input_file + assert hasattr(method, 'run_simulation') + assert hasattr(method, '_misuka_method') + assert hasattr(method, 'parse_absorption_from_json') + + def test_absorption_parsing_with_whitespace(self): + """Test absorption parsing handles whitespace correctly.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + frequencies = [100, 500, 1000] + absorption_coefficients = { + "room": "0.1 , 0.5 , 0.3" # Extra spaces + } + + surfaces, absorption_values = method.parse_absorption_from_json( + frequencies, + absorption_coefficients + ) + + assert absorption_values[0] == [(100, 0.1), (500, 0.5), (1000, 0.3)] + + def test_absorption_parsing_empty_dict(self): + """Test absorption parsing with empty dictionary.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + frequencies = [100, 500] + absorption_coefficients = {} + + surfaces, absorption_values = method.parse_absorption_from_json( + frequencies, + absorption_coefficients + ) + + assert len(surfaces) == 0 + assert len(absorption_values) == 0 + + def test_absorption_values_all_numeric(self): + """Test that parsed absorption values are numeric.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + frequencies = [100, 250, 500, 1000, 2000] + absorption_coefficients = { + "floor": "0.6, 0.69, 0.71, 0.7, 0.63", + "wall": "0.5, 0.55, 0.60, 0.62, 0.61" + } + + surfaces, absorption_values = method.parse_absorption_from_json( + frequencies, + absorption_coefficients + ) + + # Verify all values are floats + for surface_absorptions in absorption_values: + for freq, absorption in surface_absorptions: + assert isinstance(freq, (int, float)) + assert isinstance(absorption, float) + assert 0 <= absorption <= 1.0 + + def test_misuka_method_has_required_methods(self): + """Test that misukaMethod has all required methods.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + required_methods = [ + 'run_simulation', + '_misuka_method', + 'parse_absorption_from_json', + 'convert_msh_to_obj' + ] + + for method_name in required_methods: + assert hasattr(method, method_name), f"Missing method: {method_name}" + assert callable(getattr(method, method_name)) + + def test_json_loading_with_absorption_coefficients(self, create_temporary_input_file): + """Test loading JSON with absorption coefficients.""" + with open(create_temporary_input_file, 'r') as f: + data = json.load(f) + + # Verify required fields exist + assert "simulationSettings" in data + assert "results" in data + assert "msh_path" in data + + # Verify absorption coefficients can be accessed + result = data["results"][0] + assert "sourceX" in result + assert "sourceY" in result + assert "sourceZ" in result + assert "frequencies" in result + + def test_absorption_coefficient_format_conversion(self): + """Test conversion of absorption format matches Mitsuba spec.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + frequencies = [100, 500, 20000] + absorption_coefficients = { + "floor": "0.1, 0.5, 0.1" + } + + surfaces, absorption_values = method.parse_absorption_from_json( + frequencies, + absorption_coefficients + ) + + # Should match Mitsuba format: [(freq, value), ...] + expected_format = [(100, 0.1), (500, 0.5), (20000, 0.1)] + assert absorption_values[0] == expected_format + + def test_multiple_surfaces_parsing(self): + """Test parsing multiple surfaces with different absorption profiles.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + frequencies = [100, 500, 1000, 2000, 5000, 10000] + absorption_coefficients = { + "highly_absorbent": "0.9, 0.85, 0.8, 0.75, 0.7, 0.65", + "slightly_absorbent": "0.1, 0.12, 0.15, 0.18, 0.2, 0.22", + "neutral": "0.5, 0.5, 0.5, 0.5, 0.5, 0.5" + } + + surfaces, absorption_values = method.parse_absorption_from_json( + frequencies, + absorption_coefficients + ) + + assert len(surfaces) == 3 + assert len(absorption_values[0]) == 6 + + # Verify highly absorbent has high values + for freq, absorption in absorption_values[0]: + assert absorption >= 0.65 + + # Verify slightly absorbent has low values + for freq, absorption in absorption_values[1]: + assert absorption <= 0.22 + + def test_frequency_absorption_tuple_structure(self): + """Test that output tuples have correct structure.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + frequencies = [125, 250, 500] + absorption_coefficients = {"wall": "0.2, 0.3, 0.4"} + + surfaces, absorption_values = method.parse_absorption_from_json( + frequencies, + absorption_coefficients + ) + + absorption_array = absorption_values[0] + + # Each element should be a tuple of (freq, absorption) + assert len(absorption_array) == 3 + for item in absorption_array: + assert isinstance(item, tuple) + assert len(item) == 2 + freq, absorption = item + assert freq in frequencies + assert 0 <= absorption <= 1.0 + + def test_absorption_value_range(self): + """Test that absorption values are within valid range [0, 1].""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + frequencies = [100, 500, 20000] + absorption_coefficients = { + "normal": "0.0, 0.5, 1.0", + "mid_range": "0.25, 0.5, 0.75" + } + + surfaces, absorption_values = method.parse_absorption_from_json( + frequencies, + absorption_coefficients + ) + + for surface_absorptions in absorption_values: + for freq, absorption in surface_absorptions: + assert 0.0 <= absorption <= 1.0, \ + f"Absorption value {absorption} outside valid range [0, 1]" + + def test_surface_name_preservation(self): + """Test that surface names are preserved correctly.""" + with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): + method = misukaMethod() + + frequencies = [100, 500] + surface_names = ["floor", "wall1", "wall2", "ceiling", "back_wall"] + absorption_coefficients = { + name: "0.2, 0.3" for name in surface_names + } + + surfaces, absorption_values = method.parse_absorption_from_json( + frequencies, + absorption_coefficients + ) + + # Note: dict order depends on Python version, but all should be present + assert set(surfaces) == set(surface_names) + + +class TestMisukaSceneGeneration: + """Test scene generation for Mitsuba.""" + + def test_scene_dict_structure(self, create_temporary_input_file): + """Test that scene dictionary has correct structure.""" + with open(create_temporary_input_file, 'r') as f: + data = json.load(f) + + # Should have required keys + assert "simulationSettings" in data + assert "results" in data + assert data["simulationSettings"]["speed_of_sound"] == 343 + assert data["simulationSettings"]["max_time"] == 0.1 + + def test_source_receiver_coordinates(self, create_temporary_input_file): + """Test source and receiver coordinate extraction.""" + with open(create_temporary_input_file, 'r') as f: + data = json.load(f) + + result = data["results"][0] + + source_coords = [result["sourceX"], result["sourceY"], result["sourceZ"]] + assert len(source_coords) == 3 + assert all(isinstance(c, (int, float)) for c in source_coords) + + response = result["responses"][0] + receiver_coords = [response["x"], response["y"], response["z"]] + assert len(receiver_coords) == 3 + assert all(isinstance(c, (int, float)) for c in receiver_coords) + + +class TestMisukaOutput: + """Test output generation from misuka simulations.""" + + def test_receiver_results_structure(self, create_temporary_input_file): + """Test that receiver results have correct structure.""" + with open(create_temporary_input_file, 'r') as f: + data = json.load(f) + + response = data["results"][0]["responses"][0] + + # Should have receiverResults key + assert "receiverResults" in response + + +def test_misuka_method_cli(mock_requests_post, create_temporary_input_file): + """Test the misuka method CLI.""" + # Set JSON_PATH environment variable and call main() directly + os.environ["JSON_PATH"] = create_temporary_input_file + main() + + with open(create_temporary_input_file, 'r') as f: + data = json.load(f) + + # Verify results structure + assert "results" in data + assert len(data["results"]) > 0 + + # Verify that requests.post was called (save_results was executed) + mock_requests_post.assert_called_once() + + +def test_misuka_method_cli_missing_json_path(mock_requests_post): + """Test the misuka 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/misuka_method/tests/test_room_misuka.geo b/misuka_method/tests/test_room_misuka.geo new file mode 100644 index 0000000..db900ed --- /dev/null +++ b/misuka_method/tests/test_room_misuka.geo @@ -0,0 +1,51 @@ +Point(1) = { 0.000000, 5.100000, 0.000000, 1.0 }; +Point(2) = { 6.210000, 4.000000, 0.000000, 1.0 }; +Point(3) = { 5.520000, 0.000000, 0.000000, 1.0 }; +Point(4) = { 0.000000, 0.000000, 0.000000, 1.0 }; +Point(5) = { 0.000000, 5.100000, 3.300000, 1.0 }; +Point(6) = { 6.210000, 4.000000, 3.300000, 1.0 }; +Point(7) = { 0.000000, 0.000000, 3.300000, 1.0 }; +Point(8) = { 5.520000, 0.000000, 3.300000, 1.0 }; + +Line(1) = { 1, 2 }; +Line(2) = { 1, 4 }; +Line(3) = { 1, 5 }; +Line(4) = { 2, 3 }; +Line(5) = { 2, 6 }; +Line(6) = { 3, 4 }; +Line(7) = { 3, 8 }; +Line(8) = { 4, 7 }; +Line(9) = { 5, 6 }; +Line(10) = { 5, 7 }; +Line(11) = { 6, 8 }; +Line(12) = { 7, 8 }; + +Line Loop(1) = { 6, -2, 1, 4 }; +Line Loop(2) = { -1, 3, 9, -5 }; +Line Loop(3) = { -9, 10, 12, -11 }; +Line Loop(4) = { -6, 7, -12, -8 }; +Line Loop(5) = { 2, 8, -10, -3 }; +Line Loop(6) = { 11, -7, -4, 5 }; + +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("wall1") = { 2 }; +Physical Surface("ceiling") = { 3 }; +Physical Surface("wall2") = { 4 }; +Physical Surface("wall3") = { 5 }; +Physical Surface("wall4") = { 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 "*"; diff --git a/misuka_method/tests/test_room_misuka.msh b/misuka_method/tests/test_room_misuka.msh new file mode 100644 index 0000000..bbcdc1f --- /dev/null +++ b/misuka_method/tests/test_room_misuka.msh @@ -0,0 +1,2116 @@ +$MeshFormat +4.1 0 8 +$EndMeshFormat +$PhysicalNames +8 +1 8 "default" +2 1 "floor" +2 2 "wall1" +2 3 "ceiling" +2 4 "wall2" +2 5 "wall3" +2 6 "wall4" +3 7 "RoomVolume" +$EndPhysicalNames +$Entities +8 12 6 1 +1 0 5.1 0 0 +2 6.21 4 0 0 +3 5.52 0 0 0 +4 0 0 0 0 +5 0 5.1 3.3 0 +6 6.21 4 3.3 0 +7 0 0 3.3 0 +8 5.52 0 3.3 0 +1 0 4 0 6.21 5.1 0 1 8 2 1 -2 +2 0 0 0 0 5.1 0 1 8 2 1 -4 +3 0 5.1 0 0 5.1 3.3 1 8 2 1 -5 +4 5.52 0 0 6.21 4 0 1 8 2 2 -3 +5 6.21 4 0 6.21 4 3.3 1 8 2 2 -6 +6 0 0 0 5.52 0 0 1 8 2 3 -4 +7 5.52 0 0 5.52 0 3.3 1 8 2 3 -8 +8 0 0 0 0 0 3.3 1 8 2 4 -7 +9 0 4 3.3 6.21 5.1 3.3 1 8 2 5 -6 +10 0 0 3.3 0 5.1 3.3 1 8 2 5 -7 +11 5.52 0 3.3 6.21 4 3.3 1 8 2 6 -8 +12 0 0 3.3 5.52 0 3.3 1 8 2 7 -8 +1 0 0 0 6.21 5.1 0 1 1 4 6 -2 1 4 +2 0 4 0 6.21 5.1 3.3 1 2 4 -1 3 9 -5 +3 0 0 3.3 6.21 5.1 3.3 1 3 4 -9 10 12 -11 +4 0 0 0 5.52 0 3.3 1 4 4 -6 7 -12 -8 +5 0 0 0 0 5.1 3.3 1 5 4 2 8 -10 -3 +6 5.52 0 0 6.21 4 3.3 1 6 4 11 -7 -4 5 +1 0 0 0 6.21 5.1 3.3 1 7 6 1 2 3 4 5 6 +$EndEntities +$Nodes +27 286 1 286 +0 1 0 1 +1 +0 5.1 0 +0 2 0 1 +2 +6.21 4 0 +0 3 0 1 +3 +5.52 0 0 +0 4 0 1 +4 +0 0 0 +0 5 0 1 +5 +0 5.1 3.3 +0 6 0 1 +6 +6.21 4 3.3 +0 7 0 1 +7 +0 0 3.3 +0 8 0 1 +8 +5.52 0 3.3 +1 1 0 7 +9 +10 +11 +12 +13 +14 +15 +0.7762499999987194 4.962500000000227 0 +1.552499999996365 4.825000000000643 0 +2.32874999999396 4.687500000001069 0 +3.104999999991609 4.550000000001486 0 +3.881249999993475 4.412500000001156 0 +4.657499999996126 4.275000000000686 0 +5.433749999997627 4.13750000000042 0 +1 2 0 5 +16 +17 +18 +19 +20 +0 4.25000000000211 0 +0 3.400000000004221 0 +0 2.550000000006418 0 +0 1.70000000000445 0 +0 0.8500000000019954 0 +1 3 0 3 +21 +22 +23 +0 5.1 0.8249999999980804 +0 5.1 1.649999999995743 +0 5.1 2.474999999997828 +1 4 0 4 +24 +25 +26 +27 +6.072000000000003 3.200000000000018 0 +5.93400000000094 2.400000000005448 0 +5.79600000000112 1.600000000006494 0 +5.658000000000555 0.8000000000032195 0 +1 5 0 3 +28 +29 +30 +6.21 4 0.8249999999980804 +6.21 4 1.649999999995743 +6.21 4 2.474999999997828 +1 6 0 6 +31 +32 +33 +34 +35 +36 +4.731428571430031 0 0 +3.942857142860572 0 0 +3.154285714291769 0 0 +2.365714285720427 0 0 +1.577142857146942 0 0 +0.7885714285734089 0 0 +1 7 0 3 +37 +38 +39 +5.52 0 0.8249999999980804 +5.52 0 1.649999999995743 +5.52 0 2.474999999997828 +1 8 0 3 +40 +41 +42 +0 0 0.8249999999980804 +0 0 1.649999999995743 +0 0 2.474999999997828 +1 9 0 7 +43 +44 +45 +46 +47 +48 +49 +0.7762499999987194 4.962500000000227 3.3 +1.552499999996365 4.825000000000643 3.3 +2.32874999999396 4.687500000001069 3.3 +3.104999999991609 4.550000000001486 3.3 +3.881249999993475 4.412500000001156 3.3 +4.657499999996126 4.275000000000686 3.3 +5.433749999997627 4.13750000000042 3.3 +1 10 0 5 +50 +51 +52 +53 +54 +0 4.25000000000211 3.3 +0 3.400000000004221 3.3 +0 2.550000000006418 3.3 +0 1.70000000000445 3.3 +0 0.8500000000019954 3.3 +1 11 0 4 +55 +56 +57 +58 +6.072000000000003 3.200000000000018 3.3 +5.93400000000094 2.400000000005448 3.3 +5.79600000000112 1.600000000006494 3.3 +5.658000000000555 0.8000000000032195 3.3 +1 12 0 6 +59 +60 +61 +62 +63 +64 +0.7885714285697485 0 3.3 +1.577142857138913 0 3.3 +2.365714285708011 0 3.3 +3.154285714279408 0 3.3 +3.942857142852948 0 3.3 +4.731428571426536 0 3.3 +2 1 0 41 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +2.702246367247137 0.6278844212046903 0 +2.606042587526941 3.994689295288387 0 +4.135501471387311 3.688925114670039 0 +0.7255309746239729 2.136165699760757 0 +4.902674462051395 1.252630494334912 0 +1.20047813245132 0.6104954181138812 0 +5.269159873184588 2.92986357870479 0 +0.6515713233657827 3.679057086664729 0 +4.373071687051047 0.6141996761085335 0 +0.713879408774184 2.951516656175773 0 +1.445202806081749 2.549401318616725 0 +1.4661307299318 1.769306549349747 0 +2.160856254496739 2.13997549312561 0 +2.153388815033426 2.910769260569127 0 +2.858826860697639 2.527420539366687 0 +2.883103284483341 1.78179324994803 0 +3.555295546448354 2.151167921494717 0 +3.55538599452136 1.347703574723207 0 +3.642404054441497 3.018695082077782 0 +4.143451559114237 2.528430844542227 0 +1.445042169812366 3.407415605569577 0 +5.161746439014879 2.08728091321036 0 +2.255048869344842 1.323748656822932 0 +3.374880227047213 3.823234457155831 0 +4.298587786259919 1.843190706691889 0 +1.830781621157491 4.101449621592511 0 +0.7683875600501459 1.274723232763478 0 +4.91237749292892 3.6129436942932 0 +1.971428571433684 0.6229082830041432 0 +3.548622212624968 0.7121086994479576 0 +2.79381059071493 3.305830370074237 0 +5.019055347763423 0.5854986388992025 0 +1.051585836382507 4.255182029687647 0 +5.540980365877392 3.531097221289941 0 +4.488329658859804 3.061315687642965 0 +0.5584260501118248 0.5651366727068572 0 +2.127320999542864 3.560173585987611 0 +1.577361304461205 1.127741821563272 0 +4.672255063286686 2.490016346158446 0 +3.0012588298784 1.181109857586796 0 +4.135668428501737 1.1539666302613 0 +2 2 0 31 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +1.983496193715434 4.748656068746058 2.642353555724199 +3.524861074614583 4.475628473095646 0.6469532362317842 +4.170689869483857 4.36123045790141 2.617641854477065 +1.981024688072751 4.749093855574875 0.6368379189250895 +5.529889823872385 4.120470401568499 1.272491748558482 +0.6444333288960226 4.985849168794585 2.05927767325974 +5.570072844942467 4.11335263616156 2.064242586280364 +4.893356454539051 4.233221884059105 1.674949874422897 +4.924879178751135 4.227638148691425 0.7701082756560119 +4.135560251077941 4.367453095622265 1.249948278105242 +0.6343656858248634 4.987632487212987 1.24104712499307 +1.20202905091593 4.88708020032085 1.649139535744951 +3.432667366830957 4.491959081559734 2.622829972307688 +2.728897627109878 4.616620388112582 0.6492714764780272 +3.10499999999319 4.550000000001206 1.365434340236661 +2.703261743501124 4.621161365885469 2.638722571453489 +2.343935826619011 4.684810079020787 1.922301438399384 +3.688841588058679 4.446582005335822 1.925311476061128 +4.96989686064763 4.219664002139711 2.553109812715744 +1.202302782053134 4.887031713323921 0.7163966354029625 +1.210159247866509 4.885640068815916 2.561144913134303 +2.385376420429541 4.677469555157408 1.159758465332143 +3.054741305000592 4.558902506360604 2.094919959691671 +4.224810100886651 4.351643943482236 0.5334019579986077 +4.312565322594148 4.336099540281229 1.991227115787539 +5.618616525137388 4.104753916642331 0.6097483995752218 +0.5776833462350431 4.997672837220845 2.70354663497231 +0.5762063749332704 4.997934458546442 0.5950200657474403 +5.63404965774177 4.102020189449928 2.705228396391119 +1.723999555027303 4.794621656919479 2.019411175964083 +1.795467360471973 4.78196230329804 1.314728373340517 +2 3 0 41 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +2.702246367239102 0.627884421204691 3.3 +2.606042587526935 3.994689295288329 3.3 +4.135501471387224 3.688925114669808 3.3 +0.725530974623923 2.136165699760805 3.3 +4.902674462049536 1.252630494335985 3.3 +1.200478132446673 0.610495418113386 3.3 +5.269159873184508 2.929863578704726 3.3 +0.6515713233657568 3.679057086664713 3.3 +4.373071687046156 0.6141996761093222 3.3 +0.7138794087741396 2.951516656175771 3.3 +1.445202806081554 2.549401318616729 3.3 +1.466130729931171 1.769306549349424 3.3 +2.160856254496093 2.13997549312568 3.3 +2.15338881503317 2.910769260569019 3.3 +2.858826860697112 2.527420539366371 3.3 +2.883103284482265 1.781793249947509 3.3 +3.555295546447529 2.151167921494006 3.3 +3.555385994520122 1.347703574722567 3.3 +3.64240405444118 3.018695082077328 3.3 +4.143451559113692 2.528430844541685 3.3 +1.445042169812269 3.407415605569536 3.3 +5.161746439014308 2.087280913210382 3.3 +2.255048869341225 1.323748656822352 3.3 +3.374880227047138 3.823234457155672 3.3 +4.298587786258741 1.843190706691747 3.3 +1.830781621157481 4.101449621592469 3.3 +0.7683875600489698 1.274723232762874 3.3 +4.912377492928875 3.61294369429304 3.3 +1.971428571423462 0.6229082830067182 3.3 +3.548622212619272 0.7121086994479118 3.3 +2.793810590714735 3.30583037007404 3.3 +5.019055347760613 0.5854986389009575 3.3 +1.051585836382491 4.255182029687636 3.3 +5.540980365877386 3.531097221289929 3.3 +4.488329658859553 3.061315687642745 3.3 +0.5584260501090383 0.5651366727055279 3.3 +2.127320999542876 3.560173585987518 3.3 +1.577361304458576 1.127741821560872 3.3 +4.672255063286161 2.490016346158257 3.3 +3.00125882987532 1.181109857585027 3.3 +4.135668428498765 1.153966630261506 3.3 +2 4 0 28 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +3.505459795894285 0 0.658032377106032 +2.01454020410447 0 2.641967622893278 +3.507053838499056 0 2.663309771093026 +2.012946161501398 0 0.6366902289065148 +4.865401979309599 0 2.05921375528795 +0.6545980206904399 0 1.24078624470752 +4.875602516131682 0 1.241094456535543 +4.298871526490614 0 1.651488430344355 +0.6443974838671406 0 2.058905543460367 +1.221128473508714 0 1.648511569653169 +2.760265673763017 0 2.648836058929298 +2.384072622735947 0 1.92338884153312 +2.75973432623673 0 0.6511639410704287 +3.136610066657712 0 1.376210827548336 +1.22738113669884 0 2.560606383438317 +4.292618863299248 0 0.7393936165603563 +4.296350786159673 0 2.584228126142046 +1.223649213841326 0 0.715771873856837 +2.417904882314414 0 1.162541000761508 +3.102368193443461 0 2.137298866870632 +0.5869333018814119 0 0.5966410707398072 +4.933066698120168 0 2.703358929260585 +4.934555941642836 0 0.5952175860213638 +0.5854440583546006 0 2.704782413979891 +1.751447634802106 0 2.019299358032363 +3.768649892252999 0 1.280643451835007 +1.824039994209592 0 1.315095912873981 +3.696085397720391 0 1.98483055695618 +2 5 0 22 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +0 2.975000000005319 0.7361215932148703 +0 2.133874682074826 2.613113855916287 +0 3.817679962353616 2.61760765757847 +0 1.281886275606401 0.6820170777442269 +0 0.8612986076400723 2.035771238118329 +0 4.238698811323031 1.264060753882836 +0 2.125794453959565 0.6807297793938323 +0 1.690398848941836 1.432404491573377 +0 2.9752591074082 2.582039386289344 +0 3.563364762682421 1.92085905061594 +0 2.57296682209647 1.863891573970136 +0 1.309795077452145 2.674067092312445 +0 3.788013766143618 0.6555925894110306 +0 4.405657901755993 2.027635791990685 +0 0.7191813219154064 1.239127261499417 +0 4.47542187234981 2.681874050298682 +0 0.6245781276490048 0.6181259496979481 +0 0.6048716833449455 2.740832855243506 +0 4.49481492559346 0.5633802410014497 +0 2.551410583650405 1.18354020885267 +0 1.713666807641069 2.123849650378115 +0 3.281575790983544 1.27067762832458 +2 6 0 18 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +5.86504015491614 2.000232782122551 2.753274856069659 +5.867830910298883 2.016411074196425 0.5360075278748611 +5.637260150785848 0.6797689900628874 1.241778785205294 +6.09276160492423 3.320357129995534 1.241630517347858 +6.093189791309344 3.322839369909241 2.057600205069194 +5.987287937489962 2.708915579651955 1.640940440356776 +5.636903658445092 0.6777023677976404 2.058585057068526 +5.74749001496069 1.318782695424291 1.641218812814056 +5.992924077649551 2.741588855939429 2.570447607394167 +5.737509580613327 1.260925105004799 2.570866003043504 +5.73608444090115 1.252663425513916 0.7028856333450937 +5.992997453982559 2.742014225985852 0.7398057616850354 +5.866671077855663 2.009687407858918 2.007813954164472 +6.107400876806572 3.405222474240992 2.6930046065353 +6.107550781131361 3.406091484819482 0.6061276416337821 +5.622449218869113 0.5939085151832642 2.693872358363222 +5.616606147060705 0.5600356351345229 0.5688456716878525 +5.875832781331978 2.062798732359293 1.289268727873359 +3 1 0 41 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +3.982748395016723 2.131866902494136 1.649999999999908 +1.905001571868004 2.522929832411618 1.649999999999942 +2.783809050303439 1.379359660610481 1.894049905953509 +3.105235241577535 3.18471758150762 1.886406261975826 +1.447295988181111 1.305498849181051 1.97708543851478 +4.523319666724902 1.161399387875922 1.153392657073833 +1.87102264981235 3.605615646780535 1.160033125701817 +4.843491511925335 2.86040094787313 1.159889058803643 +1.757757297908341 3.70957514665298 2.261620215216739 +3.974032600267527 1.102001581995885 2.162311504171539 +4.256596207974636 3.167131397587323 2.14716125977807 +2.93509507365237 2.280750044950089 1.189456430932815 +3.42563054064633 1.031436021518221 1.058711176608252 +2.079698879421366 1.066651901090252 1.078148983359485 +1.088438455019048 1.937812665252411 1.078967168932442 +1.046797986343716 2.866301854554071 2.251872303608898 +4.856350181630246 2.280809082357493 2.260543890093401 +3.619199073662395 3.457726940313969 0.9817434735267551 +3.191764896308864 2.239857304993485 2.324304096386526 +1.021657408142361 2.991891967645576 1.011780356152358 +0.9553033612611703 3.853164463551219 1.604038917597632 +5.185592196431918 3.287766957375741 2.396163653348566 +1.066049658648649 0.9216779577124237 1.115851322635183 +2.111357440549796 1.89801080086422 2.362239546773829 +2.170233150211595 0.9018085990384976 2.423753598319347 +2.325974508817458 2.962854438232422 2.372499034842639 +3.75669844547966 2.484280640661297 0.8322020639774979 +3.171670189850378 0.826084207416367 2.489813911820977 +4.874400316607696 1.364296427022081 2.012209510457316 +2.711173798751009 3.187294244662575 1.023782385116788 +2.634843387789958 3.808749344129123 2.298460373602619 +0.861830452322895 0.8372499722850986 2.48890077077935 +2.096277343598294 1.983359873401345 0.9156131131644956 +4.560917135263096 2.035107710641359 0.8624320557041605 +0.8968064261381468 1.946977374457191 2.393097558816704 +4.033500082767937 2.210167091732712 2.506719542441852 +3.586321646640657 3.639902497162266 2.465755143136407 +1.847800900905321 2.810200118357459 0.8083406404637883 +2.758573749830245 1.445335653420554 0.7596196862816191 +4.507730337136908 3.485203150259042 0.7192062927872336 +0.9071392624803817 3.702990021572242 2.464111603443851 +$EndNodes +$Elements +19 1448 1 1448 +1 1 1 8 +1 1 9 +2 9 10 +3 10 11 +4 11 12 +5 12 13 +6 13 14 +7 14 15 +8 15 2 +1 2 1 6 +9 1 16 +10 16 17 +11 17 18 +12 18 19 +13 19 20 +14 20 4 +1 3 1 4 +15 1 21 +16 21 22 +17 22 23 +18 23 5 +1 4 1 5 +19 2 24 +20 24 25 +21 25 26 +22 26 27 +23 27 3 +1 5 1 4 +24 2 28 +25 28 29 +26 29 30 +27 30 6 +1 6 1 7 +28 3 31 +29 31 32 +30 32 33 +31 33 34 +32 34 35 +33 35 36 +34 36 4 +1 7 1 4 +35 3 37 +36 37 38 +37 38 39 +38 39 8 +1 8 1 4 +39 4 40 +40 40 41 +41 41 42 +42 42 7 +1 9 1 8 +43 5 43 +44 43 44 +45 44 45 +46 45 46 +47 46 47 +48 47 48 +49 48 49 +50 49 6 +1 10 1 6 +51 5 50 +52 50 51 +53 51 52 +54 52 53 +55 53 54 +56 54 7 +1 11 1 5 +57 6 55 +58 55 56 +59 56 57 +60 57 58 +61 58 8 +1 12 1 7 +62 7 59 +63 59 60 +64 60 61 +65 61 62 +66 62 63 +67 63 64 +68 64 8 +2 1 2 106 +69 16 1 9 +70 15 2 98 +71 2 24 98 +72 27 3 96 +73 3 31 96 +74 4 20 100 +75 36 4 100 +76 9 10 97 +77 16 9 97 +78 10 11 90 +79 10 90 97 +80 11 12 66 +81 11 66 90 +82 12 13 88 +83 66 12 88 +84 13 14 67 +85 13 67 88 +86 14 15 92 +87 67 14 92 +88 92 15 98 +89 17 16 72 +90 72 16 97 +91 18 17 74 +92 17 72 74 +93 19 18 68 +94 68 18 74 +95 20 19 91 +96 19 68 91 +97 20 91 100 +98 24 25 71 +99 24 71 98 +100 25 26 86 +101 71 25 86 +102 26 27 69 +103 26 69 86 +104 69 27 96 +105 31 32 73 +106 31 73 96 +107 32 33 94 +108 73 32 94 +109 33 34 65 +110 33 65 94 +111 34 35 93 +112 65 34 93 +113 35 36 70 +114 35 70 93 +115 70 36 100 +116 87 65 93 +117 65 87 104 +118 94 65 104 +119 66 88 95 +120 90 66 101 +121 66 95 101 +122 67 83 88 +123 83 67 99 +124 67 92 99 +125 68 74 75 +126 68 75 76 +127 68 76 91 +128 73 69 96 +129 69 73 105 +130 86 69 89 +131 89 69 105 +132 91 70 100 +133 70 91 102 +134 93 70 102 +135 71 86 103 +136 71 92 98 +137 92 71 99 +138 99 71 103 +139 74 72 85 +140 85 72 97 +141 73 94 105 +142 75 74 85 +143 76 75 77 +144 77 75 78 +145 78 75 85 +146 76 77 87 +147 76 87 102 +148 91 76 102 +149 77 78 79 +150 77 79 80 +151 77 80 87 +152 79 78 95 +153 78 85 101 +154 95 78 101 +155 80 79 81 +156 81 79 83 +157 83 79 95 +158 80 81 82 +159 80 82 104 +160 87 80 104 +161 82 81 89 +162 81 83 84 +163 81 84 89 +164 82 89 105 +165 82 94 104 +166 94 82 105 +167 84 83 99 +168 88 83 95 +169 89 84 103 +170 84 99 103 +171 90 85 97 +172 85 90 101 +173 86 89 103 +174 87 93 102 +2 2 2 84 +175 1 133 9 +176 21 133 1 +177 15 131 2 +178 2 131 28 +179 5 132 23 +180 43 132 5 +181 30 134 6 +182 6 134 49 +183 9 125 10 +184 9 133 125 +185 10 109 11 +186 10 125 109 +187 11 119 12 +188 109 119 11 +189 12 107 13 +190 12 119 107 +191 13 129 14 +192 107 129 13 +193 14 114 15 +194 14 129 114 +195 114 131 15 +196 22 116 21 +197 116 133 21 +198 23 111 22 +199 111 116 22 +200 23 132 111 +201 28 110 29 +202 28 131 110 +203 29 112 30 +204 110 112 29 +205 112 134 30 +206 44 126 43 +207 126 132 43 +208 45 106 44 +209 106 126 44 +210 46 121 45 +211 45 121 106 +212 47 118 46 +213 118 121 46 +214 48 108 47 +215 108 118 47 +216 49 124 48 +217 48 124 108 +218 49 134 124 +219 121 122 106 +220 122 135 106 +221 106 135 126 +222 107 120 115 +223 115 129 107 +224 119 120 107 +225 108 123 118 +226 108 130 123 +227 124 130 108 +228 109 127 119 +229 125 136 109 +230 109 136 127 +231 110 113 112 +232 110 114 113 +233 110 131 114 +234 111 117 116 +235 111 126 117 +236 111 132 126 +237 113 124 112 +238 124 134 112 +239 114 115 113 +240 115 130 113 +241 113 130 124 +242 114 129 115 +243 120 123 115 +244 123 130 115 +245 117 125 116 +246 125 133 116 +247 117 136 125 +248 126 135 117 +249 135 136 117 +250 118 128 121 +251 123 128 118 +252 119 127 120 +253 120 127 122 +254 122 128 120 +255 120 128 123 +256 121 128 122 +257 127 136 122 +258 122 136 135 +2 3 2 106 +259 43 5 50 +260 49 170 6 +261 6 170 55 +262 7 172 54 +263 59 172 7 +264 58 168 8 +265 8 168 64 +266 43 169 44 +267 50 169 43 +268 44 162 45 +269 44 169 162 +270 45 138 46 +271 45 162 138 +272 46 160 47 +273 138 160 46 +274 47 139 48 +275 47 160 139 +276 48 164 49 +277 139 164 48 +278 164 170 49 +279 51 144 50 +280 144 169 50 +281 52 146 51 +282 51 146 144 +283 53 140 52 +284 140 146 52 +285 54 163 53 +286 53 163 140 +287 54 172 163 +288 55 143 56 +289 55 170 143 +290 56 158 57 +291 143 158 56 +292 57 141 58 +293 57 158 141 +294 141 168 58 +295 60 142 59 +296 142 172 59 +297 61 165 60 +298 60 165 142 +299 62 137 61 +300 137 165 61 +301 63 166 62 +302 62 166 137 +303 64 145 63 +304 145 166 63 +305 64 168 145 +306 159 165 137 +307 137 176 159 +308 166 176 137 +309 138 167 160 +310 162 173 138 +311 138 173 167 +312 139 160 155 +313 155 171 139 +314 139 171 164 +315 140 147 146 +316 140 148 147 +317 140 163 148 +318 145 168 141 +319 141 177 145 +320 158 161 141 +321 161 177 141 +322 163 172 142 +323 142 174 163 +324 165 174 142 +325 143 175 158 +326 143 170 164 +327 164 171 143 +328 171 175 143 +329 146 157 144 +330 157 169 144 +331 145 177 166 +332 147 157 146 +333 148 149 147 +334 149 150 147 +335 150 157 147 +336 148 159 149 +337 148 174 159 +338 163 174 148 +339 149 151 150 +340 149 152 151 +341 149 159 152 +342 151 167 150 +343 150 173 157 +344 167 173 150 +345 152 153 151 +346 153 155 151 +347 155 167 151 +348 152 154 153 +349 152 176 154 +350 159 176 152 +351 154 161 153 +352 153 156 155 +353 153 161 156 +354 154 177 161 +355 154 176 166 +356 166 177 154 +357 156 171 155 +358 160 167 155 +359 161 175 156 +360 156 175 171 +361 162 169 157 +362 157 173 162 +363 158 175 161 +364 159 174 165 +2 4 2 76 +365 3 200 31 +366 37 200 3 +367 36 198 4 +368 4 198 40 +369 42 201 7 +370 7 201 59 +371 8 199 39 +372 64 199 8 +373 31 193 32 +374 31 200 193 +375 32 178 33 +376 32 193 178 +377 33 190 34 +378 178 190 33 +379 34 181 35 +380 34 190 181 +381 35 195 36 +382 181 195 35 +383 195 198 36 +384 38 184 37 +385 184 200 37 +386 39 182 38 +387 182 184 38 +388 39 199 182 +389 40 183 41 +390 40 198 183 +391 41 186 42 +392 183 186 41 +393 186 201 42 +394 59 192 60 +395 59 201 192 +396 60 179 61 +397 60 192 179 +398 61 188 62 +399 179 188 61 +400 62 180 63 +401 62 188 180 +402 63 194 64 +403 180 194 63 +404 194 199 64 +405 178 191 190 +406 178 203 191 +407 193 203 178 +408 179 189 188 +409 179 202 189 +410 192 202 179 +411 188 197 180 +412 180 205 194 +413 197 205 180 +414 190 196 181 +415 181 204 195 +416 196 204 181 +417 182 185 184 +418 182 194 185 +419 182 199 194 +420 183 187 186 +421 183 195 187 +422 183 198 195 +423 185 193 184 +424 193 200 184 +425 185 203 193 +426 194 205 185 +427 185 205 203 +428 187 192 186 +429 192 201 186 +430 187 202 192 +431 195 204 187 +432 187 204 202 +433 189 197 188 +434 189 196 191 +435 191 197 189 +436 189 204 196 +437 202 204 189 +438 191 196 190 +439 191 205 197 +440 203 205 191 +2 5 2 62 +441 1 16 224 +442 21 1 224 +443 20 4 222 +444 4 40 222 +445 5 23 221 +446 50 5 221 +447 42 7 223 +448 7 54 223 +449 16 17 218 +450 16 218 224 +451 17 18 206 +452 17 206 218 +453 18 19 212 +454 206 18 212 +455 19 20 209 +456 19 209 212 +457 209 20 222 +458 22 21 211 +459 211 21 224 +460 23 22 219 +461 22 211 219 +462 23 219 221 +463 40 41 220 +464 40 220 222 +465 41 42 210 +466 41 210 220 +467 210 42 223 +468 51 50 208 +469 208 50 221 +470 52 51 214 +471 51 208 214 +472 53 52 207 +473 207 52 214 +474 54 53 217 +475 53 207 217 +476 54 217 223 +477 206 212 225 +478 218 206 227 +479 206 225 227 +480 207 214 216 +481 207 216 226 +482 217 207 226 +483 214 208 215 +484 215 208 219 +485 219 208 221 +486 212 209 213 +487 213 209 220 +488 220 209 222 +489 210 213 220 +490 213 210 226 +491 217 210 223 +492 210 217 226 +493 211 215 219 +494 215 211 227 +495 218 211 224 +496 211 218 227 +497 212 213 225 +498 213 216 225 +499 216 213 226 +500 214 215 216 +501 216 215 227 +502 225 216 227 +2 6 2 52 +503 24 2 242 +504 2 28 242 +505 3 27 244 +506 37 3 244 +507 30 6 241 +508 6 55 241 +509 8 39 243 +510 58 8 243 +511 25 24 239 +512 239 24 242 +513 26 25 229 +514 229 25 239 +515 27 26 238 +516 26 229 238 +517 27 238 244 +518 28 29 231 +519 28 231 242 +520 29 30 232 +521 231 29 232 +522 232 30 241 +523 38 37 230 +524 230 37 244 +525 39 38 234 +526 38 230 234 +527 39 234 243 +528 55 56 236 +529 55 236 241 +530 56 57 228 +531 56 228 236 +532 57 58 237 +533 228 57 237 +534 237 58 243 +535 236 228 240 +536 228 237 240 +537 238 229 245 +538 229 239 245 +539 234 230 235 +540 235 230 238 +541 238 230 244 +542 231 232 233 +543 231 233 239 +544 231 239 242 +545 233 232 236 +546 236 232 241 +547 233 236 240 +548 239 233 245 +549 233 240 245 +550 234 235 237 +551 234 237 243 +552 237 235 240 +553 235 238 245 +554 240 235 245 +3 1 4 894 +555 168 199 145 274 +556 110 253 231 267 +557 125 224 218 266 +558 105 258 251 279 +559 82 272 258 279 +560 224 211 218 266 +561 232 110 231 267 +562 82 258 105 279 +563 66 88 119 275 +564 145 194 255 274 +565 112 110 232 267 +566 203 255 251 258 +567 145 199 194 274 +568 97 265 252 266 +569 196 248 191 259 +570 90 109 125 252 +571 258 272 246 279 +572 168 243 199 274 +573 218 265 72 266 +574 234 230 184 274 +575 184 182 234 274 +576 113 253 110 267 +577 131 231 110 253 +578 131 98 242 253 +579 230 251 184 274 +580 119 252 66 275 +581 119 127 252 275 +582 258 272 82 284 +583 122 252 127 276 +584 184 251 182 274 +585 224 218 97 125 +586 73 251 105 258 +587 66 109 90 252 +588 131 242 231 253 +589 246 256 253 272 +590 72 265 97 266 +591 119 109 66 252 +592 97 85 252 265 +593 131 98 253 285 +594 125 211 224 266 +595 66 90 101 252 +596 249 264 167 271 +597 89 82 105 279 +598 82 89 272 279 +599 189 248 196 259 +600 81 82 272 284 +601 218 72 97 266 +602 168 145 141 274 +603 185 251 203 255 +604 248 258 191 259 +605 231 253 233 267 +606 218 97 125 266 +607 255 258 205 273 +608 194 199 182 274 +609 251 258 246 279 +610 256 263 253 272 +611 189 191 196 248 +612 194 182 255 274 +613 202 259 250 270 +614 88 263 119 275 +615 123 263 256 282 +616 245 274 262 279 +617 248 246 255 258 +618 71 242 98 253 +619 252 247 261 265 +620 246 258 257 272 +621 247 254 252 261 +622 232 231 233 267 +623 147 269 261 271 +624 66 252 101 275 +625 107 119 88 263 +626 152 176 159 270 +627 97 72 85 265 +628 154 264 255 273 +629 97 252 125 266 +630 264 281 256 282 +631 123 130 256 263 +632 257 272 258 284 +633 204 250 202 259 +634 251 274 235 279 +635 193 251 73 258 +636 152 269 248 270 +637 110 131 253 285 +638 248 259 269 278 +639 246 248 257 258 +640 245 262 253 279 +641 113 256 253 267 +642 249 264 256 282 +643 244 96 200 251 +644 94 73 105 258 +645 250 269 259 278 +646 113 110 112 267 +647 119 127 109 252 +648 205 255 203 258 +649 93 102 70 268 +650 216 261 260 265 +651 249 120 275 276 +652 197 191 248 273 +653 197 205 191 273 +654 234 182 243 274 +655 255 264 154 281 +656 182 199 243 274 +657 205 258 191 273 +658 113 253 256 285 +659 155 281 264 282 +660 190 259 258 284 +661 254 271 252 276 +662 202 189 259 270 +663 252 265 261 266 +664 71 253 98 285 +665 191 258 248 273 +666 246 257 249 272 +667 123 249 263 282 +668 252 261 254 266 +669 216 260 261 280 +670 152 248 264 273 +671 257 264 249 271 +672 116 211 125 266 +673 97 90 125 252 +674 248 255 246 264 +675 184 185 182 251 +676 185 182 251 274 +677 159 269 152 270 +678 248 259 250 269 +679 247 264 257 271 +680 128 276 249 282 +681 251 255 246 258 +682 252 271 247 275 +683 246 256 249 264 +684 226 277 250 280 +685 247 257 264 269 +686 197 191 189 248 +687 133 224 97 125 +688 151 167 264 271 +689 152 264 154 273 +690 256 263 249 282 +691 123 256 130 282 +692 147 261 269 280 +693 93 195 181 259 +694 81 82 89 272 +695 164 139 124 256 +696 128 249 123 282 +697 86 229 253 279 +698 247 261 260 280 +699 230 244 200 251 +700 119 120 127 275 +701 152 154 176 273 +702 109 136 125 252 +703 185 251 255 274 +704 93 70 195 268 +705 250 247 269 278 +706 200 96 73 251 +707 247 257 249 271 +708 94 193 73 258 +709 204 202 189 259 +710 185 255 182 274 +711 136 254 135 266 +712 187 202 204 250 +713 238 251 235 279 +714 155 256 281 282 +715 99 84 103 253 +716 136 125 252 266 +717 86 229 239 253 +718 184 230 200 251 +719 239 242 71 253 +720 260 261 247 265 +721 93 259 102 268 +722 197 248 189 273 +723 261 269 247 271 +724 210 250 226 277 +725 155 151 167 264 +726 103 253 84 279 +727 255 264 248 273 +728 253 262 233 267 +729 203 251 193 258 +730 189 248 259 270 +731 84 253 99 272 +732 152 264 248 269 +733 141 177 161 274 +734 248 269 247 278 +735 168 141 243 274 +736 164 256 124 267 +737 248 264 257 269 +738 247 257 248 278 +739 262 274 246 279 +740 210 268 250 277 +741 250 260 213 280 +742 225 216 260 265 +743 213 260 250 268 +744 245 235 274 279 +745 226 250 213 280 +746 202 270 250 277 +747 247 269 264 271 +748 93 195 259 268 +749 248 258 255 273 +750 250 259 204 268 +751 178 193 94 258 +752 213 250 210 268 +753 247 248 257 269 +754 247 260 250 280 +755 244 69 96 251 +756 190 258 65 284 +757 97 85 90 252 +758 253 272 84 279 +759 86 239 71 253 +760 136 252 254 266 +761 81 272 257 284 +762 38 184 234 230 +763 184 234 182 38 +764 190 65 259 284 +765 256 264 246 281 +766 249 276 167 282 +767 157 147 261 271 +768 239 231 242 253 +769 108 124 139 256 +770 122 254 252 276 +771 120 122 127 276 +772 128 120 249 276 +773 110 253 113 285 +774 120 123 128 249 +775 252 254 247 271 +776 136 122 135 254 +777 149 269 147 271 +778 249 271 167 276 +779 262 274 161 281 +780 249 257 246 264 +781 210 213 226 250 +782 171 175 156 281 +783 253 263 256 285 +784 248 246 257 264 +785 192 142 165 270 +786 120 119 263 275 +787 186 268 210 277 +788 71 98 92 285 +789 157 261 254 271 +790 123 115 130 263 +791 86 71 103 253 +792 202 192 270 277 +793 126 106 162 254 +794 131 92 98 285 +795 142 174 270 277 +796 250 277 174 280 +797 95 88 66 275 +798 164 171 139 256 +799 186 183 220 268 +800 185 203 205 255 +801 187 250 204 268 +802 177 255 154 281 +803 247 250 260 278 +804 142 174 165 270 +805 212 260 68 265 +806 212 68 74 265 +807 192 142 270 277 +808 206 212 74 265 +809 175 262 171 267 +810 135 266 254 286 +811 253 262 246 279 +812 136 252 122 254 +813 261 265 227 266 +814 247 269 261 280 +815 247 249 257 275 +816 249 247 271 275 +817 245 253 229 279 +818 157 150 147 271 +819 93 181 65 259 +820 237 158 228 262 +821 194 180 166 255 +822 186 187 268 277 +823 180 166 255 273 +824 162 106 138 276 +825 190 65 181 259 +826 103 86 253 279 +827 158 262 237 274 +828 209 68 212 260 +829 107 120 119 263 +830 91 68 209 260 +831 123 249 120 263 +832 162 138 173 276 +833 205 203 191 258 +834 141 161 262 274 +835 249 120 263 275 +836 257 263 249 272 +837 165 179 192 270 +838 174 250 269 270 +839 150 149 147 271 +840 135 254 126 286 +841 135 126 266 286 +842 246 264 255 281 +843 193 200 73 251 +844 81 257 80 284 +845 164 171 256 267 +846 233 253 245 262 +847 247 250 269 280 +848 166 145 194 255 +849 256 262 253 267 +850 110 114 131 285 +851 148 250 174 280 +852 187 202 250 277 +853 240 262 245 274 +854 257 79 278 283 +855 141 262 158 274 +856 171 262 256 267 +857 245 238 235 279 +858 162 254 106 276 +859 248 270 189 273 +860 106 121 138 276 +861 166 177 145 255 +862 102 76 91 268 +863 162 173 254 276 +864 140 207 146 261 +865 253 256 246 262 +866 174 250 148 269 +867 209 91 260 268 +868 257 275 79 283 +869 79 272 83 275 +870 178 203 193 258 +871 216 213 260 280 +872 140 207 261 280 +873 251 246 274 279 +874 147 269 148 280 +875 227 261 216 265 +876 132 221 111 286 +877 258 259 248 284 +878 146 207 214 261 +879 72 218 206 265 +880 196 190 181 259 +881 253 99 272 285 +882 259 260 250 278 +883 76 260 91 268 +884 127 122 136 252 +885 264 269 151 271 +886 111 221 219 286 +887 79 257 272 275 +888 141 158 237 274 +889 218 227 265 266 +890 257 258 248 284 +891 250 268 187 277 +892 68 260 75 265 +893 117 135 126 266 +894 108 256 139 282 +895 175 143 262 267 +896 86 238 229 279 +897 250 270 174 277 +898 249 263 257 275 +899 246 255 251 274 +900 153 154 264 281 +901 69 73 96 251 +902 157 254 173 271 +903 185 193 203 251 +904 177 154 161 281 +905 254 261 247 271 +906 229 245 239 253 +907 104 65 258 284 +908 111 266 126 286 +909 83 272 263 275 +910 151 264 152 269 +911 195 204 181 259 +912 245 229 238 279 +913 149 151 269 271 +914 250 259 248 270 +915 65 94 104 258 +916 101 275 252 283 +917 219 266 111 286 +918 238 69 251 279 +919 141 161 158 262 +920 204 189 196 259 +921 79 77 278 283 +922 66 101 95 275 +923 186 187 183 268 +924 215 261 227 266 +925 80 257 278 284 +926 149 151 152 269 +927 186 220 210 268 +928 81 80 82 284 +929 124 256 113 267 +930 132 111 126 286 +931 238 69 244 251 +932 246 255 274 281 +933 253 272 263 285 +934 150 157 173 271 +935 116 111 219 266 +936 185 194 182 255 +937 225 260 212 265 +938 236 233 262 267 +939 250 148 269 280 +940 238 244 230 251 +941 79 80 257 278 +942 75 260 278 283 +943 85 101 90 252 +944 222 100 91 268 +945 236 262 143 267 +946 263 272 257 275 +947 175 171 143 267 +948 233 245 240 262 +949 110 113 114 285 +950 153 154 152 264 +951 126 135 106 254 +952 193 184 200 251 +953 74 72 206 265 +954 113 124 130 256 +955 117 126 111 266 +956 206 225 212 265 +957 179 202 192 270 +958 83 84 99 272 +959 160 138 118 276 +960 75 265 260 283 +961 138 121 118 276 +962 195 204 259 268 +963 72 218 97 224 +964 108 130 124 256 +965 100 198 70 268 +966 79 77 80 278 +967 74 68 75 265 +968 257 248 278 284 +969 80 81 79 257 +970 75 260 76 278 +971 78 79 275 283 +972 136 109 127 252 +973 250 260 259 268 +974 232 112 29 110 +975 209 222 91 268 +976 79 78 77 283 +977 155 139 256 282 +978 243 141 237 274 +979 174 269 159 270 +980 173 271 254 276 +981 238 86 69 279 +982 79 83 95 275 +983 247 265 252 283 +984 171 155 139 256 +985 78 275 101 283 +986 259 278 248 284 +987 136 135 117 266 +988 231 232 29 110 +989 99 103 71 253 +990 235 251 230 274 +991 180 255 205 273 +992 213 225 216 260 +993 237 228 240 262 +994 105 73 69 251 +995 209 19 212 68 +996 222 198 100 268 +997 95 263 88 275 +998 246 274 262 281 +999 209 260 213 268 +1000 79 257 81 272 +1001 227 215 216 261 +1002 194 205 180 255 +1003 187 192 202 277 +1004 19 209 91 68 +1005 238 230 235 251 +1006 83 272 99 285 +1007 136 117 125 266 +1008 248 269 250 270 +1009 85 101 252 283 +1010 193 185 184 251 +1011 175 143 158 262 +1012 70 91 100 268 +1013 160 276 118 282 +1014 65 87 93 259 +1015 240 245 235 274 +1016 177 166 154 255 +1017 216 226 213 280 +1018 18 212 74 206 +1019 129 88 67 263 +1020 231 239 233 253 +1021 252 265 85 283 +1022 148 147 149 269 +1023 114 92 131 285 +1024 140 261 147 280 +1025 116 219 211 266 +1026 246 272 253 279 +1027 247 275 257 283 +1028 155 153 264 281 +1029 167 271 173 276 +1030 140 146 147 261 +1031 188 197 189 273 +1032 119 66 12 88 +1033 18 212 68 74 +1034 116 125 117 266 +1035 102 91 70 268 +1036 236 233 240 262 +1037 83 263 95 275 +1038 218 227 206 265 +1039 52 146 140 207 +1040 33 65 190 178 +1041 252 275 247 283 +1042 107 88 129 263 +1043 247 260 265 283 +1044 217 277 226 280 +1045 220 213 210 268 +1046 138 45 106 162 +1047 146 52 214 207 +1048 188 189 270 273 +1049 247 257 278 283 +1050 83 263 272 285 +1051 178 190 191 258 +1052 237 262 240 274 +1053 227 216 225 265 +1054 106 44 126 162 +1055 160 167 276 282 +1056 176 137 270 273 +1057 11 90 66 109 +1058 162 254 157 286 +1059 87 259 65 284 +1060 33 94 65 178 +1061 32 193 94 178 +1062 154 255 166 273 +1063 82 94 105 258 +1064 116 117 111 266 +1065 10 90 109 125 +1066 221 144 208 286 +1067 56 228 158 143 +1068 106 121 45 138 +1069 132 169 221 286 +1070 60 192 142 165 +1071 254 261 157 286 +1072 128 122 120 276 +1073 207 53 217 280 +1074 217 163 277 280 +1075 175 158 161 262 +1076 174 148 159 269 +1077 32 94 193 73 +1078 195 70 198 268 +1079 130 256 108 282 +1080 119 11 66 109 +1081 164 139 48 124 +1082 246 262 256 281 +1083 180 166 63 194 +1084 228 56 236 143 +1085 121 46 138 118 +1086 76 68 91 260 +1087 160 167 138 276 +1088 198 222 183 268 +1089 60 179 192 165 +1090 185 205 194 255 +1091 107 119 12 88 +1092 78 95 101 275 +1093 71 99 253 285 +1094 35 70 195 93 +1095 181 35 195 93 +1096 161 175 262 281 +1097 146 261 214 286 +1098 140 53 207 280 +1099 261 266 215 286 +1100 160 138 46 118 +1101 76 75 68 260 +1102 216 261 207 280 +1103 145 63 166 194 +1104 106 254 122 276 +1105 235 230 234 274 +1106 155 153 151 264 +1107 137 179 165 270 +1108 209 212 213 260 +1109 138 167 173 276 +1110 149 150 151 271 +1111 72 74 85 265 +1112 214 207 216 261 +1113 75 76 77 278 +1114 126 169 132 286 +1115 75 278 77 283 +1116 108 48 139 124 +1117 179 137 188 270 +1118 247 278 260 283 +1119 240 228 236 262 +1120 219 221 208 286 +1121 144 146 214 286 +1122 137 159 176 270 +1123 146 157 147 261 +1124 218 211 227 266 +1125 245 233 239 253 +1126 106 135 122 254 +1127 155 156 153 281 +1128 188 270 137 273 +1129 84 272 89 279 +1130 254 266 261 286 +1131 41 183 220 186 +1132 159 149 152 269 +1133 181 204 196 259 +1134 214 261 215 286 +1135 209 213 220 268 +1136 93 87 102 259 +1137 67 129 13 88 +1138 243 237 234 274 +1139 129 263 67 285 +1140 234 182 39 243 +1141 118 276 128 282 +1142 208 144 214 286 +1143 169 144 221 286 +1144 173 157 162 254 +1145 157 261 146 286 +1146 218 17 72 206 +1147 199 39 182 243 +1148 85 265 75 283 +1149 129 107 13 88 +1150 95 83 88 263 +1151 165 61 179 137 +1152 81 89 84 272 +1153 93 181 34 65 +1154 215 214 216 261 +1155 85 74 75 265 +1156 220 183 222 268 +1157 95 78 79 275 +1158 153 161 154 281 +1159 172 223 217 277 +1160 124 113 112 267 +1161 17 74 72 206 +1162 210 226 217 277 +1163 115 123 120 263 +1164 236 232 233 267 +1165 178 191 203 258 +1166 61 188 179 137 +1167 121 106 122 276 +1168 144 208 214 51 +1169 170 241 143 267 +1170 104 258 82 284 +1171 190 34 181 65 +1172 219 215 266 286 +1173 44 169 126 162 +1174 164 134 170 267 +1175 212 225 213 260 +1176 162 157 169 286 +1177 94 82 104 258 +1178 139 47 108 282 +1179 87 278 259 284 +1180 160 47 139 282 +1181 186 192 187 277 +1182 172 217 163 277 +1183 62 180 188 273 +1184 158 57 237 228 +1185 112 232 241 267 +1186 87 77 76 278 +1187 47 118 108 282 +1188 186 223 201 277 +1189 90 10 97 125 +1190 83 67 263 285 +1191 47 160 118 282 +1192 164 143 171 267 +1193 71 92 99 285 +1194 140 147 148 280 +1195 160 155 167 282 +1196 83 79 81 272 +1197 214 144 51 146 +1198 159 148 149 269 +1199 164 124 134 267 +1200 167 150 173 271 +1201 241 134 112 267 +1202 166 180 62 273 +1203 62 188 137 273 +1204 172 54 217 223 +1205 219 208 215 286 +1206 83 99 67 285 +1207 118 128 123 282 +1208 85 78 101 283 +1209 209 220 222 268 +1210 143 241 236 267 +1211 137 166 62 273 +1212 220 41 186 210 +1213 128 118 121 276 +1214 218 72 16 224 +1215 54 217 163 172 +1216 80 104 82 284 +1217 237 141 57 158 +1218 26 229 86 238 +1219 189 179 188 270 +1220 88 83 67 263 +1221 89 69 86 279 +1222 186 210 223 277 +1223 113 115 114 285 +1224 151 153 152 264 +1225 148 163 140 280 +1226 195 187 204 268 +1227 22 116 111 219 +1228 104 87 65 284 +1229 208 221 50 144 +1230 27 244 69 96 +1231 28 231 110 131 +1232 241 55 170 143 +1233 163 174 142 277 +1234 180 205 197 273 +1235 242 24 71 98 +1236 129 115 263 285 +1237 142 201 172 277 +1238 161 156 175 281 +1239 167 151 150 271 +1240 107 115 120 263 +1241 103 84 89 279 +1242 208 214 215 286 +1243 216 207 226 280 +1244 131 28 231 242 +1245 115 107 129 263 +1246 179 189 202 270 +1247 170 134 241 267 +1248 30 241 112 232 +1249 97 16 72 224 +1250 238 86 26 69 +1251 198 183 195 268 +1252 144 157 146 286 +1253 239 24 71 242 +1254 221 169 50 144 +1255 141 168 243 58 +1256 30 241 134 112 +1257 137 165 159 270 +1258 67 14 129 285 +1259 207 217 226 280 +1260 237 240 235 274 +1261 55 241 236 143 +1262 223 201 42 186 +1263 215 211 219 266 +1264 201 142 192 277 +1265 174 159 165 270 +1266 78 75 77 283 +1267 40 198 222 183 +1268 225 206 227 265 +1269 116 22 211 219 +1270 143 164 170 267 +1271 123 130 108 282 +1272 195 183 187 268 +1273 103 89 86 279 +1274 132 221 169 43 +1275 67 92 14 285 +1276 36 70 100 198 +1277 155 160 139 282 +1278 243 237 141 58 +1279 83 81 84 272 +1280 224 97 9 133 +1281 27 244 238 69 +1282 142 59 172 201 +1283 227 211 215 266 +1284 14 114 129 285 +1285 170 164 49 134 +1286 223 172 201 277 +1287 78 85 75 283 +1288 186 42 223 210 +1289 124 49 164 134 +1290 14 92 114 285 +1291 118 123 108 282 +1292 237 235 234 274 +1293 124 112 134 267 +1294 39 182 234 38 +1295 145 199 168 64 +1296 137 176 166 273 +1297 230 38 184 37 +1298 74 17 18 206 +1299 122 128 121 276 +1300 142 59 201 192 +1301 220 40 222 183 +1302 15 131 92 98 +1303 188 180 197 273 +1304 70 36 195 198 +1305 96 200 73 31 +1306 25 24 71 239 +1307 29 28 231 110 +1308 243 199 8 168 +1309 131 114 15 92 +1310 154 166 176 273 +1311 200 244 3 96 +1312 241 232 236 267 +1313 129 114 115 285 +1314 236 56 55 143 +1315 163 142 172 277 +1316 221 132 5 43 +1317 52 214 51 146 +1318 209 20 19 91 +1319 33 32 94 178 +1320 50 169 221 43 +1321 112 29 30 232 +1322 153 156 161 281 +1323 44 106 45 162 +1324 166 63 62 180 +1325 90 11 10 109 +1326 97 9 16 224 +1327 200 193 73 31 +1328 47 160 46 118 +1329 46 45 121 138 +1330 223 210 217 277 +1331 157 144 169 286 +1332 219 111 23 221 +1333 207 140 52 53 +1334 132 23 111 221 +1335 133 21 224 116 +1336 145 194 199 64 +1337 211 224 21 116 +1338 237 141 58 57 +1339 212 19 18 68 +1340 53 217 163 54 +1341 242 131 2 98 +1342 66 12 11 119 +1343 241 170 6 134 +1344 9 1 224 133 +1345 60 61 179 165 +1346 201 192 186 277 +1347 139 48 108 47 +1348 34 35 181 93 +1349 223 172 7 201 +1350 222 4 100 198 +1351 14 15 114 92 +1352 49 164 48 124 +1353 80 87 104 284 +1354 107 12 13 88 +1355 142 59 192 60 +1356 41 42 186 210 +1357 199 8 39 243 +1358 193 32 73 31 +1359 195 36 70 35 +1360 238 26 27 69 +1361 190 34 65 33 +1362 137 61 188 62 +1363 21 22 211 116 +1364 3 37 244 200 +1365 50 221 5 43 +1366 9 1 16 224 +1367 194 145 63 64 +1368 129 13 14 67 +1369 131 2 28 242 +1370 99 92 67 285 +1371 241 6 30 134 +1372 198 222 4 40 +1373 23 22 111 219 +1374 183 220 40 41 +1375 168 199 8 64 +1376 3 200 96 31 +1377 168 8 243 58 +1378 55 170 6 241 +1379 50 208 144 51 +1380 2 24 242 98 +1381 244 27 3 96 +1382 20 4 100 222 +1383 126 132 169 43 +1384 72 16 17 218 +1385 5 23 132 221 +1386 172 59 7 201 +1387 36 100 4 198 +1388 6 170 49 134 +1389 133 97 9 125 +1390 172 7 54 223 +1391 131 15 2 98 +1392 228 158 57 56 +1393 7 223 201 42 +1394 229 25 26 86 +1395 224 1 21 133 +1396 43 169 126 44 +1397 10 9 97 125 +1398 191 259 190 196 +1399 190 259 191 258 +1400 272 256 249 246 +1401 272 249 256 263 +1402 275 276 271 249 +1403 271 276 275 252 +1404 264 282 167 155 +1405 167 282 264 249 +1406 275 127 276 120 +1407 275 276 127 252 +1408 273 152 270 176 +1409 273 270 152 248 +1410 274 177 145 141 +1411 274 145 177 255 +1412 239 86 25 229 +1413 25 86 239 71 +1414 285 263 113 115 +1415 285 113 263 256 +1416 130 113 263 115 +1417 130 263 113 256 +1418 262 171 281 175 +1419 262 281 171 256 +1420 178 65 258 94 +1421 178 258 65 190 +1422 281 177 274 161 +1423 281 274 177 255 +1424 174 280 163 148 +1425 163 280 174 277 +1426 268 76 259 102 +1427 268 259 76 260 +1428 278 76 259 260 +1429 116 224 125 211 +1430 116 125 224 133 +1431 281 171 155 156 +1432 281 155 171 256 +1433 230 200 37 184 +1434 37 200 230 244 +1435 143 228 262 236 +1436 143 262 228 158 +1437 105 279 69 89 +1438 69 279 105 251 +1439 286 162 126 169 +1440 286 126 162 254 +1441 280 53 163 140 +1442 280 163 53 217 +1443 278 80 87 77 +1444 278 87 80 284 +1445 222 91 20 209 +1446 20 91 222 100 +1447 76 259 87 278 +1448 76 87 259 102 +$EndElements From 90cceb998bacf4f67d89c52f0f456a0db5ea8693 Mon Sep 17 00:00:00 2001 From: timon Date: Mon, 29 Jun 2026 00:06:19 +0200 Subject: [PATCH 02/11] removed edc wrong values bug --- example_settings/misuka_setting.json | 20 +- misuka_method/Dockerfile | 4 +- .../misuka_interface/misuka_interface.py | 781 +++++++----------- misuka_method/pyproject.toml | 2 - misuka_method/tests/test_input_misuka.json | 3 +- misuka_method/tests/test_misuka_cli.py | 292 +------ 6 files changed, 338 insertions(+), 764 deletions(-) diff --git a/example_settings/misuka_setting.json b/example_settings/misuka_setting.json index 056d808..1e64126 100644 --- a/example_settings/misuka_setting.json +++ b/example_settings/misuka_setting.json @@ -17,9 +17,9 @@ "type": "float", "display": "text", "min": 0.01, - "max": 15, + "max": 20, "step": 1.0, - "default": 0.1, + "default": 10, "endAdornment": "s" }, { @@ -28,9 +28,9 @@ "type": "integer", "display": "text", "min": 100, - "max": 30000, + "max": 40000, "step": 1000, - "default": 1000, + "default": 20000, "endAdornment": "Hz" }, { @@ -131,6 +131,18 @@ "step": 0.1, "default": 0.1, "endAdornment": "" + }, + { + "name": "Provide more Log Data", + "id": "log_data", + "type": "string", + "display": "radio", + "options": + { + "yes": "yes", + "no": "no" + }, + "default": "no" } ], diff --git a/misuka_method/Dockerfile b/misuka_method/Dockerfile index e9fc1fc..cc3cb47 100644 --- a/misuka_method/Dockerfile +++ b/misuka_method/Dockerfile @@ -66,12 +66,12 @@ RUN cd /opt/misuka && \ ENV PYTHONPATH="/opt/misuka/build/python" # Install remaining Python dependencies -RUN python3.11 -m pip install numpy requests gmsh meshio pyfar==0.8.0 pyrato matplotlib + WORKDIR /app COPY misuka_method /app/misuka_method -RUN python3.11 -m pip install --no-deps /app/misuka_method +RUN python3.11 -m pip install /app/misuka_method WORKDIR /app/misuka_method diff --git a/misuka_method/misuka_interface/misuka_interface.py b/misuka_method/misuka_interface/misuka_interface.py index c33a0a2..76981c8 100644 --- a/misuka_method/misuka_interface/misuka_interface.py +++ b/misuka_method/misuka_interface/misuka_interface.py @@ -7,29 +7,15 @@ import mitsuba as mi import numpy as np import pyfar as pf -import pyrato +import pyrato as pr import pandas as pd - +import os +from pathlib import Path mi.set_variant("cuda_acoustic", "llvm_acoustic", "cuda_ad_acoustic", "llvm_ad_acoustic") -from mitsuba import ScalarTransform4f as tf - from .definition import SimulationMethod - - -RESULT_PARAMETER_KEYS = ( - "edt", - "t20", - "t30", - "c80", - "d50", - "ts", - "spl_t0_freq", -) - - class misukaMethod(SimulationMethod): """Interface class to run the misuka method. @@ -68,6 +54,8 @@ def _misuka_method(self, json_file_path: str | Path) -> None: # ------- fetching simulation settings and initialization ------- # ---------------------------------------------------------------- + print(f'Using variant: {mi.variant()}') + # Load the input JSON file with open(json_file_path, "r") as json_file: result_container = json.load(json_file) @@ -84,26 +72,31 @@ def _misuka_method(self, json_file_path: str | Path) -> None: msh_path = result_container["msh_path"] absorption_map = result_container["absorption_coefficients"] dynamic_range_db = simulation_settings["dynamic_range_db"] + #log_data = simulation_settings["log_data"] == "yes" except KeyError as e: raise KeyError(f"Could not fetch the simulation settings for misuka T.T: {e}") - source_intensity = sound_power_W / (4.0 * np.pi) # omnidirectional point source + source_intensity = sound_power_W - n_bands = len(frequencies) frequency_range = (float(np.min(frequencies)), float(np.max(frequencies))) f_center, f_lower, f_upper = pf.constants.fractional_octave_frequencies_exact(1, frequency_range) assert np.all(np.abs(f_center-frequencies)/frequencies < 1e-2), "Frequency mismatch between input frequencies and pyrato's fractional octave frequencies" frequencies_misuka = ", ".join(str(f) for f in frequencies) - + #TODO: currently using scattering specified by misuka_setting / User... for future use from msh file scattering_map = [ simulation_settings[f"scattering_{int(f)}"] for f in frequencies ] - source_coords = [ - result_container["results"][0]["sourceX"], - result_container["results"][0]["sourceY"], - result_container["results"][0]["sourceZ"], + # Create frequency-scattering tuples for spectrum + scattering_spectrum = create_frequency_spectrum(frequencies, scattering_map) #TODO: when scattering is surface material dependent move this into for loop + + source_coords = [ #TODO: when multiple soundsources are implemented adjust like receiver_coords + [ + result_container["results"][0]["sourceX"], + result_container["results"][0]["sourceY"], + result_container["results"][0]["sourceZ"], + ] ] receiver_coords = [ [ @@ -118,17 +111,8 @@ def _misuka_method(self, json_file_path: str | Path) -> None: set_progress_and_save(25, result_container, json_file_path) # ---------------------------------------------------------------- - # --- initializing scene parameters, microphone and integrator --- + # --- initializing scene with geometry & integrator --- # ---------------------------------------------------------------- - - scene = create_acoustic_scene( - msh_file=msh_path, - frequencies=frequencies, - absorption_map=absorption_map, - source_position=source_coords, - source_intensity=source_intensity, - scattering_map=scattering_map, - ) # set up the integrator integrator_acoustic = mi.load_dict( @@ -140,32 +124,67 @@ def _misuka_method(self, json_file_path: str | Path) -> None: } ) - set_progress_and_save(35, result_container, json_file_path) + print(f'Simulationsdauer: {max_time}') + + # define scene as dictionary + scene_dict = { + "type": "scene", + } - # ---------------------------------------------------------------- - # ------------- calculating ETC for each receiver -------------- - # ---------------------------------------------------------------- + # Extract physical group information from gmsh + gmsh.initialize() + gmsh.open(str(msh_path)) + surfaces = gmsh.model.getPhysicalGroups(2) + + # For each surface, create a separate PLY file + # Nach gmsh.open(str(msh_file)): + ply_dir = Path(msh_path).parent + + for i, (dim, tag) in enumerate(surfaces): + surface_name = gmsh.model.getPhysicalName(dim, tag) + absorption_str = absorption_map.get(surface_name, "0.1, 0.1, 0.1, 0.1, 0.1, 0.1") + entity_tags = gmsh.model.getEntitiesForPhysicalName(surface_name) + + ply_file = str(ply_dir / f"surface_{surface_name}.ply") # korrekt + export_physical_surface_to_ply(surface_name, entity_tags, ply_file) + absorption_spectrum = create_frequency_spectrum(frequencies, absorption_str) + + scene_dict[f"bsdf_{i}"] = { + "type": "twosided", + "id": f"bsdf_{i}", + "bsdf": { + "type": "acousticbsdf", + "absorption": { + "type": "spectrum", + "value": absorption_spectrum, + }, + "scattering": { + "type": "spectrum", + "value": scattering_spectrum, + }, + "specular_lobe_width": 0.001, + }, + } - etc = np.empty((n_receivers, n_bands, time_bins), dtype=np.float64) - - for i_rec, receiver_coord in enumerate(receiver_coords): - # set up the microphone sensor for this receiver - microphone_direction = ( - np.asarray(source_coords) - np.asarray(receiver_coord) - ) - distance_to_source = np.linalg.norm( - microphone_direction.astype(float) - ) - if distance_to_source == 0: - raise ValueError( - f"Receiver {i_rec} is at the same position as the source." - ) - microphone_direction = ( - microphone_direction / distance_to_source - ).tolist() - - microphone = mi.load_dict( - { + scene_dict[f"shape_{i}"] = { + "type": "ply", + "filename": ply_file, + "face_normals": True, + "bsdf": {"type": "ref", "id": f"bsdf_{i}"}, + } + + # render for every source to every reciever + for i_src, source_coord in enumerate(source_coords): + for i_rec, receiver_coord in enumerate(receiver_coords): + + microphone_direction = (np.asarray(source_coord) - np.asarray(receiver_coord)) + distance_to_source = np.linalg.norm(microphone_direction.astype(float)) + if distance_to_source == 0: + raise ValueError(f"Receiver {i_rec} is at the same position as the source.") + + microphone_direction = (microphone_direction / distance_to_source).tolist() + + microphone = mi.load_dict({ "type": "microphone", "origin": receiver_coord, "direction": microphone_direction, @@ -173,97 +192,170 @@ def _misuka_method(self, json_file_path: str | Path) -> None: "type": "tape", "frequencies": frequencies_misuka, # rendered frequencies "time_bins": time_bins, # number of time bins + "component_format": "float32", }, + }) + + scene_dict["emitter"] = { + "type": "sphere", + "radius": 0.1, + "center": source_coord, + "emitter": { + 'type': 'area', + 'radiance': { + 'type': 'uniform', + 'value': source_intensity, + } + } } - ) - - # render the scene for this receiver and store the ETC - render_raw = mi.render( - scene, - integrator=integrator_acoustic, - sensor=microphone, - spp=rpf, - ) - # Convert Mitsuba tensor to float64 explicitly - render_array = np.asarray(render_raw, dtype=np.float64) / float(rpf) #TODO: if misuka already averages over rays, this division is not needed. Check this and remove if so. - render_array = np.squeeze(render_array) - - if render_array.shape != (time_bins, n_bands): - raise ValueError( - "Unexpected Misuka render shape after squeeze: " - f"{render_array.shape}; expected {(time_bins, n_bands)}" - ) - - etc[i_rec] = render_array.T.astype(np.float64) - - # update progress for each reciever in even steps to ~90% - set_progress_and_save(35 + int(55/(n_receivers+1) * (i_rec + 1)), result_container, json_file_path) - - # ---------------------------------------------------------------- - # ------------- Converting ETC to EDC -------------- - # ---------------------------------------------------------------- - # Create time vector - times_vector = np.linspace(0, max_time, time_bins) + scene = mi.load_dict(scene_dict) + + #print(f'Meine Szene: {scene}') + + etc = mi.render(scene, sensor=microphone, integrator=integrator_acoustic,spp=rpf) + etc_signal = pf.Signal(etc.numpy().T, sampling_rate=time_bins / max_time, domain='time') + etc_signal.time /= np.max(np.abs(etc_signal.time)) + edc = pr.edc.schroeder_integration(etc_signal, is_energy=True) + edc_normalized = pf.dsp.normalize(edc) + edc_normalized_db = 10*np.log10(edc_normalized.time/1e-12) + edc_normalized_db = np.squeeze(edc_normalized_db, axis=0) + edc_normalized_db = finite_array(edc_normalized_db, nan=0.0, neginf=-400.0, posinf=0.0) + # limit = np.max(edc_normalized_db) - dynamic_range_db + # edc_normalized_db[edc_normalized_db list: + """Create frequency-value tuples for Mitsuba spectrum format. + + Parameters + ---------- + frequencies : list + List of frequency values [125, 250, 500, ...] + values : list or str + Either a list of floats or a comma-separated string of values + + Returns + ------- + list + List of (frequency, value) tuples for Mitsuba spectrum + """ + # Parse values if string + if isinstance(values, str): + vals = [float(val.strip()) for val in values.split(",")] + else: + vals = [float(val) for val in values] + + # Pad with default value (0.1) if not enough values provided + while len(vals) < len(frequencies): + vals.append(0.1) + + return list(zip(frequencies, vals[:len(frequencies)])) + def export_physical_surface_to_ply(surface_name: str, entity_tags, ply_path: str | Path) -> Path: """Export only the triangular mesh faces of one physical surface to PLY.""" @@ -317,368 +409,89 @@ def export_physical_surface_to_ply(surface_name: str, entity_tags, ply_path: str ).write(str(ply_path)) return ply_path -def create_frequency_spectrum(frequencies: list, values) -> list: - """Create frequency-value tuples for Mitsuba spectrum format. - - Parameters - ---------- - frequencies : list - List of frequency values [125, 250, 500, ...] - values : list or str - Either a list of floats or a comma-separated string of values - - Returns - ------- - list - List of (frequency, value) tuples for Mitsuba spectrum - """ - # Parse values if string - if isinstance(values, str): - vals = [float(val.strip()) for val in values.split(",")] - else: - vals = [float(val) for val in values] - - # Pad with default value (0.1) if not enough values provided - while len(vals) < len(frequencies): - vals.append(0.1) - - return list(zip(frequencies, vals[:len(frequencies)])) - -def create_acoustic_scene( - msh_file, - frequencies, - absorption_map, - source_position, - source_intensity, - scattering_map, -): - """Create an acoustic scene with surfaces from the MSH file. - - Parameters - ---------- - msh_file : str | Path - Path to the MSH file - frequencies : list - List of frequency values - absorption_map : dict - Mapping of surface names to comma-separated absorption values - source_position : list - [x, y, z] source position - source_intensity : float - Source intensity value - scattering_map : list - List of scattering coefficients per frequency band - """ - - scene_dict = { - "type": "scene", - "source": { - "type": "point", - "position": source_position, - "intensity": { - "type": "spectrum", - "value": source_intensity, - }, - }, - } - - # Create frequency-scattering tuples for spectrum - scattering_spectrum = create_frequency_spectrum(frequencies, scattering_map) - - # Extract physical group information from gmsh - gmsh.initialize() - gmsh.open(str(msh_file)) - surfaces = gmsh.model.getPhysicalGroups(2) - # For each surface, create a separate PLY file - for i, (dim, tag) in enumerate(surfaces): - surface_name = gmsh.model.getPhysicalName(dim, tag) - absorption_str = absorption_map.get(surface_name, "0.1, 0.1, 0.1, 0.1, 0.1, 0.1") - entity_tags = gmsh.model.getEntitiesForPhysicalName(surface_name) +# copy pasted from pyrato +def center_time(energy_decay_curve): + r""" + Calculate the room-acoustic center time (:math:`T_s`). - # Create output file for this surface - ply_file = f"surface_{surface_name}.ply" + The center time :math:`T_s` is the time of the centroid of the squared + impulse response. It quantifies the balance between early and late + sound energy [#isoTs]_. - export_physical_surface_to_ply(surface_name, entity_tags, ply_file) + The parameter is defined as - # Create frequency-absorption tuples for spectrum - absorption_spectrum = create_frequency_spectrum(frequencies, absorption_str) + .. math:: - scene_dict[f"shape_{i}"] = { - "type": "ply", - "filename": ply_file, - "bsdf": { - "type": "acousticbsdf", - "absorption": { - "type": "spectrum", - "value": absorption_spectrum, - }, - "scattering": { - "type": "spectrum", - "value": scattering_spectrum, - }, - }, + T_s = + \frac{ + \displaystyle \int_{0}^{\infty} t \cdot p^2(t)\,\mathrm{d}t + }{ + \displaystyle \int_{0}^{\infty} p^2(t)\,\mathrm{d}t } - gmsh.finalize() # Finalize AFTER the loop + where :math:`p(t)` is the room impulse response sound pressure. - return mi.load_dict(scene_dict) + Using the energy decay curve :math:`e(t)`, the parameter can be + computed efficiently via the EDC identity as -def set_progress_and_save(percentage, result_container, json_file_path): - result = result_container["results"][0] - result["percentage"] = percentage - result.pop("percentages", None) - # Save the updated JSON - with open(json_file_path, "w") as json_output: - json_output.write(json.dumps(result_container, indent=4, allow_nan=False)) + .. math:: - - -def prepare_result_responses(result_container: dict) -> None: - """Reset response result containers to the CHORAS EDC/parameter format.""" - for response in result_container["results"][0]["responses"]: - response["receiverResults"] = [] - response["parameters"] = {key: [] for key in RESULT_PARAMETER_KEYS} - -def finite_array(values, nan=0.0, posinf=0.0, neginf=0.0) -> np.ndarray: - return np.nan_to_num( - np.asarray(values, dtype=float), - nan=nan, - posinf=posinf, - neginf=neginf, - ) - -def json_scalar(value): - """Convert numpy scalar values to regular JSON scalar values.""" - if isinstance(value, np.generic): - return value.item() - return value - -def edc_to_db(edc: pf.TimeData, dynamic_range_db: float) -> tuple: - """Convert an EDC to dB and clip it to the requested dynamic range. - - Returns (edc_db, edc_db_absolute) where edc_db is normalized to peak=0dB - and edc_db_absolute uses absolute dB reference for SPL calculation. - """ - edc_energy = np.nan_to_num( - np.asarray(edc.time, dtype=float), - nan=0.0, - posinf=0.0, - neginf=0.0, - ) - - # For pyrato compatibility: keep energy form but ensure minimum values - # to avoid log(0) issues in pyrato functions - edc_energy_safe = np.maximum(edc_energy, 1e-15) - - # Calculate absolute dB (referenced to 1e-12 W/m²) - edc_db_absolute = 10 * np.log10(edc_energy_safe / 1e-12) - - # Normalize EDC to peak = 0 dB for display - peak_energy = np.max(edc_energy_safe, axis=-1, keepdims=True) - edc_normalized = edc_energy_safe / peak_energy - edc_db_normalized = 10 * np.log10(edc_normalized) - - # Clip to dynamic range - limit = -dynamic_range_db - edc_db_normalized = np.maximum(edc_db_normalized, limit) - - return edc_db_normalized, edc_db_absolute - -def store_receiver_results( - response: dict, - edc_db: np.ndarray, - times: list, - frequencies: np.ndarray, -) -> None: - """Store one EDC result object per frequency band.""" - # Ensure all inputs are proper numeric types - edc_db = np.asarray(edc_db, dtype=np.float64) - frequencies = np.asarray(frequencies, dtype=np.float64) - - response["receiverResults"] = [ - { - "data": finite_array(edc_db[i_frequency], nan=0.0, posinf=0.0, neginf=0.0).tolist(), - "t": times if isinstance(times, list) else times.tolist(), - "frequency": json_scalar(frequencies[i_frequency]), - "type": "edc", - } - for i_frequency in range(len(frequencies)) - ] - -def etc_to_edc( - etc: pf.TimeData, - lower_frequency_cutoffs: np.ndarray, - upper_frequency_cutoffs: np.ndarray, -) -> pf.TimeData: - """Convert energy time curve into energy decay curve. + T_s = + \frac{ + \displaystyle \int_{0}^{\infty} e(t)\,\mathrm{d}t + }{ + e(0) + }. Parameters ---------- - etc : pf.TimeData - energy time curve of cshape (..., n_bands). - lower_frequency_cutoffs : np.ndarray - lower cutoff frequencies from the frequency bands of shape (n_bands). - upper_frequency_cutoffs : np.ndarray - lower cutoff frequencies from the frequency bands of shape (n_bands). - - Results - ------- - edc : pf.TimeData - Resulting energy decay curve. - """ - full_frequency_range = np.max(upper_frequency_cutoffs) - np.min( - lower_frequency_cutoffs - ) - bandwidth = upper_frequency_cutoffs - lower_frequency_cutoffs - etc_eq = etc * (bandwidth / full_frequency_range) - edc = pyrato.edc.schroeder_integration(etc_eq, is_energy=True) - return edc - -def center_time(edc: pf.TimeData) -> np.ndarray: - edc_time = np.asarray(edc.time) - energy = np.empty_like(edc_time) - energy[..., :-1] = edc_time[..., :-1] - edc_time[..., 1:] - energy[..., -1] = edc_time[..., -1] - np.maximum(energy, 0, out=energy) - - denominator = np.sum(energy, axis=-1) - numerator = np.sum(energy * np.asarray(edc.times), axis=-1) - return np.divide( - numerator, - denominator, - out=np.zeros_like(denominator, dtype=float), - where=denominator > 0, - ) + energy_decay_curve : pyfar.TimeData + Energy decay curve of the room impulse response. The EDC must + start at time zero and must have equal time spacing. -def calculate_reverberation_time_from_db(edc_db: np.ndarray, times: np.ndarray, db_start: float, db_end: float) -> np.ndarray: - """Calculate reverberation time from dB decay curve. - - Fit a line between db_start and db_end points and extrapolate to -60 dB. - - Parameters - ---------- - edc_db : np.ndarray - EDC in dB (shape: n_bands x n_times) - times : np.ndarray - Time array (shape: n_times) - db_start : float - Starting dB level for fitting (e.g., -5) - db_end : float - Ending dB level for fitting (e.g., -25 for T20, -35 for T30, -10 for EDT) - Returns ------- - np.ndarray - Reverberation times for each band (shape: n_bands) + center_time : numpy.ndarray + Center time (:math:`T_s`) in seconds, + shaped according to the channel shape of the input EDC. + + References + ---------- + .. [#isoTs] ISO 3382, Acoustics — Measurement of the reverberation + time of rooms with reference to other acoustical parameters. """ - n_bands = edc_db.shape[0] - rt = np.zeros(n_bands) - - for i_band in range(n_bands): - decay = edc_db[i_band, :] - - # Find indices where decay is within [db_start, db_end] - mask = (decay >= db_end) & (decay <= db_start) - - if np.sum(mask) < 2: - # Not enough points to fit - rt[i_band] = 0.0 - continue - - # Fit a line to the decay in this range - valid_times = times[mask] - valid_decay = decay[mask] - - # Linear regression: decay = a * time + b - coeffs = np.polyfit(valid_times, valid_decay, 1) - slope = coeffs[0] # dB/second - - if slope >= 0: - # Not decaying properly - rt[i_band] = 0.0 - continue - - # Extrapolate to -60 dB: -60 = slope * t + b - # t = (-60 - b) / slope - intercept = coeffs[1] - t_60 = (-60.0 - intercept) / slope - - # Ensure positive and reasonable value - if t_60 > 0 and t_60 < 100: # Max 100 seconds is reasonable for indoor rooms - rt[i_band] = t_60 - else: - rt[i_band] = 0.0 - - return rt + if not isinstance(energy_decay_curve, pf.TimeData): + raise TypeError( + "energy_decay_curve must be a pyfar.TimeData or derived object.") -def calculate_and_store_acoustic_parameters( - edc: pf.TimeData, - edc_db_absolute: np.ndarray, - result_container: dict, - i_rec: int) -> None: - """Calculate and store acoustic parameters for a receiver. + if not np.isclose(energy_decay_curve.times[0], 0.0): + raise ValueError("energy_decay_curve must start at time zero.") - Parameters - ---------- - edc : pf.TimeData - Energy decay curve (in energy domain, not dB). - edc_db_absolute : np.ndarray - Energy decay curve in absolute dB (for SPL at t=0). - result_container : dict - Result container to store parameters. - i_rec : int - Receiver index. - """ - response = result_container["results"][0]["responses"][i_rec] - parameters = response.setdefault( - "parameters", {key: [] for key in ["t20", "t30", "c80", "d50", "ts", "spl_t0_freq", "edt"]} + if np.any(energy_decay_curve.time[..., 0] == 0): + raise ValueError( + "Initial energy of energy_decay_curve must not be zero.") + + dt = np.diff(energy_decay_curve.times) + if not np.allclose(dt, dt[0]): + raise ValueError( + "energy_decay_curve must have equal time spacing.") + + sampling_interval = dt[0] + initial_energy = energy_decay_curve.time[..., 0] + center_time = ( + np.nansum(energy_decay_curve.time, axis=-1) + * sampling_interval + / initial_energy ) - times = np.asarray(edc.times) - - try: - t20 = calculate_reverberation_time_from_db(edc_db_absolute, times, db_start=-5.0, db_end=-25.0) - parameters["t20"] = t20.tolist() - except Exception as e: - parameters["t20"] = [0.0] * len(edc.time) - - try: - t30 = calculate_reverberation_time_from_db(edc_db_absolute, times, db_start=-5.0, db_end=-35.0) - parameters["t30"] = t30.tolist() - except Exception as e: - parameters["t30"] = [0.0] * len(edc.time) - - try: - c80 = pyrato.parameters.clarity(edc, 80) - c80 = finite_array(c80, nan=0.0, neginf=0.0, posinf=0.0) - parameters["c80"] = c80.tolist() - except Exception as e: - parameters["c80"] = [0.0] * len(edc.time) - - try: - d50 = pyrato.parameters.definition(edc, 50) * 100 - d50 = finite_array(d50, nan=0.0, neginf=0.0, posinf=0.0) - parameters["d50"] = d50.tolist() - except Exception as e: - parameters["d50"] = [0.0] * len(edc.time) - - try: - ts = center_time(edc) * 1000 - ts = finite_array(ts, nan=0.0, neginf=0.0, posinf=0.0) - parameters["ts"] = ts.tolist() - except Exception as e: - parameters["ts"] = [0.0] * len(edc.time) - - try: - spl = edc_db_absolute[..., 0] - parameters["spl_t0_freq"] = finite_array(spl, nan=0.0, neginf=0.0, posinf=0.0).tolist() - except Exception as e: - parameters["spl_t0_freq"] = [0.0] * len(edc.time) - - try: - edt = calculate_reverberation_time_from_db(edc_db_absolute, times, db_start=0.0, db_end=-10.0) - parameters["edt"] = edt.tolist() - except Exception as e: - parameters["edt"] = [0.0] * len(edc.time) + return center_time + +def finite_array(values, nan=0.0, posinf=0.0, neginf=0.0) -> np.ndarray: + return np.nan_to_num( + np.asarray(values, dtype=float), + nan=nan, + posinf=posinf, + neginf=neginf, + ) \ No newline at end of file diff --git a/misuka_method/pyproject.toml b/misuka_method/pyproject.toml index 432ca08..77b53c5 100644 --- a/misuka_method/pyproject.toml +++ b/misuka_method/pyproject.toml @@ -30,8 +30,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - ] dependencies = [ "numpy>=1.23.0", diff --git a/misuka_method/tests/test_input_misuka.json b/misuka_method/tests/test_input_misuka.json index de67c8f..add049c 100644 --- a/misuka_method/tests/test_input_misuka.json +++ b/misuka_method/tests/test_input_misuka.json @@ -21,7 +21,8 @@ "scattering_500": 0.1, "scattering_1000": 0.1, "scattering_2000": 0.1, - "scattering_4000": 0.1 + "scattering_4000": 0.1, + "log_data": "no" }, "results": [ { diff --git a/misuka_method/tests/test_misuka_cli.py b/misuka_method/tests/test_misuka_cli.py index 491411b..9097a91 100644 --- a/misuka_method/tests/test_misuka_cli.py +++ b/misuka_method/tests/test_misuka_cli.py @@ -5,6 +5,7 @@ import numpy as np from pathlib import Path from unittest.mock import patch +import pyfar from misuka_interface import main, misukaMethod @@ -12,40 +13,6 @@ class TestMisukaIntegration: """Test suite for misuka integration.""" - def test_parse_absorption_from_json(self): - """Test parsing absorption coefficients from JSON.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - frequencies = [100, 500, 20000] - absorption_coefficients = { - "floor": "0.1, 0.5, 0.1", - "wall": "0.2, 0.4, 0.3", - "ceiling": "0.15, 0.25, 0.35" - } - - surfaces, absorption_values = method.parse_absorption_from_json( - frequencies, - absorption_coefficients - ) - - assert len(surfaces) == 3 - assert surfaces == ["floor", "wall", "ceiling"] - assert len(absorption_values) == 3 - - # Check floor absorption values - assert absorption_values[0] == [(100, 0.1), (500, 0.5), (20000, 0.1)] - - # Check wall absorption values - assert absorption_values[1] == [(100, 0.2), (500, 0.4), (20000, 0.3)] - - def test_convert_msh_to_obj(self, tmp_path): - """Test MSH to OBJ conversion method exists.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - assert hasattr(method, 'convert_msh_to_obj') - assert callable(method.convert_msh_to_obj) - def test_misuka_method_initialization(self, create_temporary_input_file): """Test misukaMethod initialization.""" method = misukaMethod(input_json_path=create_temporary_input_file) @@ -54,79 +21,6 @@ def test_misuka_method_initialization(self, create_temporary_input_file): assert method.input_json_path == create_temporary_input_file assert hasattr(method, 'run_simulation') assert hasattr(method, '_misuka_method') - assert hasattr(method, 'parse_absorption_from_json') - - def test_absorption_parsing_with_whitespace(self): - """Test absorption parsing handles whitespace correctly.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - frequencies = [100, 500, 1000] - absorption_coefficients = { - "room": "0.1 , 0.5 , 0.3" # Extra spaces - } - - surfaces, absorption_values = method.parse_absorption_from_json( - frequencies, - absorption_coefficients - ) - - assert absorption_values[0] == [(100, 0.1), (500, 0.5), (1000, 0.3)] - - def test_absorption_parsing_empty_dict(self): - """Test absorption parsing with empty dictionary.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - frequencies = [100, 500] - absorption_coefficients = {} - - surfaces, absorption_values = method.parse_absorption_from_json( - frequencies, - absorption_coefficients - ) - - assert len(surfaces) == 0 - assert len(absorption_values) == 0 - - def test_absorption_values_all_numeric(self): - """Test that parsed absorption values are numeric.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - frequencies = [100, 250, 500, 1000, 2000] - absorption_coefficients = { - "floor": "0.6, 0.69, 0.71, 0.7, 0.63", - "wall": "0.5, 0.55, 0.60, 0.62, 0.61" - } - - surfaces, absorption_values = method.parse_absorption_from_json( - frequencies, - absorption_coefficients - ) - - # Verify all values are floats - for surface_absorptions in absorption_values: - for freq, absorption in surface_absorptions: - assert isinstance(freq, (int, float)) - assert isinstance(absorption, float) - assert 0 <= absorption <= 1.0 - - def test_misuka_method_has_required_methods(self): - """Test that misukaMethod has all required methods.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - required_methods = [ - 'run_simulation', - '_misuka_method', - 'parse_absorption_from_json', - 'convert_msh_to_obj' - ] - - for method_name in required_methods: - assert hasattr(method, method_name), f"Missing method: {method_name}" - assert callable(getattr(method, method_name)) def test_json_loading_with_absorption_coefficients(self, create_temporary_input_file): """Test loading JSON with absorption coefficients.""" @@ -145,148 +39,6 @@ def test_json_loading_with_absorption_coefficients(self, create_temporary_input_ assert "sourceZ" in result assert "frequencies" in result - def test_absorption_coefficient_format_conversion(self): - """Test conversion of absorption format matches Mitsuba spec.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - frequencies = [100, 500, 20000] - absorption_coefficients = { - "floor": "0.1, 0.5, 0.1" - } - - surfaces, absorption_values = method.parse_absorption_from_json( - frequencies, - absorption_coefficients - ) - - # Should match Mitsuba format: [(freq, value), ...] - expected_format = [(100, 0.1), (500, 0.5), (20000, 0.1)] - assert absorption_values[0] == expected_format - - def test_multiple_surfaces_parsing(self): - """Test parsing multiple surfaces with different absorption profiles.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - frequencies = [100, 500, 1000, 2000, 5000, 10000] - absorption_coefficients = { - "highly_absorbent": "0.9, 0.85, 0.8, 0.75, 0.7, 0.65", - "slightly_absorbent": "0.1, 0.12, 0.15, 0.18, 0.2, 0.22", - "neutral": "0.5, 0.5, 0.5, 0.5, 0.5, 0.5" - } - - surfaces, absorption_values = method.parse_absorption_from_json( - frequencies, - absorption_coefficients - ) - - assert len(surfaces) == 3 - assert len(absorption_values[0]) == 6 - - # Verify highly absorbent has high values - for freq, absorption in absorption_values[0]: - assert absorption >= 0.65 - - # Verify slightly absorbent has low values - for freq, absorption in absorption_values[1]: - assert absorption <= 0.22 - - def test_frequency_absorption_tuple_structure(self): - """Test that output tuples have correct structure.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - frequencies = [125, 250, 500] - absorption_coefficients = {"wall": "0.2, 0.3, 0.4"} - - surfaces, absorption_values = method.parse_absorption_from_json( - frequencies, - absorption_coefficients - ) - - absorption_array = absorption_values[0] - - # Each element should be a tuple of (freq, absorption) - assert len(absorption_array) == 3 - for item in absorption_array: - assert isinstance(item, tuple) - assert len(item) == 2 - freq, absorption = item - assert freq in frequencies - assert 0 <= absorption <= 1.0 - - def test_absorption_value_range(self): - """Test that absorption values are within valid range [0, 1].""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - frequencies = [100, 500, 20000] - absorption_coefficients = { - "normal": "0.0, 0.5, 1.0", - "mid_range": "0.25, 0.5, 0.75" - } - - surfaces, absorption_values = method.parse_absorption_from_json( - frequencies, - absorption_coefficients - ) - - for surface_absorptions in absorption_values: - for freq, absorption in surface_absorptions: - assert 0.0 <= absorption <= 1.0, \ - f"Absorption value {absorption} outside valid range [0, 1]" - - def test_surface_name_preservation(self): - """Test that surface names are preserved correctly.""" - with patch.object(misukaMethod.__bases__[0], '__init__', lambda x, y: None): - method = misukaMethod() - - frequencies = [100, 500] - surface_names = ["floor", "wall1", "wall2", "ceiling", "back_wall"] - absorption_coefficients = { - name: "0.2, 0.3" for name in surface_names - } - - surfaces, absorption_values = method.parse_absorption_from_json( - frequencies, - absorption_coefficients - ) - - # Note: dict order depends on Python version, but all should be present - assert set(surfaces) == set(surface_names) - - -class TestMisukaSceneGeneration: - """Test scene generation for Mitsuba.""" - - def test_scene_dict_structure(self, create_temporary_input_file): - """Test that scene dictionary has correct structure.""" - with open(create_temporary_input_file, 'r') as f: - data = json.load(f) - - # Should have required keys - assert "simulationSettings" in data - assert "results" in data - assert data["simulationSettings"]["speed_of_sound"] == 343 - assert data["simulationSettings"]["max_time"] == 0.1 - - def test_source_receiver_coordinates(self, create_temporary_input_file): - """Test source and receiver coordinate extraction.""" - with open(create_temporary_input_file, 'r') as f: - data = json.load(f) - - result = data["results"][0] - - source_coords = [result["sourceX"], result["sourceY"], result["sourceZ"]] - assert len(source_coords) == 3 - assert all(isinstance(c, (int, float)) for c in source_coords) - - response = result["responses"][0] - receiver_coords = [response["x"], response["y"], response["z"]] - assert len(receiver_coords) == 3 - assert all(isinstance(c, (int, float)) for c in receiver_coords) - class TestMisukaOutput: """Test output generation from misuka simulations.""" @@ -301,30 +53,28 @@ def test_receiver_results_structure(self, create_temporary_input_file): # Should have receiverResults key assert "receiverResults" in response +class TestMisukaRunthrough: + """Test the full run-through of the misuka method.""" -def test_misuka_method_cli(mock_requests_post, create_temporary_input_file): - """Test the misuka method CLI.""" - # Set JSON_PATH environment variable and call main() directly - os.environ["JSON_PATH"] = create_temporary_input_file - main() + def test_full_simulation_run(self, create_temporary_input_file): - with open(create_temporary_input_file, 'r') as f: - data = json.load(f) + print("pyfar file:", pyfar.__file__) - # Verify results structure - assert "results" in data - assert len(data["results"]) > 0 - - # Verify that requests.post was called (save_results was executed) - mock_requests_post.assert_called_once() + print("pyfar version:", pyfar.__version__) + print("has constants:", hasattr(pyfar, "constants")) -def test_misuka_method_cli_missing_json_path(mock_requests_post): - """Test the misuka 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() + print("dir contains constants:", "constants" in dir(pyfar)) + """Test running the full simulation.""" + method = misukaMethod(input_json_path=create_temporary_input_file) + + # Run the simulation + method.run_simulation() + + # Check that results are populated + with open(create_temporary_input_file, 'r') as f: + data = json.load(f) + + assert "results" in data + assert len(data["results"]) > 0 + assert "responses" in data["results"][0] From 278e40c52b973dec99ef66c2143e1adafce0fedd Mon Sep 17 00:00:00 2001 From: timon Date: Mon, 29 Jun 2026 17:57:02 +0200 Subject: [PATCH 03/11] clearing some unused code and adding log option --- example_settings/misuka_setting.json | 17 +-- .../misuka_interface/misuka_interface.py | 109 +++++++----------- misuka_method/tests/test_input_misuka.json | 2 +- 3 files changed, 54 insertions(+), 74 deletions(-) diff --git a/example_settings/misuka_setting.json b/example_settings/misuka_setting.json index 1e64126..91b3c6c 100644 --- a/example_settings/misuka_setting.json +++ b/example_settings/misuka_setting.json @@ -56,15 +56,16 @@ "endAdornment": "m/s" }, { - "name": "Dynamic Range", + "name": "Limit Dynamic Range to 100dB", "id": "dynamic_range_db", - "display": "text", - "type": "integer", - "min": 5, - "max": 200, - "step": 10, - "default": 100, - "endAdornment": "dB" + "type": "string", + "display": "radio", + "options": + { + "yes": "yes", + "no": "no" + }, + "default": "no" }, { "name": "Scattering for 125 Hz", diff --git a/misuka_method/misuka_interface/misuka_interface.py b/misuka_method/misuka_interface/misuka_interface.py index 76981c8..2fd6f45 100644 --- a/misuka_method/misuka_interface/misuka_interface.py +++ b/misuka_method/misuka_interface/misuka_interface.py @@ -49,34 +49,41 @@ def _misuka_method(self, json_file_path: str | Path) -> None: json_file_path: Path to the JSON configuration file """ - # ---------------------------------------------------------------- # ------- fetching simulation settings and initialization ------- # ---------------------------------------------------------------- - print(f'Using variant: {mi.variant()}') - - # Load the input JSON file - with open(json_file_path, "r") as json_file: - result_container = json.load(json_file) - try: - simulation_settings = result_container["simulationSettings"] - speed_of_sound = simulation_settings["speed_of_sound"] - max_time = simulation_settings["max_time"] - time_bins = simulation_settings["sampling_rate"] - rpf = simulation_settings["rays_per_frequency"] - sound_power_W = simulation_settings["sound_power_W"] - n_receivers = len(result_container["results"][0]["responses"]) - frequencies = result_container["results"][0]["frequencies"] - msh_path = result_container["msh_path"] - absorption_map = result_container["absorption_coefficients"] - dynamic_range_db = simulation_settings["dynamic_range_db"] - #log_data = simulation_settings["log_data"] == "yes" + # Load the input JSON file + with open(json_file_path, "r") as json_file: + result_container = json.load(json_file) except KeyError as e: raise KeyError(f"Could not fetch the simulation settings for misuka T.T: {e}") - source_intensity = sound_power_W + simulation_settings = result_container["simulationSettings"] + speed_of_sound = simulation_settings["speed_of_sound"] + max_time = simulation_settings["max_time"] + time_bins = simulation_settings["sampling_rate"] + rpf = simulation_settings["rays_per_frequency"] + sound_power_W = simulation_settings["sound_power_W"] + n_receivers = len(result_container["results"][0]["responses"]) + frequencies = result_container["results"][0]["frequencies"] + msh_path = result_container["msh_path"] + absorption_map = result_container["absorption_coefficients"] + dynamic_range_db = simulation_settings["dynamic_range_db"] == "yes" + log_data = simulation_settings["log_data"] == "yes" + + if log_data: + print(f'Using variant: {mi.variant()}') + print(f'Input value for speed of sound: {speed_of_sound}') + print(f'Input value for maximum simulation time: {max_time}') + print(f'Input value for time_bins: {time_bins}') + print(f'Input value for rays per frequency: {rpf}') + print(f'Input value for source sound power in W: {sound_power_W}') + print(f'Rendered frequencies: {frequencies}') + print(f'Input values for absorption: {absorption_map}') + print(f'Use dynamic range limiter: {dynamic_range_db}') + frequency_range = (float(np.min(frequencies)), float(np.max(frequencies))) f_center, f_lower, f_upper = pf.constants.fractional_octave_frequencies_exact(1, frequency_range) @@ -107,13 +114,11 @@ def _misuka_method(self, json_file_path: str | Path) -> None: for i in range(n_receivers) ] + if log_data: print(f'Source coordinates: {source_coords}\n Reciever coordinates: {receiver_coords}') + # updating percentage of progress set_progress_and_save(25, result_container, json_file_path) - # ---------------------------------------------------------------- - # --- initializing scene with geometry & integrator --- - # ---------------------------------------------------------------- - # set up the integrator integrator_acoustic = mi.load_dict( { @@ -124,7 +129,11 @@ def _misuka_method(self, json_file_path: str | Path) -> None: } ) - print(f'Simulationsdauer: {max_time}') + if log_data: print(f'Integrator: {integrator_acoustic}') + + # ---------------------------------------------------------------- + # --------------------- initializing scene --------------------- + # ---------------------------------------------------------------- # define scene as dictionary scene_dict = { @@ -173,7 +182,10 @@ def _misuka_method(self, json_file_path: str | Path) -> None: "bsdf": {"type": "ref", "id": f"bsdf_{i}"}, } - # render for every source to every reciever + # ---------------------------------------------------------------- + # -------- rendering for every source to every reciever ---------- + # ---------------------------------------------------------------- + for i_src, source_coord in enumerate(source_coords): for i_rec, receiver_coord in enumerate(receiver_coords): @@ -204,14 +216,14 @@ def _misuka_method(self, json_file_path: str | Path) -> None: 'type': 'area', 'radiance': { 'type': 'uniform', - 'value': source_intensity, + 'value': sound_power_W, } } } scene = mi.load_dict(scene_dict) - #print(f'Meine Szene: {scene}') + if log_data: print(f'Loaded Scene for reciever at {receiver_coord} and source at {source_coord}: {scene}') etc = mi.render(scene, sensor=microphone, integrator=integrator_acoustic,spp=rpf) etc_signal = pf.Signal(etc.numpy().T, sampling_rate=time_bins / max_time, domain='time') @@ -220,35 +232,11 @@ def _misuka_method(self, json_file_path: str | Path) -> None: edc_normalized = pf.dsp.normalize(edc) edc_normalized_db = 10*np.log10(edc_normalized.time/1e-12) edc_normalized_db = np.squeeze(edc_normalized_db, axis=0) - edc_normalized_db = finite_array(edc_normalized_db, nan=0.0, neginf=-400.0, posinf=0.0) - # limit = np.max(edc_normalized_db) - dynamic_range_db - # edc_normalized_db[edc_normalized_db None: edt = finite_array(edt, nan=0.0, neginf=0.0, posinf=0.0) result_container["results"][0]["responses"][i_rec]["parameters"]['edt'] = edt.tolist() - # print(t20.shape) - # print(t30.shape) - # print(c80.shape) - # print(d50.shape) - # print(ts.shape) - # print(edt.shape) - pressure_csv_path = str(json_file_path).replace(".json", "_pressure.csv") pressure_data = [] @@ -356,7 +337,6 @@ def create_frequency_spectrum(frequencies: list, values) -> list: return list(zip(frequencies, vals[:len(frequencies)])) - def export_physical_surface_to_ply(surface_name: str, entity_tags, ply_path: str | Path) -> Path: """Export only the triangular mesh faces of one physical surface to PLY.""" ply_path = Path(ply_path) @@ -409,7 +389,6 @@ def export_physical_surface_to_ply(surface_name: str, entity_tags, ply_path: str ).write(str(ply_path)) return ply_path - # copy pasted from pyrato def center_time(energy_decay_curve): r""" diff --git a/misuka_method/tests/test_input_misuka.json b/misuka_method/tests/test_input_misuka.json index add049c..bbaac9f 100644 --- a/misuka_method/tests/test_input_misuka.json +++ b/misuka_method/tests/test_input_misuka.json @@ -15,7 +15,7 @@ "sampling_rate": 10000, "sound_power_W": 1, "speed_of_sound": 343, - "dynamic_range_db": 100, + "dynamic_range_db": "no", "scattering_125": 0.1, "scattering_250": 0.1, "scattering_500": 0.1, From 7f77084e8aea2cd62221120e131882481761d07f Mon Sep 17 00:00:00 2001 From: timon Date: Mon, 29 Jun 2026 20:22:23 +0200 Subject: [PATCH 04/11] adjust common/exampleInput --- common/exampleInput_misuka.json | 5 +++-- misuka_method/misuka_interface/misuka_interface.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/common/exampleInput_misuka.json b/common/exampleInput_misuka.json index de67c8f..bbaac9f 100644 --- a/common/exampleInput_misuka.json +++ b/common/exampleInput_misuka.json @@ -15,13 +15,14 @@ "sampling_rate": 10000, "sound_power_W": 1, "speed_of_sound": 343, - "dynamic_range_db": 100, + "dynamic_range_db": "no", "scattering_125": 0.1, "scattering_250": 0.1, "scattering_500": 0.1, "scattering_1000": 0.1, "scattering_2000": 0.1, - "scattering_4000": 0.1 + "scattering_4000": 0.1, + "log_data": "no" }, "results": [ { diff --git a/misuka_method/misuka_interface/misuka_interface.py b/misuka_method/misuka_interface/misuka_interface.py index 2fd6f45..1032961 100644 --- a/misuka_method/misuka_interface/misuka_interface.py +++ b/misuka_method/misuka_interface/misuka_interface.py @@ -225,6 +225,7 @@ def _misuka_method(self, json_file_path: str | Path) -> None: if log_data: print(f'Loaded Scene for reciever at {receiver_coord} and source at {source_coord}: {scene}') + #TODO: use sound_power_W for scaling the etc etc = mi.render(scene, sensor=microphone, integrator=integrator_acoustic,spp=rpf) etc_signal = pf.Signal(etc.numpy().T, sampling_rate=time_bins / max_time, domain='time') etc_signal.time /= np.max(np.abs(etc_signal.time)) From 34198976a41241d735196be3d799da06c8e2029f Mon Sep 17 00:00:00 2001 From: timon Date: Thu, 2 Jul 2026 11:24:07 +0200 Subject: [PATCH 05/11] Remove obsolete pressure csv genration code after backend update --- .../misuka_interface/misuka_interface.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/misuka_method/misuka_interface/misuka_interface.py b/misuka_method/misuka_interface/misuka_interface.py index 1032961..7caeb42 100644 --- a/misuka_method/misuka_interface/misuka_interface.py +++ b/misuka_method/misuka_interface/misuka_interface.py @@ -276,21 +276,6 @@ def _misuka_method(self, json_file_path: str | Path) -> None: edt = finite_array(edt, nan=0.0, neginf=0.0, posinf=0.0) result_container["results"][0]["responses"][i_rec]["parameters"]['edt'] = edt.tolist() - pressure_csv_path = str(json_file_path).replace(".json", "_pressure.csv") - pressure_data = [] - - for time_idx, time_value in enumerate(etc_signal.times.tolist()): - row = {"t": time_value} - for band_idx, frequency in enumerate(frequencies): - row[f"{int(frequency)}Hz"] = float( - etc.numpy()[time_idx, band_idx, 0] - ) - pressure_data.append(row) - - df = pd.DataFrame(pressure_data) - df.to_csv(pressure_csv_path, index=False) - - # update progress for each source in even steps to ~90% set_progress_and_save(35 + int(55/(n_receivers+1) * (i_src + 1)), result_container, json_file_path) From 6214c27a84f697eeef012b9ef8fa2cd9eaf51a36 Mon Sep 17 00:00:00 2001 From: tschnell <150254331+tmschnell@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:44:54 +0200 Subject: [PATCH 06/11] Update misuka_method/misuka_interface/misuka_interface.py Co-authored-by: Marco Berzborn --- .../misuka_interface/misuka_interface.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/misuka_method/misuka_interface/misuka_interface.py b/misuka_method/misuka_interface/misuka_interface.py index 7caeb42..c2f3cf1 100644 --- a/misuka_method/misuka_interface/misuka_interface.py +++ b/misuka_method/misuka_interface/misuka_interface.py @@ -73,16 +73,15 @@ def _misuka_method(self, json_file_path: str | Path) -> None: dynamic_range_db = simulation_settings["dynamic_range_db"] == "yes" log_data = simulation_settings["log_data"] == "yes" - if log_data: - print(f'Using variant: {mi.variant()}') - print(f'Input value for speed of sound: {speed_of_sound}') - print(f'Input value for maximum simulation time: {max_time}') - print(f'Input value for time_bins: {time_bins}') - print(f'Input value for rays per frequency: {rpf}') - print(f'Input value for source sound power in W: {sound_power_W}') - print(f'Rendered frequencies: {frequencies}') - print(f'Input values for absorption: {absorption_map}') - print(f'Use dynamic range limiter: {dynamic_range_db}') + logger.info(f'Using variant: {mi.variant()}') + logger.info(f'Input value for speed of sound: {speed_of_sound}') + logger.info(f'Input value for maximum simulation time: {max_time}') + logger.info(f'Input value for time_bins: {time_bins}') + logger.info(f'Input value for rays per frequency: {rpf}') + logger.info(f'Input value for source sound power in W: {sound_power_W}') + logger.info(f'Rendered frequencies: {frequencies}') + logger.info(f'Input values for absorption: {absorption_map}') + logger.info(f'Use dynamic range limiter: {dynamic_range_db}') frequency_range = (float(np.min(frequencies)), float(np.max(frequencies))) From 818ee2fe2dcd73e06e9bbdc07419cdf3a67e7022 Mon Sep 17 00:00:00 2001 From: tschnell <150254331+tmschnell@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:46:55 +0200 Subject: [PATCH 07/11] Update example_settings/misuka_setting.json Co-authored-by: Marco Berzborn --- example_settings/misuka_setting.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_settings/misuka_setting.json b/example_settings/misuka_setting.json index 91b3c6c..5ebf832 100644 --- a/example_settings/misuka_setting.json +++ b/example_settings/misuka_setting.json @@ -2,7 +2,7 @@ "type": "simulationSettings", "options": [ { - "name": "Rays per Frequencies", + "name": "Rays per Frequency Band", "id": "rays_per_frequency", "type": "integer", "display": "text", From b1ff936b2abef94f6d11a3bbc99ad7e30fe8927e Mon Sep 17 00:00:00 2001 From: tschnell <150254331+tmschnell@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:47:06 +0200 Subject: [PATCH 08/11] Update example_settings/misuka_setting.json Co-authored-by: Marco Berzborn --- example_settings/misuka_setting.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_settings/misuka_setting.json b/example_settings/misuka_setting.json index 5ebf832..f5700ea 100644 --- a/example_settings/misuka_setting.json +++ b/example_settings/misuka_setting.json @@ -12,7 +12,7 @@ "default": 1048576 }, { - "name": "Maximum Time to Simulate", + "name": "Maximum Response Length", "id": "max_time", "type": "float", "display": "text", From 9e84b8268a35fd1f237f904c5db2918b4bff570f Mon Sep 17 00:00:00 2001 From: tschnell <150254331+tmschnell@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:47:21 +0200 Subject: [PATCH 09/11] Update example_settings/misuka_setting.json Co-authored-by: Marco Berzborn --- example_settings/misuka_setting.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_settings/misuka_setting.json b/example_settings/misuka_setting.json index f5700ea..5dd2458 100644 --- a/example_settings/misuka_setting.json +++ b/example_settings/misuka_setting.json @@ -23,7 +23,7 @@ "endAdornment": "s" }, { - "name": "Time Bins", + "name": "Histogram sampling rate", "id": "sampling_rate", "type": "integer", "display": "text", From 41c8a06e944af833aa3ad53ce8d415b76062e273 Mon Sep 17 00:00:00 2001 From: timon Date: Tue, 21 Jul 2026 15:18:23 +0200 Subject: [PATCH 10/11] Changes following the pull request feedback --- example_settings/misuka_setting.json | 89 +--- misuka_method/Dockerfile | 90 ++-- .../misuka_interface/misuka_interface.py | 441 +++++++++++++----- misuka_method/pyproject.toml | 12 +- misuka_method/tests/test_input_misuka.json | 9 +- 5 files changed, 384 insertions(+), 257 deletions(-) diff --git a/example_settings/misuka_setting.json b/example_settings/misuka_setting.json index 5dd2458..9799215 100644 --- a/example_settings/misuka_setting.json +++ b/example_settings/misuka_setting.json @@ -33,17 +33,6 @@ "default": 20000, "endAdornment": "Hz" }, - { - "name": "Sound Power of Source", - "id": "sound_power_W", - "display": "text", - "type": "float", - "min": 0.1, - "max": 50, - "step": 5, - "default": 1, - "endAdornment": "W" - }, { "name": "Speed of Sound", "id": "speed_of_sound", @@ -68,82 +57,12 @@ "default": "no" }, { - "name": "Scattering for 125 Hz", - "id": "scattering_125", - "display": "text", - "type": "float", - "min": 0, - "max": 1, - "step": 0.1, - "default": 0.1, - "endAdornment": "" - }, - { - "name": "Scattering for 250 Hz", - "id": "scattering_250", - "display": "text", - "type": "float", - "min": 0, - "max": 1, - "step": 0.1, - "default": 0.1, - "endAdornment": "" - }, - { - "name": "Scattering for 500 Hz", - "id": "scattering_500", - "display": "text", - "type": "float", - "min": 0, - "max": 1, - "step": 0.1, - "default": 0.1, - "endAdornment": "" - }, - { - "name": "Scattering for 1000 Hz", - "id": "scattering_1000", - "display": "text", - "type": "float", - "min": 0, - "max": 1, - "step": 0.1, - "default": 0.1, - "endAdornment": "" - }, - { - "name": "Scattering for 2000 Hz", - "id": "scattering_2000", - "display": "text", - "type": "float", - "min": 0, - "max": 1, - "step": 0.1, - "default": 0.1, - "endAdornment": "" - }, - { - "name": "Scattering for 4000 Hz", - "id": "scattering_4000", + "name": "Scattering as [s(125), s(250), s(500), s(1000), s(2000)]", + "id": "scattering_coefficients", "display": "text", - "type": "float", - "min": 0, - "max": 1, - "step": 0.1, - "default": 0.1, - "endAdornment": "" - }, - { - "name": "Provide more Log Data", - "id": "log_data", "type": "string", - "display": "radio", - "options": - { - "yes": "yes", - "no": "no" - }, - "default": "no" + "default": "0.1, 0.1, 0.1, 0.1, 0.1", + "endAdornment": "" } ], diff --git a/misuka_method/Dockerfile b/misuka_method/Dockerfile index cc3cb47..c7ac057 100644 --- a/misuka_method/Dockerfile +++ b/misuka_method/Dockerfile @@ -1,5 +1,5 @@ -FROM ubuntu:22.04 -#python 3.11 image statt ubuntu +# ---------- Stage 1: builder ---------- +FROM ubuntu:22.04 AS builder ENV DEBIAN_FRONTEND=noninteractive @@ -25,8 +25,10 @@ RUN add-apt-repository ppa:deadsnakes/ppa && \ clang-17 \ libc++-17-dev \ libc++abi-17-dev \ + libllvm17 \ cmake \ ninja-build \ + ccache \ git \ build-essential \ zlib1g-dev \ @@ -37,42 +39,78 @@ RUN add-apt-repository ppa:deadsnakes/ppa && \ ENV CC=clang-17 ENV CXX=clang++-17 +ENV CMAKE_CXX_COMPILER_LAUNCHER=ccache +ENV CMAKE_C_COMPILER_LAUNCHER=ccache RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 -RUN python3.11 -m pip install --upgrade pip - -# Install typing_extensions so stub generator works on Python < 3.11 -RUN python3.11 -m pip install typing_extensions - -# Clone misuka with ALL submodules -RUN git clone --recursive \ - https://github.com/misuka-renderer/misuka.git \ - /opt/misuka && \ +RUN python3.11 -m pip install --upgrade pip typing_extensions + +# Shallow clone of misuka at the pinned commit +RUN mkdir -p /opt/misuka && cd /opt/misuka && \ + git init && \ + git remote add origin https://github.com/misuka-renderer/misuka.git && \ + git fetch --depth 1 origin 0362f576f362533ae3c6b94a369381e7dcfb0843 && \ + git checkout FETCH_HEAD && \ + git submodule update --init --recursive --depth 1 + +# Build misuka from source, with ccache to speed up rebuilds +RUN --mount=type=cache,target=/root/.ccache \ cd /opt/misuka && \ - git checkout 9845db9359ed7f489b5633195df3ce0acc09afe4 && \ - git submodule update --init --recursive - -# Build misuka from source, explicitly pointing cmake to Python 3.10 -RUN cd /opt/misuka && \ mkdir build && cd build && \ cmake -GNinja .. \ - -DMI_DEFAULT_VARIANTS="scalar_rgb;llvm_ad_rgb;scalar_acoustic;llvm_ad_acoustic" \ + -DMI_DEFAULT_VARIANTS="scalar_rgb;scalar_acoustic;llvm_ad_acoustic" \ -DCMAKE_BUILD_TYPE=Release \ -DPython_EXECUTABLE=/usr/bin/python3.11 \ -DPython3_EXECUTABLE=/usr/bin/python3.11 && \ - ninja + ninja && \ + ccache -s -# Add misuka build output to Python path -ENV PYTHONPATH="/opt/misuka/build/python" +WORKDIR /app +COPY misuka_method /app/misuka_method +RUN python3.11 -m pip install --target=/opt/misuka_python \ + numpy \ + requests \ + meshio \ + pyfar \ + pyrato \ + pandas && \ + python3.11 -m pip install --no-deps --target=/opt/misuka_python /app/misuka_method + +# ---------- Stage 2: runtime ---------- +FROM ubuntu:22.04 AS runtime + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + software-properties-common ca-certificates gnupg wget lsb-release && \ + add-apt-repository ppa:deadsnakes/ppa && \ + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc && \ + echo "deb http://apt.llvm.org/jammy/ llvm-toolchain-jammy-17 main" > /etc/apt/sources.list.d/llvm.list && \ + apt-get update && apt-get install -y \ + python3.11 \ + python3-pip \ + libc++1-17 \ + libc++abi1-17 \ + libllvm17 \ + libpng16-16 \ + libjpeg-turbo8 \ + libglu1-mesa \ + gmsh \ + python3-gmsh \ + zlib1g \ + && rm -rf /var/lib/apt/lists/* -# Install remaining Python dependencies +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 +# Misuka's compiled build output (libmitsuba.so, libdrjit-extra.so, plugins/, etc.) +COPY --from=builder /opt/misuka/build /opt/misuka/build -WORKDIR /app +# Python package and dependencies installed in the builder stage. +COPY --from=builder /opt/misuka_python /opt/misuka_python -COPY misuka_method /app/misuka_method -RUN python3.11 -m pip install /app/misuka_method +ENV LD_LIBRARY_PATH="/opt/misuka/build" +ENV PYTHONPATH="/opt/misuka_python:/opt/misuka/build/python:/usr/lib/python3/dist-packages" +ENV DRJIT_LIBLLVM_PATH="/usr/lib/x86_64-linux-gnu/libLLVM-17.so" WORKDIR /app/misuka_method - -CMD ["python3.11", "-m", "misuka_interface"] \ No newline at end of file +CMD ["python3.11", "-m", "misuka_interface"] diff --git a/misuka_method/misuka_interface/misuka_interface.py b/misuka_method/misuka_interface/misuka_interface.py index c2f3cf1..b6d60f8 100644 --- a/misuka_method/misuka_interface/misuka_interface.py +++ b/misuka_method/misuka_interface/misuka_interface.py @@ -11,8 +11,10 @@ import pandas as pd import os from pathlib import Path +import logging +logger = logging.getLogger(__name__) -mi.set_variant("cuda_acoustic", "llvm_acoustic", "cuda_ad_acoustic", "llvm_ad_acoustic") +mi.set_variant("llvm_ad_acoustic") from .definition import SimulationMethod @@ -31,72 +33,66 @@ def __init__(self, input_json_path: str | Path | None = None): super().__init__(input_json_path) def run_simulation(self) -> None: - """Run the simulation. - - Parameters - ---------- - json_file_path : str | Path | None, optional - Path to the JSON file. If not provided, uses the path from initialization. - """ - self._misuka_method(self.input_json_path) + """Run the simulation using the JSON file provided at initialization.""" + self._misuka_method() - def _misuka_method(self, json_file_path: str | Path) -> None: + def _misuka_method(self) -> None: """ - Run misuka simulation for acoustic wave propagation. - - Args: - json_file_path: Path to the JSON configuration file + Run the misuka simulation method using the input JSON file. + + Reads the simulation settings from the user configuration JSON file + (self.input_json_path), sets up the scene, and performs the simulation + for each source-receiver pair. Results are written back to the input + JSON file, including the EDC and acoustic parameters for each + source-receiver pair. + + Raises + ------ + KeyError + - If required keys are missing in the input JSON file. + + ValueError + - If a receiver is at the same position as the source. + - If the position of a source is the same as a receiver. """ - # ---------------------------------------------------------------- - # ------- fetching simulation settings and initialization ------- - # ---------------------------------------------------------------- - try: # Load the input JSON file - with open(json_file_path, "r") as json_file: + with open(self.input_json_path, "r") as json_file: result_container = json.load(json_file) except KeyError as e: raise KeyError(f"Could not fetch the simulation settings for misuka T.T: {e}") - simulation_settings = result_container["simulationSettings"] - speed_of_sound = simulation_settings["speed_of_sound"] - max_time = simulation_settings["max_time"] - time_bins = simulation_settings["sampling_rate"] - rpf = simulation_settings["rays_per_frequency"] - sound_power_W = simulation_settings["sound_power_W"] - n_receivers = len(result_container["results"][0]["responses"]) - frequencies = result_container["results"][0]["frequencies"] - msh_path = result_container["msh_path"] - absorption_map = result_container["absorption_coefficients"] - dynamic_range_db = simulation_settings["dynamic_range_db"] == "yes" - log_data = simulation_settings["log_data"] == "yes" - + try: + simulation_settings = result_container["simulationSettings"] + speed_of_sound = simulation_settings["speed_of_sound"] + max_time = simulation_settings["max_time"] + time_bins = simulation_settings["sampling_rate"] + rpf = simulation_settings["rays_per_frequency"] + n_receivers = len(result_container["results"][0]["responses"]) + frequencies = result_container["results"][0]["frequencies"] + msh_path = result_container["msh_path"] + absorption_map = result_container["absorption_coefficients"] + dynamic_range_limiter = simulation_settings["dynamic_range_db"] == "yes" + scattering_str = simulation_settings["scattering_coefficients"] + except KeyError as e: + raise KeyError(f"Missing required key in the input JSON file: {e}") + logger.info(f'Using variant: {mi.variant()}') - logger.info(f'Input value for speed of sound: {speed_of_sound}') - logger.info(f'Input value for maximum simulation time: {max_time}') - logger.info(f'Input value for time_bins: {time_bins}') - logger.info(f'Input value for rays per frequency: {rpf}') - logger.info(f'Input value for source sound power in W: {sound_power_W}') logger.info(f'Rendered frequencies: {frequencies}') + logger.info(f'Input value for rays per frequency: {rpf}') + logger.info(f'Input value for maximum response length: {max_time}') + logger.info(f'Input value for time_bins: {time_bins}') + logger.info(f'Input value for speed of sound: {speed_of_sound}') + logger.info(f'Use dynamic range limiter: {dynamic_range_limiter}') logger.info(f'Input values for absorption: {absorption_map}') - logger.info(f'Use dynamic range limiter: {dynamic_range_db}') - frequency_range = (float(np.min(frequencies)), float(np.max(frequencies))) f_center, f_lower, f_upper = pf.constants.fractional_octave_frequencies_exact(1, frequency_range) assert np.all(np.abs(f_center-frequencies)/frequencies < 1e-2), "Frequency mismatch between input frequencies and pyrato's fractional octave frequencies" frequencies_misuka = ", ".join(str(f) for f in frequencies) - #TODO: currently using scattering specified by misuka_setting / User... for future use from msh file - scattering_map = [ - simulation_settings[f"scattering_{int(f)}"] - for f in frequencies - ] - # Create frequency-scattering tuples for spectrum - scattering_spectrum = create_frequency_spectrum(frequencies, scattering_map) #TODO: when scattering is surface material dependent move this into for loop - source_coords = [ #TODO: when multiple soundsources are implemented adjust like receiver_coords [ result_container["results"][0]["sourceX"], @@ -113,10 +109,10 @@ def _misuka_method(self, json_file_path: str | Path) -> None: for i in range(n_receivers) ] - if log_data: print(f'Source coordinates: {source_coords}\n Reciever coordinates: {receiver_coords}') + logger.info(f'Source coordinates: {source_coords}\n Reciever coordinates: {receiver_coords}') # updating percentage of progress - set_progress_and_save(25, result_container, json_file_path) + set_progress_and_save(25, result_container, self.input_json_path) # set up the integrator integrator_acoustic = mi.load_dict( @@ -128,11 +124,7 @@ def _misuka_method(self, json_file_path: str | Path) -> None: } ) - if log_data: print(f'Integrator: {integrator_acoustic}') - - # ---------------------------------------------------------------- - # --------------------- initializing scene --------------------- - # ---------------------------------------------------------------- + logger.info(f'Integrator Setup: {integrator_acoustic}') # define scene as dictionary scene_dict = { @@ -140,50 +132,13 @@ def _misuka_method(self, json_file_path: str | Path) -> None: } # Extract physical group information from gmsh - gmsh.initialize() - gmsh.open(str(msh_path)) - surfaces = gmsh.model.getPhysicalGroups(2) - - # For each surface, create a separate PLY file - # Nach gmsh.open(str(msh_file)): - ply_dir = Path(msh_path).parent - - for i, (dim, tag) in enumerate(surfaces): - surface_name = gmsh.model.getPhysicalName(dim, tag) - absorption_str = absorption_map.get(surface_name, "0.1, 0.1, 0.1, 0.1, 0.1, 0.1") - entity_tags = gmsh.model.getEntitiesForPhysicalName(surface_name) - - ply_file = str(ply_dir / f"surface_{surface_name}.ply") # korrekt - export_physical_surface_to_ply(surface_name, entity_tags, ply_file) - absorption_spectrum = create_frequency_spectrum(frequencies, absorption_str) - - scene_dict[f"bsdf_{i}"] = { - "type": "twosided", - "id": f"bsdf_{i}", - "bsdf": { - "type": "acousticbsdf", - "absorption": { - "type": "spectrum", - "value": absorption_spectrum, - }, - "scattering": { - "type": "spectrum", - "value": scattering_spectrum, - }, - "specular_lobe_width": 0.001, - }, - } - - scene_dict[f"shape_{i}"] = { - "type": "ply", - "filename": ply_file, - "face_normals": True, - "bsdf": {"type": "ref", "id": f"bsdf_{i}"}, - } - - # ---------------------------------------------------------------- - # -------- rendering for every source to every reciever ---------- - # ---------------------------------------------------------------- + scene_dict = build_scene_from_gmsh( + scene_dict, + msh_path, + absorption_map, + frequencies, + scattering_str, + ) for i_src, source_coord in enumerate(source_coords): for i_rec, receiver_coord in enumerate(receiver_coords): @@ -207,6 +162,8 @@ def _misuka_method(self, json_file_path: str | Path) -> None: }, }) + logger.info(f'Loaded Microphone for reciever at {receiver_coord} and source at {source_coord}: {microphone}') + scene_dict["emitter"] = { "type": "sphere", "radius": 0.1, @@ -214,15 +171,15 @@ def _misuka_method(self, json_file_path: str | Path) -> None: "emitter": { 'type': 'area', 'radiance': { - 'type': 'uniform', - 'value': sound_power_W, + 'type': 'uniform', + 'value': 30.0 # not scaled yet but needs some value to successfully load scene } } } scene = mi.load_dict(scene_dict) - if log_data: print(f'Loaded Scene for reciever at {receiver_coord} and source at {source_coord}: {scene}') + logger.info(f'Loaded Scene for reciever at {receiver_coord} and source at {source_coord}: {scene}') #TODO: use sound_power_W for scaling the etc etc = mi.render(scene, sensor=microphone, integrator=integrator_acoustic,spp=rpf) @@ -230,11 +187,12 @@ def _misuka_method(self, json_file_path: str | Path) -> None: etc_signal.time /= np.max(np.abs(etc_signal.time)) edc = pr.edc.schroeder_integration(etc_signal, is_energy=True) edc_normalized = pf.dsp.normalize(edc) - edc_normalized_db = 10*np.log10(edc_normalized.time/1e-12) + with np.errstate(divide='ignore', invalid='ignore'): + edc_normalized_db = 10*np.log10(edc_normalized.time/1e-12) edc_normalized_db = np.squeeze(edc_normalized_db, axis=0) - edc_normalized_db = finite_array(edc_normalized_db, nan=0.0, neginf=-600.0, posinf=0.0) + edc_normalized_db = finite_array(edc_normalized_db, nan=0.0, neginf=-100, posinf=0.0) - if dynamic_range_db: + if dynamic_range_limiter: limit = np.max(edc_normalized_db) - 100 edc_normalized_db[edc_normalized_db None: ts = center_time(edc)*1000 # in ms TODO replace by pyrato 1.1.0 version result_container["results"][0]["responses"][i_rec]["parameters"]['ts'] = np.squeeze(ts).tolist() - spl = np.squeeze(10*np.log10(edc.time[..., 0]/1e-12)) + with np.errstate(divide='ignore', invalid='ignore'): + spl = np.squeeze(10*np.log10(edc.time[..., 0]/1e-12)) spl = finite_array(spl, nan=0.0, neginf=0.0, posinf=0.0) result_container["results"][0]["responses"][i_rec]["parameters"]['spl_t0_freq'] = spl.tolist() @@ -276,18 +235,167 @@ def _misuka_method(self, json_file_path: str | Path) -> None: result_container["results"][0]["responses"][i_rec]["parameters"]['edt'] = edt.tolist() # update progress for each source in even steps to ~90% - set_progress_and_save(35 + int(55/(n_receivers+1) * (i_src + 1)), result_container, json_file_path) + set_progress_and_save(35 + int(55/(n_receivers+1) * (i_src + 1)), result_container, self.input_json_path) - # set to 100% - set_progress_and_save(100, result_container, json_file_path) + set_progress_and_save(100, result_container, self.input_json_path) + + +def build_scene_from_gmsh( + scene_dict: dict, + msh_path: str | Path, + absorption_map: dict, + frequencies: list, + scattering_str: str, +) -> dict: + """Initialize Gmsh, export physical surfaces to PLY and populate the Mitsuba scene. + + This function loads a mesh file using Gmsh, extracts all physical surface groups, + exports each surface as a separate PLY file, and populates a Mitsuba scene dictionary + with the corresponding material properties (BSDF and geometry). Gmsh is guaranteed + to be finalized even if an exception occurs during processing. + + Parameters + ---------- + scene_dict : dict + Base Mitsuba scene dictionary to populate. Should contain at least + `{"type": "scene"}` as a starting point. + msh_path : str | Path + Path to the Gmsh mesh file (.msh format) containing physical groups + defining surfaces. + absorption_map : dict + Mapping of physical surface names to absorption coefficient strings. + Each value should be a comma-separated string of 5 absorption coefficients + (one per frequency band). Surfaces not in the map default to + "0.1, 0.1, 0.1, 0.1, 0.1". + frequencies : list + List of frequency values (in Hz) for which to define the acoustic spectrum. + Example: [125, 250, 500, 1000, 2000, 4000]. + scattering_str : str + Comma-separated string of scattering coefficients (one per frequency band). + Applied uniformly to all surfaces. + + Returns + ------- + dict + Populated Mitsuba scene dictionary containing BSDF definitions and PLY geometry + references for all physical surfaces. The dictionary includes keys like: + `bsdf_0`, `bsdf_1`, ... for material properties and + `shape_0`, `shape_1`, ... for geometry references. + + Raises + ------ + RuntimeError: + - If Gmsh fails to initialize, open the mesh file, extract physical groups, + or export surfaces. The original exception is chained for debugging. + + Notes + ----- + - PLY files are exported to the same directory as the input mesh file with + naming convention: `surface_{physical_group_name}.ply`. + - All physical groups of dimension 2 (surfaces) are processed. Groups with + other dimensions are ignored. + - Gmsh is finalized in a finally block to ensure cleanup even on error. + - Only triangular mesh faces (3 nodes per element) are exported to PLY. + + Examples + -------- + >>> import json + >>> from pathlib import Path + >>> scene_base = {"type": "scene"} + >>> msh_file = Path("room.msh") + >>> absorption = {"Wall": "0.2, 0.2, 0.3, 0.3, 0.4, 0.4", "Floor": "0.1, 0.1, 0.15, 0.15, 0.2, 0.2"} + >>> freq = [125, 250, 500, 1000, 2000, 4000] + >>> scatter_spec = [(125, 0.05), (250, 0.05), (500, 0.1), (1000, 0.1), (2000, 0.15), (4000, 0.15)] + >>> result = build_scene_from_gmsh(scene_base, msh_file, absorption, freq, scatter_spec) + >>> "bsdf_0" in result + True + """ + try: + gmsh.initialize() + gmsh.open(str(msh_path)) + surfaces = gmsh.model.getPhysicalGroups(2) + ply_dir = Path(msh_path).parent + #TODO: when scattering is surface material dependent move this into for loop + scattering_spectrum = create_frequency_value_pairs( + frequencies, + scattering_str, + num_tuples=len(frequencies), + ) + for i, (dim, tag) in enumerate(surfaces): + surface_name = gmsh.model.getPhysicalName(dim, tag) + absorption_str = absorption_map.get(surface_name, "0.1, 0.1, 0.1, 0.1, 0.1, 0.1") + entity_tags = [ + (dim, entity_tag) + for entity_tag in gmsh.model.getEntitiesForPhysicalGroup(dim, tag) + ] + ply_file = str(ply_dir / f"surface_{surface_name}.ply") + export_physical_surface_to_ply(surface_name, entity_tags, ply_file) + absorption_spectrum = create_frequency_value_pairs( + frequencies, + absorption_str, + num_tuples=len(frequencies), + ) + scene_dict[f"bsdf_{i}"] = { + "type": "twosided", + "id": f"bsdf_{i}", + "bsdf": { + "type": "acousticbsdf", + "absorption": { + "type": "spectrum", + "value": absorption_spectrum, + }, + "scattering": { + "type": "spectrum", + "value": scattering_spectrum, + }, + "specular_lobe_width": 0.001, + }, + } -def set_progress_and_save(percentage, result_container, json_file_path): + scene_dict[f"shape_{i}"] = { + "type": "ply", + "filename": ply_file, + "face_normals": True, + "bsdf": {"type": "ref", "id": f"bsdf_{i}"}, + } + + return scene_dict + except Exception as exc: + logger.exception("Failed to build Mitsuba scene from geometry '%s'.", msh_path) + raise RuntimeError(f"Failed to build Mitsuba scene from geometry '{msh_path}'.") from exc + finally: + try: + gmsh.finalize() + except Exception: + logger.debug("Gmsh finalize skipped because it was not initialized.") + +def set_progress_and_save(percentage: int, result_container: dict, json_file_path: str | Path): + """Update the simulation progress state and persist it to the JSON result file. + + The function writes the current percentage into the first result entry, + removes any outdated percentage list entries, and saves the updated + result container back to the given JSON file. + + Parameters + ---------- + percentage : int + Current progress percentage (0-100). + result_container : dict + Dictionary containing the simulation results and settings. + json_file_path : str | Path + Path to the JSON file where the updated result container will be saved. + + Notes + ----- + - The function updates the ``percentage`` field in the first result of the result container. + - It can also be used to persist any other updates made to the container data. + """ result = result_container["results"][0] result["percentage"] = percentage result.pop("percentages", None) @@ -295,35 +403,91 @@ def set_progress_and_save(percentage, result_container, json_file_path): with open(json_file_path, "w") as json_output: json_output.write(json.dumps(result_container, indent=4, allow_nan=False)) -def create_frequency_spectrum(frequencies: list, values) -> list: - """Create frequency-value tuples for Mitsuba spectrum format. - +def create_frequency_value_pairs(frequencies: list, val_str: str, num_tuples: int = 5) -> list: + """Create a configurable number of frequency-value tuples for Mitsuba spectra. + + This function takes a list of frequencies and a comma-separated string of values, + validates the input on the number of values, their range and same size as the frequency list, + and returns a list of (frequency, value) tuples suitable for Mitsuba's spectrum format. The function + is used e.g. for scattering and absorption coefficients. + Parameters ---------- frequencies : list List of frequency values [125, 250, 500, ...] - values : list or str - Either a list of floats or a comma-separated string of values - + val_str : str + A comma-separated string of values. + num_tuples : int, optional + Number of frequency-value tuples to create. The default is 5. + Returns ------- list - List of (frequency, value) tuples for Mitsuba spectrum + List of (frequency, value) tuples for Mitsuba spectrum. + + Raises + ------ + ValueError: + - if ``num_tuples`` is not a positive integer + - If the number of values in the string does not match ``num_tuples`` + - If any value is outside the range [0, 1] + - If Value string is empty or not convertible to float + - If the number of frequencies does not match ``num_tuples`` + + Examples + -------- + >>> create_frequency_value_pairs([125, 250, 500, 1000, 2000], "0.1, 0.2, 0.3, 0.4, 0.5") + [(125, 0.1), (250, 0.2), (500, 0.3), (1000, 0.4), (2000, 0.5)] + >>> create_frequency_value_pairs([125, 250, 500, 1000, 2000], "0.1, 0.2, 0.3, 0.4, 1.5") + ValueError: Number of Values in String must match the requested number of tuples and each must be between 0 and 1. + >>> create_frequency_value_pairs([125, 250, 500, 1000, 2000], "0.1, 0.2, 0.3, 0.4, 0.5, 0.5", num_tuples=6) + [(125, 0.1), (250, 0.2), (500, 0.3), (1000, 0.4), (2000, 0.5)] """ - # Parse values if string - if isinstance(values, str): - vals = [float(val.strip()) for val in values.split(",")] - else: - vals = [float(val) for val in values] - - # Pad with default value (0.1) if not enough values provided - while len(vals) < len(frequencies): - vals.append(0.1) - - return list(zip(frequencies, vals[:len(frequencies)])) + try: + if not isinstance(num_tuples, int) or num_tuples <= 0: + raise ValueError("Number of tuples must be a positive integer.") + if len(frequencies) != num_tuples: + raise ValueError("Number of frequencies must match the requested number of tuples.") + if not val_str: + raise ValueError("Value string is empty.") + + vals = [float(val.strip()) for val in val_str.split(",")] + if len(vals) != num_tuples or any(val < 0 or val > 1 for val in vals): + raise ValueError( + "Number of Values in String must match the requested number of tuples and each must be between 0 and 1." + ) + except ValueError as e: + logger.error(f"Error parsing Values: {e}") + raise ValueError(f"Invalid values: {val_str}") from e + + return list(zip(frequencies[:num_tuples], vals)) def export_physical_surface_to_ply(surface_name: str, entity_tags, ply_path: str | Path) -> Path: - """Export only the triangular mesh faces of one physical surface to PLY.""" + """Export only the triangular mesh faces of one physical surface to PLY. + + This function extracts the triangular mesh faces corresponding to a specific physical surface (specified by name and entity tags) + from a Gmsh model and exports them to a PLY file. It ensures that only triangular elements + are included, and raises an error if no triangular faces are found. + + Parameters + ---------- + surface_name : str + Name of the physical surface to export + entity_tags : list of tuples + List of (dimension, entity_tag) tuples for the physical surface + ply_path : str | Path + Path to the output PLY file + + Returns + ------- + Path + Path to the exported PLY file + + Raises + ------ + ValueError: + - If the physical surface contains no triangular mesh faces. + """ ply_path = Path(ply_path) node_tags_all, coords_all, _ = gmsh.model.mesh.getNodes() coords_all = coords_all.reshape((len(node_tags_all), 3)) @@ -453,9 +617,28 @@ def center_time(energy_decay_curve): return center_time def finite_array(values, nan=0.0, posinf=0.0, neginf=0.0) -> np.ndarray: + """ + Replace NaN, positive infinity, and negative infinity in an array with specified finite values. + + Parameters + ---------- + values : array-like + Input array. + nan : float, optional + Value to replace NaN with. Default is 0.0. + posinf : float, optional + Value to replace positive infinity with. Default is 0.0. + neginf : float, optional + Value to replace negative infinity with. Default is 0.0. + + Returns + ------- + np.ndarray + Array with specified values replacing NaN and infinity. + """ return np.nan_to_num( np.asarray(values, dtype=float), nan=nan, posinf=posinf, neginf=neginf, - ) \ No newline at end of file + ) diff --git a/misuka_method/pyproject.toml b/misuka_method/pyproject.toml index 77b53c5..aecc433 100644 --- a/misuka_method/pyproject.toml +++ b/misuka_method/pyproject.toml @@ -4,18 +4,13 @@ version = "0.1.0" description = "" requires-python = ">=3.9,<3.14" authors = [ - { name = "Tobias Jüterbock", email = "a.jueterbock@tu-berlin.de" }, + { name = "Timon Schnell", email = "t.schnell@campus.tu-berlin.de" }, ] keywords = [ - "acoustic simulation", - "geometrical acoustics", - "open-source", - "acoustic path tracing", - ] classifiers = [ @@ -24,12 +19,11 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - ] dependencies = [ "numpy>=1.23.0", diff --git a/misuka_method/tests/test_input_misuka.json b/misuka_method/tests/test_input_misuka.json index bbaac9f..5e28126 100644 --- a/misuka_method/tests/test_input_misuka.json +++ b/misuka_method/tests/test_input_misuka.json @@ -13,16 +13,9 @@ "rays_per_frequency": 1048576, "max_time": 0.1, "sampling_rate": 10000, - "sound_power_W": 1, "speed_of_sound": 343, "dynamic_range_db": "no", - "scattering_125": 0.1, - "scattering_250": 0.1, - "scattering_500": 0.1, - "scattering_1000": 0.1, - "scattering_2000": 0.1, - "scattering_4000": 0.1, - "log_data": "no" + "scattering_coefficients": "0.1, 0.1, 0.1, 0.1, 0.1" }, "results": [ { From 96ebb18cd6b2f1f5ed46540b38c06aae25bc0156 Mon Sep 17 00:00:00 2001 From: timon Date: Sun, 26 Jul 2026 19:57:44 +0200 Subject: [PATCH 11/11] CHANGES: fetching misuka from discussed tag, added description for input fields --- example_settings/misuka_setting.json | 35 ++++++----- misuka_method/Dockerfile | 6 +- .../misuka_interface/misuka_interface.py | 61 ++++++++++--------- misuka_method/tests/test_input_misuka.json | 2 +- 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/example_settings/misuka_setting.json b/example_settings/misuka_setting.json index 9799215..b493f3b 100644 --- a/example_settings/misuka_setting.json +++ b/example_settings/misuka_setting.json @@ -9,7 +9,8 @@ "min": 1000, "max": 4194304, "step": 524288, - "default": 1048576 + "default": 1048576, + "description": "Number of rays used for every frequency band in every source-reciever simulation." }, { "name": "Maximum Response Length", @@ -20,7 +21,8 @@ "max": 20, "step": 1.0, "default": 10, - "endAdornment": "s" + "endAdornment": "s", + "description": "Length of the simulated IR." }, { "name": "Histogram sampling rate", @@ -31,7 +33,8 @@ "max": 40000, "step": 1000, "default": 20000, - "endAdornment": "Hz" + "endAdornment": "Hz", + "description": "Sampling rate of the simulated histogram." }, { "name": "Speed of Sound", @@ -42,27 +45,29 @@ "max": 360, "step": 5, "default": 343, - "endAdornment": "m/s" + "endAdornment": "m/s", + "description": "Speed of sound in m/s that is used in simulation." }, { - "name": "Limit Dynamic Range to 100dB", + "name": "Limit Dynamic Range", "id": "dynamic_range_db", - "type": "string", - "display": "radio", - "options": - { - "yes": "yes", - "no": "no" - }, - "default": "no" + "type": "integer", + "display": "text", + "min": -1, + "max": 250, + "step": 20, + "default": -1, + "endAdornment": "dB", + "description": "Using a positive integer input will limit the dynamic range (EDC_max - EDC_min) to that input." }, { - "name": "Scattering as [s(125), s(250), s(500), s(1000), s(2000)]", + "name": "Scattering", "id": "scattering_coefficients", "display": "text", "type": "string", "default": "0.1, 0.1, 0.1, 0.1, 0.1", - "endAdornment": "" + "endAdornment": "", + "description": "Used for controlling scattering on all surface materials for the different frequency bands [s(125), s(250), s(500), s(1000), s(2000)]." } ], diff --git a/misuka_method/Dockerfile b/misuka_method/Dockerfile index c7ac057..54a0a5e 100644 --- a/misuka_method/Dockerfile +++ b/misuka_method/Dockerfile @@ -45,11 +45,11 @@ ENV CMAKE_C_COMPILER_LAUNCHER=ccache RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 RUN python3.11 -m pip install --upgrade pip typing_extensions -# Shallow clone of misuka at the pinned commit +# Shallow clone of misuka from the lgpl branch RUN mkdir -p /opt/misuka && cd /opt/misuka && \ git init && \ git remote add origin https://github.com/misuka-renderer/misuka.git && \ - git fetch --depth 1 origin 0362f576f362533ae3c6b94a369381e7dcfb0843 && \ + git fetch --depth 1 origin lgpl && \ git checkout FETCH_HEAD && \ git submodule update --init --recursive --depth 1 @@ -58,7 +58,7 @@ RUN --mount=type=cache,target=/root/.ccache \ cd /opt/misuka && \ mkdir build && cd build && \ cmake -GNinja .. \ - -DMI_DEFAULT_VARIANTS="scalar_rgb;scalar_acoustic;llvm_ad_acoustic" \ + -DMI_DEFAULT_VARIANTS="scalar_rgb;llvm_ad_rgb;llvm_ad_acoustic" \ -DCMAKE_BUILD_TYPE=Release \ -DPython_EXECUTABLE=/usr/bin/python3.11 \ -DPython3_EXECUTABLE=/usr/bin/python3.11 && \ diff --git a/misuka_method/misuka_interface/misuka_interface.py b/misuka_method/misuka_interface/misuka_interface.py index b6d60f8..acf383e 100644 --- a/misuka_method/misuka_interface/misuka_interface.py +++ b/misuka_method/misuka_interface/misuka_interface.py @@ -74,7 +74,7 @@ def _misuka_method(self) -> None: frequencies = result_container["results"][0]["frequencies"] msh_path = result_container["msh_path"] absorption_map = result_container["absorption_coefficients"] - dynamic_range_limiter = simulation_settings["dynamic_range_db"] == "yes" + dynamic_range_limiter = simulation_settings["dynamic_range_db"] scattering_str = simulation_settings["scattering_coefficients"] except KeyError as e: raise KeyError(f"Missing required key in the input JSON file: {e}") @@ -85,7 +85,7 @@ def _misuka_method(self) -> None: logger.info(f'Input value for maximum response length: {max_time}') logger.info(f'Input value for time_bins: {time_bins}') logger.info(f'Input value for speed of sound: {speed_of_sound}') - logger.info(f'Use dynamic range limiter: {dynamic_range_limiter}') + logger.info(f'Use dynamic range limiter: {dynamic_range_limiter} (-1 or 0 means no limiter)') logger.info(f'Input values for absorption: {absorption_map}') frequency_range = (float(np.min(frequencies)), float(np.max(frequencies))) @@ -190,10 +190,10 @@ def _misuka_method(self) -> None: with np.errstate(divide='ignore', invalid='ignore'): edc_normalized_db = 10*np.log10(edc_normalized.time/1e-12) edc_normalized_db = np.squeeze(edc_normalized_db, axis=0) - edc_normalized_db = finite_array(edc_normalized_db, nan=0.0, neginf=-100, posinf=0.0) + edc_normalized_db = finite_array(edc_normalized_db, nan=0.0, neginf=-100, posinf=100) - if dynamic_range_limiter: - limit = np.max(edc_normalized_db) - 100 + if dynamic_range_limiter > 0: + limit = np.max(edc_normalized_db) - dynamic_range_limiter edc_normalized_db[edc_normalized_db None: for i_frq in range(len(frequencies)) ] - t20 = np.squeeze(pr.parameters.reverberation_time_linear_regression(edc, 'T20')) - t20 = finite_array(t20, nan=0.0, neginf=0.0, posinf=0.0) - result_container["results"][0]["responses"][i_rec]["parameters"]['t20'] = t20.tolist() + with np.errstate(divide='ignore', invalid='ignore'): + t20 = np.squeeze(pr.parameters.reverberation_time_linear_regression(edc, 'T20')) + t20 = finite_array(t20, nan=0.0, neginf=0.0, posinf=0.0) + result_container["results"][0]["responses"][i_rec]["parameters"]['t20'] = t20.tolist() - t30 = np.squeeze(pr.parameters.reverberation_time_linear_regression(edc, 'T30')) - t30 = finite_array(t30, nan=0.0, neginf=0.0, posinf=0.0) - result_container["results"][0]["responses"][i_rec]["parameters"]['t30'] = t30.tolist() + t30 = np.squeeze(pr.parameters.reverberation_time_linear_regression(edc, 'T30')) + t30 = finite_array(t30, nan=0.0, neginf=0.0, posinf=0.0) + result_container["results"][0]["responses"][i_rec]["parameters"]['t30'] = t30.tolist() - c80 = np.squeeze(pr.parameters.clarity(edc, 80)) - c80 = finite_array(c80, nan=0.0, neginf=0.0, posinf=0.0) - result_container["results"][0]["responses"][i_rec]["parameters"]['c80'] = c80.tolist() + c80 = np.squeeze(pr.parameters.clarity(edc, 80)) + c80 = finite_array(c80, nan=0.0, neginf=0.0, posinf=0.0) + result_container["results"][0]["responses"][i_rec]["parameters"]['c80'] = c80.tolist() - d50 = np.squeeze(pr.parameters.definition(edc, 50)) * 100 - d50 = finite_array(d50, nan=0.0, neginf=0.0, posinf=0.0) - result_container["results"][0]["responses"][i_rec]["parameters"]['d50'] = d50.tolist() + d50 = np.squeeze(pr.parameters.definition(edc, 50)) * 100 + d50 = finite_array(d50, nan=0.0, neginf=0.0, posinf=0.0) + result_container["results"][0]["responses"][i_rec]["parameters"]['d50'] = d50.tolist() + + ts = center_time(edc)*1000 # in ms TODO replace by pyrato 1.1.0 version + result_container["results"][0]["responses"][i_rec]["parameters"]['ts'] = np.squeeze(ts).tolist() - ts = center_time(edc)*1000 # in ms TODO replace by pyrato 1.1.0 version - result_container["results"][0]["responses"][i_rec]["parameters"]['ts'] = np.squeeze(ts).tolist() - with np.errstate(divide='ignore', invalid='ignore'): spl = np.squeeze(10*np.log10(edc.time[..., 0]/1e-12)) - spl = finite_array(spl, nan=0.0, neginf=0.0, posinf=0.0) - result_container["results"][0]["responses"][i_rec]["parameters"]['spl_t0_freq'] = spl.tolist() + spl = finite_array(spl, nan=0.0, neginf=0.0, posinf=0.0) + result_container["results"][0]["responses"][i_rec]["parameters"]['spl_t0_freq'] = spl.tolist() - edt = np.squeeze(pr.parameters.reverberation_time_linear_regression(edc, 'EDT')) - edt = finite_array(edt, nan=0.0, neginf=0.0, posinf=0.0) - result_container["results"][0]["responses"][i_rec]["parameters"]['edt'] = edt.tolist() + + edt = np.squeeze(pr.parameters.reverberation_time_linear_regression(edc, 'EDT')) + edt = finite_array(edt, nan=0.0, neginf=0.0, posinf=0.0) + result_container["results"][0]["responses"][i_rec]["parameters"]['edt'] = edt.tolist() # update progress for each source in even steps to ~90% set_progress_and_save(35 + int(55/(n_receivers+1) * (i_src + 1)), result_container, self.input_json_path) @@ -401,7 +403,10 @@ def set_progress_and_save(percentage: int, result_container: dict, json_file_pat result.pop("percentages", None) # Save the updated JSON with open(json_file_path, "w") as json_output: - json_output.write(json.dumps(result_container, indent=4, allow_nan=False)) + json_output.write(json.dumps(result_container, + indent=4, + allow_nan=False + )) def create_frequency_value_pairs(frequencies: list, val_str: str, num_tuples: int = 5) -> list: """Create a configurable number of frequency-value tuples for Mitsuba spectra. @@ -539,7 +544,7 @@ def export_physical_surface_to_ply(surface_name: str, entity_tags, ply_path: str return ply_path # copy pasted from pyrato -def center_time(energy_decay_curve): +def center_time(energy_decay_curve: pf.TimeData) -> np.ndarray: r""" Calculate the room-acoustic center time (:math:`T_s`). @@ -616,7 +621,7 @@ def center_time(energy_decay_curve): return center_time -def finite_array(values, nan=0.0, posinf=0.0, neginf=0.0) -> np.ndarray: +def finite_array(values: np.ndarray, nan: float = 0.0, posinf: float = 0.0, neginf: float = 0.0) -> np.ndarray: """ Replace NaN, positive infinity, and negative infinity in an array with specified finite values. diff --git a/misuka_method/tests/test_input_misuka.json b/misuka_method/tests/test_input_misuka.json index 5e28126..845c9d3 100644 --- a/misuka_method/tests/test_input_misuka.json +++ b/misuka_method/tests/test_input_misuka.json @@ -14,7 +14,7 @@ "max_time": 0.1, "sampling_rate": 10000, "speed_of_sound": 343, - "dynamic_range_db": "no", + "dynamic_range_db": -1, "scattering_coefficients": "0.1, 0.1, 0.1, 0.1, 0.1" }, "results": [