diff --git a/example_settings/pyroomacoustics_setting.json b/example_settings/pyroomacoustics_setting.json new file mode 100644 index 0000000..7ed022e --- /dev/null +++ b/example_settings/pyroomacoustics_setting.json @@ -0,0 +1,48 @@ +{ + "type": "simulationSettings", + "options": [ + { + "name": "Speed of sound", + "id": "c0", + "type": "float", + "display": "text", + "min": 100, + "max": 500, + "default": 343, + "step": 1, + "endAdornment": "m/s" + }, + { + "name": "Air density", + "id": "rho0", + "type": "float", + "display": "text", + "min": 0.001, + "max": 3, + "default": 1.213, + "step": 0.001, + "endAdornment": "kg/m^3" + }, + { + "name": "Image source order", + "id": "image_source_order", + "type": "integer", + "display": "text", + "min": 1, + "max": 1000, + "default": 2, + "step": 1 + }, + { + "name": "Sampling rate", + "id": "sampling_rate", + "type": "integer", + "display": "text", + "min": 1000, + "max": 192000, + "default": 8000, + "step": 1000, + "endAdornment": "Hz" + } + ] +} diff --git a/methods-config.json b/methods-config.json index 2683ca6..557376a 100644 --- a/methods-config.json +++ b/methods-config.json @@ -28,5 +28,15 @@ "settings":"my_new_setting.json", "repositoryURL":"", "documentationURL":"" + }, + { + "simulationType": "Pyroomacoustics", + "containerImage": "pyroomacoustics_image:latest", + "envVars": {}, + "label": "Pyroomacoustics", + "settings": "pyroomacoustics_setting.json", + "entryFile": "__main__.py", + "repositoryURL": "https://github.com/LCAV/pyroomacoustics", + "documentationURL": "https://pyroomacoustics.readthedocs.io" } ] diff --git a/pyroomacoustics_method/pyproject.toml b/pyroomacoustics_method/pyproject.toml index 5cf3db5..23f9a8c 100644 --- a/pyroomacoustics_method/pyproject.toml +++ b/pyroomacoustics_method/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "gmsh", "pyroomacoustics>=0.8.0", "requests", + "pyfar>=0.8.0", ] [project.optional-dependencies] @@ -73,3 +74,38 @@ pyroomacoustics_interface = "pyroomacoustics_interface:main" [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.ruff] +line-length = 79 +lint.ignore = [ + "D200", # One-line docstring should fit on one line with quotes + "D202", # No blank lines allowed after function docstring + "D205", # 1 blank line required between summary line and description + "D401", # First line should be in imperative mood + "D404", # First word of the docstring should not be "This" + "B006", # Do not use mutable data structures for argument defaults + "B008", # Do not perform calls in argument defaults + "PT018", # Assertion should be broken down into multiple parts + "PT019", # Fixture `_` without value is injected as parameter + +] +lint.select = [ + "B", # bugbear extension + "ARG", # Remove unused function/method arguments + "C4", # Check for common security issues + "E", # PEP8 errors + "F", # Pyflakes + "W", # PEP8 warnings + "D", # Docstring guidelines + "NPY", # Check all numpy related deprecations + "D417", # Missing argument descriptions in the docstring + "PT", # Pytest style + "A", # Avoid builtin function and type shadowing + "ERA", # No commented out code +] + +# Ignore missing docstrings in tests + + +[tool.ruff.lint.pydocstyle] +convention = "numpy" diff --git a/pyroomacoustics_method/pyroomacoustics_interface/pyroomacoustics_interface.py b/pyroomacoustics_method/pyroomacoustics_interface/pyroomacoustics_interface.py index 7c58c3c..e67f446 100644 --- a/pyroomacoustics_method/pyroomacoustics_interface/pyroomacoustics_interface.py +++ b/pyroomacoustics_method/pyroomacoustics_interface/pyroomacoustics_interface.py @@ -4,23 +4,49 @@ import gmsh import numpy as np import warnings -import os from pathlib import Path +import pyfar as pf # Support both package and script execution. from .definition import SimulationMethod class PyroomacousticsMethod(SimulationMethod): - def __init__(self, input_json_path: str | Path | None = None): + """Interface class to run simulations using pyroomacoustics. + + This interface class sets configures the simulation parameters, + source and receiver positions, as well as room geometry and boundary data. + + On a successful simulation, the computed RIRs are exported to the input + data file. + + Parameters + ---------- + input_json_path : str or Path, optional + Path to the simulation configuration file. + + """ + + def __init__(self, input_json_path: str | Path | None): + """Initialize from configuration file. + + Parameters + ---------- + input_json_path : str | Path | None, optional + The input configuration file path. Note that if ``None`` is + provided, the simulation will return an error. This input is only + allowed to support the case where the environment variable is not + set. + """ super().__init__(input_json_path) - def run_simulation(self): - """Run the simulation method for pyroomacoustics based on the JSON file. + def run_simulation(self) -> None: + """Execute the simulation and export results to the configuration file. """ print("pyroomacoustics_method: starting simulation") + walls = import_room_geometry(self.input_json_path) simulation_setup = setup_simulation(self.input_json_path, walls) @@ -37,12 +63,12 @@ def run_simulation(self): print("pyroomacoustics_method: simulation done!") -def read_json_input(json_file_path): - """Read the input JSON file. +def read_json_input(json_file_path: str | Path) -> dict: + """Read the input JSON file and return the content as a dictionary. Parameters ---------- - json_file_path : str + json_file_path : str | Path Path of the input JSON file Returns @@ -58,7 +84,7 @@ def read_json_input(json_file_path): return input_data -def import_room_geometry(json_file_path): +def import_room_geometry(json_file_path: str | Path) -> list[pra.Wall]: """Import room geometry and absorption coefficients. The geometry is read from a .geo file specified in the JSON input file. @@ -66,7 +92,7 @@ def import_room_geometry(json_file_path): Parameters ---------- - json_file_path : str + json_file_path : str | Path Path to the JSON file containing room geometry and absorption coefficients. @@ -87,11 +113,46 @@ def import_room_geometry(json_file_path): import json input_data = json.load(f) - frequencies = input_data['frequencies'] - n_bands = len(frequencies) # initialize gmsh and load the geometry file gmsh.initialize() + try: + walls = _import_room_geometry(input_data) + finally: + gmsh.finalize() + + return walls + + +def _import_room_geometry(input_data: dict) -> list[pra.Wall]: + """Private import class for geometry and boundary conditions. + + This private function ensures that gmsh is properly finalized after + geometry import, even if an error occurs during the import process. + + The public function `import_room_geometry` is responsible for initializing + and finalizing gmsh. + + Parameters + ---------- + input_data : dict + The input configuration data. + + Returns + ------- + list[pra.Wall] + List of walls defining the room geometry and boundary conditions for + all frequency bands. + + Raises + ------ + ValueError + If absorption coefficients for any surface are not found in the + input JSON file. + """ + + + frequencies = input_data['results'][0]['frequencies'] geometry_file = input_data['geo_path'] gmsh.open(geometry_file) @@ -138,11 +199,31 @@ def import_room_geometry(json_file_path): element_type, 3, tag=tag) faces = np.reshape(face_nodes, (len(face_nodes) // 3, 3)) + absorption_coeffs_config = input_data[ + 'absorption_coefficients'][surface_name] + if isinstance(absorption_coeffs_config, str): + absorption_coeffs = np.array( + [ + float(x.strip()) + for x in absorption_coeffs_config.split(",") + ], + dtype=float) + elif isinstance(absorption_coeffs_config, list): + absorption_coeffs = np.array( + absorption_coeffs_config, dtype=float) + elif isinstance(absorption_coeffs_config, np.ndarray): + absorption_coeffs = absorption_coeffs_config.astype(float) + else: + raise ValueError( + "Invalid format for the absorption coefficient. ", + f"Got type {type(absorption_coeffs_config)}." + ) + material = pra.Material( energy_absorption={ 'description': surface_name, - 'center_freqs': input_data['frequencies'], - 'coeffs': input_data['absorption_coefficients'][surface_name], + 'center_freqs': frequencies, + 'coeffs': absorption_coeffs, } ) @@ -155,13 +236,10 @@ def import_room_geometry(json_file_path): for face in faces ) - # finalizing gmsh - gmsh.finalize() - return walls -def get_source_positions(input_data): +def get_source_positions(input_data: dict) -> np.ndarray: """Extract source positions from input data. Parameters @@ -181,7 +259,7 @@ def get_source_positions(input_data): ]) -def get_receiver_positions(input_data): +def get_receiver_positions(input_data: dict) -> np.ndarray: """Extract receiver positions from input data. Parameters @@ -211,7 +289,7 @@ def get_receiver_positions(input_data): return receiver_pos -def set_default_simulation_settings(input_data): +def set_default_simulation_settings(input_data: dict) -> dict: """Set default simulation settings if not provided in input data. Parameters @@ -260,7 +338,10 @@ def set_default_simulation_settings(input_data): return input_data -def setup_simulation(json_file_path, walls): +def setup_simulation( + json_file_path: str | Path, + walls: list[pra.Wall] + ) -> pra.Room: """Set up the pyroomacoustics simulation based on the JSON file. Parameters @@ -282,10 +363,18 @@ def setup_simulation(json_file_path, walls): input_data = read_json_input(json_file_path) extended_input_data = set_default_simulation_settings(input_data) - sampling_rate = extended_input_data['simulationSettings'].get('sampling_rate') - image_source_order = extended_input_data['simulationSettings'].get('image_source_order') - ray_tracing = extended_input_data['simulationSettings'].get('ray_tracing') - air_absorption = extended_input_data['simulationSettings'].get('air_absorption') + sampling_rate = extended_input_data["simulationSettings"].get( + "sampling_rate" + ) + image_source_order = extended_input_data["simulationSettings"].get( + "image_source_order" + ) + ray_tracing = bool( + extended_input_data["simulationSettings"].get("ray_tracing") + ) + air_absorption = bool( + extended_input_data["simulationSettings"].get("air_absorption") + ) room = pra.Room( walls, @@ -295,6 +384,19 @@ def setup_simulation(json_file_path, walls): air_absorption=air_absorption, ) + frequencies = input_data["results"][0]["frequencies"] + + room.octave_bands.base_freq = frequencies[0] + room.n_octave_bands = len(frequencies) + + alpha, m_pyfar, _ = pf.constants.air_attenuation( + 20, + frequencies, + relative_humidity=50/1e2) + m = np.squeeze(m_pyfar.freq) + + room.air_absorption = m + # Add sources source_pos = get_source_positions(input_data) if source_pos.shape != (3,): @@ -309,13 +411,16 @@ def setup_simulation(json_file_path, walls): return room -def export_rir_to_input(json_file_path, rir): +def export_rir_to_input( + json_file_path: str | Path, + rir: list[list[np.ndarray]] + ) -> None: """Export the computed RIRs to the input data structure. Parameters ---------- - input_data : dict - Input data as a dictionary. + json_file_path : str | Path + Path to the input JSON file. rir : list of list of np.ndarray Computed RIRs from pyroomacoustics. @@ -328,10 +433,8 @@ def export_rir_to_input(json_file_path, rir): import json input_data = json.load(f) - # num_receivers = len(input_data['results'][0]['responses']) - - # for i in range(num_receivers): input_data['results'][0]['responses'][0]['receiverResults'] = rir.tolist() + input_data["results"][0]["percentage"] = 100 with open(json_file_path, 'w') as f: json.dump(input_data, f, indent=4) diff --git a/pyroomacoustics_method/tests/test_input_pyroomacoustics.json b/pyroomacoustics_method/tests/test_input_pyroomacoustics.json index 5a1b958..141aa0f 100644 --- a/pyroomacoustics_method/tests/test_input_pyroomacoustics.json +++ b/pyroomacoustics_method/tests/test_input_pyroomacoustics.json @@ -1,50 +1,44 @@ { - "frequencies" : [125, 250, 500, 1000, 2000, 4000, 8000], - "absorption_coefficients": { - "floor": [0.6, 0.69, 0.71, 0.7, 0.63, 0.6, 0.61], - "wall1": [0.6, 0.69, 0.71, 0.7, 0.63, 0.6, 0.61], - "ceiling": [0.6, 0.69, 0.71, 0.7, 0.63, 0.6, 0.61], - "wall2": [0.6, 0.69, 0.71, 0.7, 0.63, 0.6, 0.61], - "wall3": [0.6, 0.69, 0.71, 0.7, 0.63, 0.6, 0.61], - "wall4": [0.6, 0.69, 0.71, 0.7, 0.63, 0.6, 0.61] - }, - "msh_path": "test_room_pyroomacoustics.msh", - "geo_path": "test_room_pyroomacoustics.geo", - "simulationSettings": { - "mnm_1": 0.5, - "mnm_2": 50.0 - }, - "results": [ + "absorption_coefficients": { + "floor": "0.6, 0.69, 0.71, 0.7, 0.63", + "wall1": "0.6, 0.69, 0.71, 0.7, 0.63", + "ceiling": "0.6, 0.69, 0.71, 0.7, 0.63", + "wall2": "0.6, 0.69, 0.71, 0.7, 0.63", + "wall3": "0.6, 0.69, 0.71, 0.7, 0.63", + "wall4": "0.6, 0.69, 0.71, 0.7, 0.63" + }, + "msh_path": "test_room_pyroomacoustics.msh", + "geo_path": "test_room_pyroomacoustics.geo", + "simulationSettings": { + "sampling_rate": 5000, + "image_source_order": 2, + "ray_tracing": true + }, + "results": [ + { + "percentage": 100, + "sourceX": 2, + "sourceY": 2, + "sourceZ": 1.5, + "resultType": "Pyroomacoustics", + "frequencies": [125, 250, 500, 1000, 2000], + "responses": [ { - "percentage": 100, - "sourceX": 2, - "sourceY": 2, - "sourceZ": 1.5, - "resultType": "MyNewMethod", - "frequencies": [ - 125, - 250, - 500, - 1000, - 2000 - ], - "responses": [ - { - "x": 1, - "y": 1, - "z": 1.5, - "parameters": { - "edt": [], - "t20": [], - "t30": [], - "c80": [], - "d50": [], - "ts": [], - "spl_t0_freq": [] - }, - "receiverResults": [] - } - ] + "x": 1, + "y": 1, + "z": 1.5, + "parameters": { + "edt": [], + "t20": [], + "t30": [], + "c80": [], + "d50": [], + "ts": [], + "spl_t0_freq": [] + }, + "receiverResults": [] } - ] + ] + } + ] }