diff --git a/example_settings/modart_setting.json b/example_settings/modart_setting.json new file mode 100644 index 0000000..0bbf8d3 --- /dev/null +++ b/example_settings/modart_setting.json @@ -0,0 +1,111 @@ +{ + "type": "simulationSettings", + "options": [ + { + "name": "Response duration", + "id": "durat", + "type": "float", + "display": "text", + "min": 1e-1, + "max": 1e1, + "default": 1e0, + "step": 1e-1, + "endAdornment": "s" + }, + { + "name": "Echogram sample rate", + "id": "f_e", + "type": "float", + "display": "text", + "min": 5e1, + "max": 5e4, + "default": 5e3, + "step": 5e1, + "endAdornment": "Hz" + }, + { + "name": "T60 threshold", + "id": "T60", + "type": "float", + "display": "text", + "min": 1e-3, + "max": 1e1, + "default": 1e-1, + "step": 1e-2, + "endAdornment": "s" + }, + { + "name": "Max slopes", + "id": "slopes", + "type": "int", + "display": "text", + "min": 1, + "max": 100, + "default": 10, + "step": 1 + }, + { + "name": "Air humidity", + "id": "humi", + "type": "float", + "display": "text", + "min": 0.0, + "max": 100.0, + "default": 50.0, + "step": 5.0, + "endAdornment": "%" + }, + { + "name": "Air temperature", + "id": "temp", + "type": "float", + "display": "text", + "min": -100.0, + "max": 100.0, + "default": 20.0, + "step": 1.0, + "endAdornment": "°C" + }, + { + "name": "Atmospheric pressure", + "id": "pres", + "type": "float", + "display": "text", + "min": 50.0, + "max": 150.0, + "default": 100.0, + "step": 0.1, + "endAdornment": "kPa" + }, + { + "name": "Points per square meter", + "id": "ppsm", + "type": "float", + "display": "text", + "min": 0, + "max": 100.0, + "default": 30.0, + "step": 0.1 + }, + { + "name": "Rays per hemisphere", + "id": "rays", + "type": "int", + "display": "text", + "min": 10, + "max": 10000, + "default": 1000, + "step": 100 + }, + { + "name": "Max parallel processes", + "id": "pool", + "type": "int", + "display": "text", + "min": 1, + "max": 10000, + "default": 4, + "step": 1 + } + ] +} diff --git a/methods-config.json b/methods-config.json index 2683ca6..151abf7 100644 --- a/methods-config.json +++ b/methods-config.json @@ -19,6 +19,16 @@ "repositoryURL":"https://github.com/Building-acoustics-TU-Eindhoven/acousticDE/", "documentationURL":"https://building-acoustics-tu-eindhoven.github.io/acousticDE/index.html" }, + { + "simulationType": "MoDART", + "containerImage": "modart_image:latest", + "envVars": {}, + "label": "MoD-ART", + "entryFile":"modart_interface.py", + "settings":"modart_setting.json", + "repositoryURL":"https://github.com/IoSR-Surrey/MoD-ART/", + "documentationURL":"https://github.com/IoSR-Surrey/MoD-ART/blob/master/example%20usage/Tutorial.ipynb" + }, { "simulationType": "MyNewMethod", "containerImage": "mynewmethod_image:latest", diff --git a/modart_method/Dockerfile b/modart_method/Dockerfile new file mode 100644 index 0000000..b766d96 --- /dev/null +++ b/modart_method/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11.13-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies for mesh generation and scientific computing +RUN apt-get update && apt-get install -y \ + git \ + build-essential \ + gmsh \ + && rm -rf /var/lib/apt/lists/* + +# Copy method package directory +COPY modart_method /app/modart_method + +# Install the method package +RUN pip install --no-cache-dir /app/modart_method + +WORKDIR /app/modart_method + +# Default command to run the containerized MoDART method +CMD ["python", "-m", "modart_interface"] diff --git a/modart_method/LICENSE b/modart_method/LICENSE new file mode 100644 index 0000000..a8e20f6 --- /dev/null +++ b/modart_method/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025, Matteo Scerbo + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/modart_method/modart_interface/__cli__.py b/modart_method/modart_interface/__cli__.py new file mode 100644 index 0000000..b0dcc6b --- /dev/null +++ b/modart_method/modart_interface/__cli__.py @@ -0,0 +1,19 @@ +"""CLI module for MoDART method.""" +import os +from .modart_interface import MoDARTMethod + + +def main() -> None: + """Run the MoDART method simulation.""" + # JSON path in the uploads folder. This variable is set for the + # container when it is started up. + json_file_path = os.environ.get("JSON_PATH") + + print(f"Running MoDART method with JSON_PATH={json_file_path}") + modart_method_object = MoDARTMethod(json_file_path) + modart_method_object.run_simulation() + + # Save the results to a separate file + modart_method_object.save_results() + + print("MoDART container finished.") diff --git a/modart_method/modart_interface/__init__.py b/modart_method/modart_interface/__init__.py new file mode 100644 index 0000000..6937cdd --- /dev/null +++ b/modart_method/modart_interface/__init__.py @@ -0,0 +1,8 @@ +"""MoDARTMethod package.""" +from .__main__ import main +from .modart_interface import MoDARTMethod + +__all__ = [ + "main", + "MoDARTMethod" +] diff --git a/modart_method/modart_interface/__main__.py b/modart_method/modart_interface/__main__.py new file mode 100644 index 0000000..fe0bf36 --- /dev/null +++ b/modart_method/modart_interface/__main__.py @@ -0,0 +1,5 @@ +"""Main module for MoDART method.""" +from .__cli__ import main + +if __name__ == "__main__": + main() diff --git a/modart_method/modart_interface/definition.py b/modart_method/modart_interface/definition.py new file mode 100644 index 0000000..0031729 --- /dev/null +++ b/modart_method/modart_interface/definition.py @@ -0,0 +1,92 @@ +"""Base class implementation of the SimulationMethod interface class.""" +from abc import ABC, abstractmethod +from pathlib import Path +import time + +import requests + + +class SimulationMethod(ABC): + """Abstract base class for simulation methods. + + This class serves as a template for methods required to run a simulation + and return results to the simulation service executor. + + """ + + def __init__(self, input_json_path: str | Path | None): + """Initialize the simulation method. + + Parameters + ---------- + input_json_path : str | Path | None, optional + The path to the input JSON file, by default None + + Raises + ------ + FileNotFoundError + If the input JSON file does not exist. + + """ + if input_json_path is None or ( + isinstance(input_json_path, str) and input_json_path == ""): + raise FileNotFoundError("input_json_path cannot be None or empty") + + input_path = Path(input_json_path) + if not input_path.exists(): + raise FileNotFoundError( + f"Input JSON file not found: {input_json_path}") + + self._input_json_path = input_json_path + + @property + def input_json_path(self) -> str | Path: + """The input JSON file.""" + return self._input_json_path + + @abstractmethod + def run_simulation(self): + """Run the simulation for the given a JSON file.""" + pass + + def save_results( + self, + url="http://host.docker.internal:5001/receive", + max_retries=5, + delay=2, + ): + """Return the results back to the simulation service executor. + + Parameters + ---------- + url : str, optional + The URL of the results server, + by default "http://host.docker.internal:5001/receive" which + is the default address for local executrion via Docker. + max_retries : int, optional + The maximum number of retries if the request fails, by default 5 + delay : int, optional + The delay in seconds between retries, by default 2 + + """ + + json_tmp_file = self.input_json_path + for attempt in range(1, max_retries + 1): + try: + with open(json_tmp_file, "rb") as f: + response = requests.post(url, files={"file": f}) + + if response.status_code == 200: + print("Successfully sent file.") + return True + + print( + f"Attempt {attempt}: ", + f"Server returned {response.status_code}") + except requests.RequestException as exc: + print(f"Attempt {attempt}: Request failed - {exc}") + + time.sleep(delay) + + print("Max retries reached. Giving up.") + return False diff --git a/modart_method/modart_interface/modart_interface.py b/modart_method/modart_interface/modart_interface.py new file mode 100644 index 0000000..d4feee4 --- /dev/null +++ b/modart_method/modart_interface/modart_interface.py @@ -0,0 +1,572 @@ +"""Module implementing a CHORAS interface for MoDART. +""" +import json +import gmsh +import numpy as np +from pathlib import Path + +from raves import compute_ART, compute_MoDART, run_MoDART +from raves.src.utils import sanitize_ascii + +from numpy.random import default_rng +from scipy.signal import butter, sosfilt +from scipy.interpolate import make_interp_spline + +from .definition import SimulationMethod + + +def save_converted_mesh(geo_file_path: str | Path, + output_folder_path: str | Path) -> None: + """ + Read a .geo file (using gmsh), triangulate its surfaces, and save the result + in Wavefront format (.obj + .mtl) as expected by MoD-ART. + + Parameters + ---------- + geo_file_path : str or Path + Path to the .geo file to be read. + output_folder_path : str or Path + Path to the folder where converted mesh files will be saved. + """ + if not Path(geo_file_path).is_file(): + raise FileNotFoundError('The specified .geo file could not be found.') + if not Path(output_folder_path).is_dir(): + raise NotADirectoryError('The specified output folder could not be found.') + + # Run the gmsh session in a "protected" scope to make sure it gets finalized no matter what. + gmsh.initialize() + try: + # This factor scales the size of triangles generated by gmsh. + # The triangulation has no effect on the results, it only slows things down; + # make it as large as possible. + gmsh.option.setNumber('Mesh.MeshSizeFactor', 100) + + # Read the input file... + gmsh.open(geo_file_path) + # ...and mesh its surfaces (2D elements). + gmsh.model.mesh.generate(2) + + # Retrieve the integer IDs of all surface materials (2D physical groups). + material_tags = gmsh.model.getPhysicalGroups(dim=2) + # Retrieve the names of surface materials, as strings. + material_names = [gmsh.model.getPhysicalName(dim, tag) + for (dim, tag) in material_tags] + # Retrieve the lists of "entities" characterized by each of the retrieved surface materials. + surface_entities = {name: [tag for dim, tag in gmsh.model.getEntitiesForPhysicalName(name)] + for name in material_names} + + # Retrieve the 3D coordinates of mesh vertices, and format them as an (N, 3) array. + node_tags_all, coords_all, _ = gmsh.model.mesh.getNodes() + vertices = coords_all.reshape((len(node_tags_all), 3)) + num_vertices = vertices.shape[0] + + # Reference object for gmsh elements of "triangle" type. + gmsh_triangle_type = gmsh.model.mesh.getElementType('triangle', 1) + + # Prepare the lists which will form the Wavefront-formatted faces. + triangles = list() + triangle_materials = list() + triangle_patch_ids = list() + num_patches = 0 + + # Cycle over the detected surface materials. + for material_name, entity_tags_list in surface_entities.items(): + # For each surface material, we found a list of "entities" which may be + # different polygons made of that material. + # TODO: Confirm if it actually works like that. + for tag in entity_tags_list: + # Find the triangles which form the surface entity. + face_nodes = gmsh.model.mesh.getElementFaceNodes(gmsh_triangle_type, 3, tag=tag) + num_triangles_in_element = len(face_nodes) // 3 + # Just in case a non-triangular surface element snuck in: ignore it. + if num_triangles_in_element == 0: + continue + # This forms an (M, 3) integer-valued array of vertex indices (1-indexed). + faces = np.reshape(face_nodes, (num_triangles_in_element, 3)) + + # For the purpose of MoD-ART, each "entity" is used as a surface patch. + # TODO: Allow automatic segmentation with maximum area threshold (will use code from our upcoming paper). + num_patches += 1 + for face in faces: + triangles.append(face) + # Note: the material name is "sanitized" to only latin letters, digits, and underscores. + triangle_materials.append(sanitize_ascii(material_name)) + triangle_patch_ids.append(num_patches) + finally: + # Close the gmsh session even in case of an exception. + gmsh.finalize() + + # Convert the lists to arrays, for easier handling. + triangles = np.array(triangles) + num_triangles = triangles.shape[0] + triangle_patch_ids = np.array(triangle_patch_ids) + + # The surface normals are inverted w.r.t. what MoD-ART expects. Flip them (by inverting the triangle winding). + # TODO: How can this be tested, to prevent breaking changes in the future? MoD-ART does not raise any errors if the normals are flipped. + triangles = triangles[:, ::-1] + + # Make sure all vertex indices are valid. + assert np.all(triangles <= num_vertices), 'The triangle definitions include vertex indices out of range.' + + # Prepare all of the lines to be written in each of the Wavefront files. + obj_output_lines = list() + mtl_output_lines = list() + + # The .obj always starts by specifying the .mtl. + obj_output_lines.append('mtllib mesh.mtl\n') + + # List all of the vertex coordinates, in order, in the .obj. + for v in vertices: + line = 'v ' + ' '.join([str(c) for c in v]) + '\n' + obj_output_lines.append(line) + + # List all of the triangles in the .obj, and add their materials to the .mtl. + latest_patch_name = None + for i in range(num_triangles): + # Consider the "dummy" material name assigned to this triangle. + patch_name = f'Patch_{triangle_patch_ids[i]}_Mat_{triangle_materials[i]}' + # If it is different from the latest material specified in the .obj, it needs to be added. + if patch_name != latest_patch_name: + obj_output_lines.append(f'usemtl {patch_name}\n') + + # It also needs to be added to the .mtl, as it is a new material definition. + mtl_output_lines.append(f'newmtl {patch_name}\n') + # Assign the "dummy" material a random RGB color. + r, g, b = np.round(np.random.uniform(size=3), 3) + mtl_output_lines.append(f'Kd {r} {g} {b}\n') + + # All following triangles in the .obj implicitly use this material. + latest_patch_name = patch_name + + # Add the vertex indices forming the triangle. + # Note: these are already 1-indexed, as they should be for the Wavefront format. + obj_output_lines.append('f ' + ' '.join([str(v) for v in triangles[i]]) + '\n') + + # Appending filenames using " / " requires this to be a Path object, not a plain string. + if type(output_folder_path) == str: + output_folder_path = Path(output_folder_path) + + with open(str(output_folder_path / 'mesh.obj'), mode='w') as file: + for line in obj_output_lines: + file.write(line) + with open(str(output_folder_path / 'mesh.mtl'), mode='w') as file: + for line in mtl_output_lines: + file.write(line) + + +def save_materials_file(json_file_path: str | Path) -> None: + """ + Read the JSON settings file and save surface material data in the .csv format + expected by MoD-ART, in the folder spacified in the JSON. + + Parameters + ---------- + json_file_path : str or Path + Path to the JSON file to be read. + """ + if not Path(json_file_path).is_file(): + raise FileNotFoundError('The specified JSON file could not be found.') + + # Load the input JSON file. + with open(json_file_path, "r") as json_file: + result_container = json.load(json_file) + + # The formatted data needs to be saved in the specified folder. + if 'MoDART_data_subfolder' not in result_container: + raise ValueError('The MoD-ART data folder is not specified in the JSON.') + output_folder_path = Path(result_container['MoDART_data_subfolder']) + if not Path(output_folder_path).is_dir(): + raise NotADirectoryError('The MoD-ART data folder could not be found.') + + # TODO: For now, we assume that the length of each + # result_container['absorption_coefficients'].values() + # is the same as the length of each + # result_container['results'][res_idx]['frequencies'] + # Eventually this will change. This portion of the code will need to be updated. + freq_bands = None + for res in result_container['results']: + freqs = np.array(res['frequencies'], dtype=float) + if freq_bands is None: + freq_bands = freqs + else: + assert np.allclose(freq_bands, freqs) + + # Arrange the coefficients into a dict. + absorptions = dict() + for material, coeff_string in result_container['absorption_coefficients'].items(): + coeffs = np.array(coeff_string.replace(',', '').split(' '), dtype=float) + if freq_bands is None: + raise RuntimeError('The frequencies should be known before the coefficients are read.') + else: + assert len(freq_bands) == len(coeffs) + # Note: the material name is "sanitized" to only latin letters, digits, and underscores, + # MATCHING THE NAME REPORTED IN THE CONVERTED MESH FILES. + absorptions[sanitize_ascii(material)] = coeffs + + # Save the data in .csv format. + with open(str(output_folder_path / 'materials.csv'), mode='w') as file: + line = 'Frequencies, ' + ', '.join([str(f) for f in freq_bands]) + '\n' + file.write(line) + + for material, coeffs in absorptions.items(): + line = material + ', ' + ', '.join([str(c) for c in coeffs]) + '\n' + file.write(line) + + # TODO: For now, scattering coefficients are arbitrarily set to 0.3. + # Eventually the true values will be specified in the JSON. + line = material + ', 0.3\n' + file.write(line) + + +def noise_shaping(fs: int | float, + band_centers: np.ndarray, + envelopes: np.ndarray) -> np.ndarray: + """ + Generate impulse responses based on band-wise signal envelopes, through the + amplitude modulation of stochastic signals. + + Parameters + ---------- + fs : int + Audio sample rate of the envelopes AND the final response, in Hertz. + band_centers : np.ndarray + One-dimensional array containing the center frequency of each band, in Hertz. + envelopes : np.ndarray + Four-dimensional array containing the modulation envelopes. + It has shape (S, R, B, T), where: + - S is the number of sound sources + - R is the number of receivers + - B is the number of frequency bands (must match length of "band_centers") + - T is the duration in samples. + + Returns + ------- + response : np.ndarray + Three-dimensional array containing the room impulse responses. + It has shape (S, R, T), matching the energy envelopes (all bands are combined). + + Notes + ----- + The provided envelopes are assumed to be AMPLITUDE, i.e., square-rooted energy. + The stochastic signal used for modulation is a Poisson process with lambda=0.5. + The signal is generated only once and used for all sources, receivers, and + frequency bands (band-passed appropriately for each). + The band-passing occurs BEFORE the amplitude modulation, so out-of-band artefacts + may be introduced if the envelopes are very jagged. + """ + assert band_centers.ndim == 1 + assert envelopes.ndim == 4 + assert envelopes.shape[2] == band_centers.shape[0] + + num_bands = band_centers.shape[0] + duration_in_samples = envelopes.shape[3] + + # Random number generator for the stochastic signal to be modulated. + rng = default_rng() + + # White noise + # noise_signal = rng.normal(size=duration_in_samples) + # Poisson process + noise_signal = rng.poisson(lam=0.5, size=duration_in_samples).astype(float) + + # Ensure the noise signal has unit energy per second, matching the + # convention used to generate the echograms. + noise_signal *= np.sqrt(duration_in_samples * fs / np.sum(noise_signal**2)) + + # Factor for octave-band boundaries. + # TODO: These may become third-octave bands at some point. + band_bound = np.sqrt(2) + + # Consider the frequency band centers provided alongside the input data. + # Ensure that all frequencies support filtering with the given sample rate. + if np.any(band_centers * band_bound >= fs): + print('Warning: the audio sample rate is too low for some frequency bands.') + # Select only the acceptable bands. + band_centers = band_centers[band_centers * band_bound < fs] + # Update the number of rendered bands. + num_bands = len(band_centers) + # Drop unused bands from the echogram, to preserve the right shape. + envelopes = envelopes[:, :, :num_bands] + + # Prepare an array for the band-pass filtered signals. + filtered_noise_signals = np.zeros((num_bands, duration_in_samples)) + + for b in range(num_bands): + # Prepare the suitable band-pass filter... + sos = butter(6, (band_centers[b] / band_bound, + band_centers[b] * band_bound), + btype='bandpass', output='sos', fs=fs) + # ...and apply it to the stochastic signal. + filtered_noise_signals[b] = sosfilt(sos, noise_signal) + + # The envelope array has shape (S, R, B, T), the noise signals have shape (B, T): + # we need to add two "leading" dimensions, which is done using [None, None]. + modulated_noise_signals = envelopes * filtered_noise_signals[None, None] + + # The dimension of index 2 holds the separate frequency bands. + # Sum the array along that dimension to obtain the complete impulse responses. + return np.sum(modulated_noise_signals, axis=2) + + +def schroeder_curves(fs: int | float, + band_centers: np.ndarray, + echograms: np.ndarray) -> np.ndarray: + """ + Convert a set of echograms into backward-integrated energy curves, with bandwitdh correction. + + Parameters + ---------- + fs : int + Audio sample rate used for the bandwidth correction, in Hertz. + band_centers : np.ndarray + One-dimensional array containing the center frequency of each band, in Hertz. + echograms : np.ndarray + Four-dimensional array containing the energy envelopes. + It has shape (S, R, B, T), where: + - S is the number of sound sources + - R is the number of receivers + - B is the number of frequency bands (must match length of "band_centers") + - T is the duration in samples. + + Returns + ------- + response : np.ndarray + Array of the same shape as "echograms", containing the energy decay curves. + """ + assert band_centers.ndim == 1 + assert echograms.ndim == 4 + assert echograms.shape[2] == band_centers.shape[0] + + # Factor for octave-band boundaries. + # TODO: These may become third-octave bands at some point. + band_bound = np.sqrt(2) + lower_thres = band_centers / band_bound + upper_thres = band_centers * band_bound + band_widths = upper_thres - lower_thres + + nyquist = fs / 2 + echograms *= band_widths[None, None, :, None] / nyquist + + return np.cumsum(echograms[:, :, :, ::-1], axis=-1)[:, :, :, ::-1] + + +class MoDARTMethod(SimulationMethod): + """Interface class to run the MoDART method. + + The class implements a method to run the calculations for the + MoD-ART analysis and generate responses using its parameters. + All required configuration parameters are expected to be provided + in the input JSON file passed during initialization. + """ + # TODO: Add more "failure" tests: make sure the correct exception is raised, e.g., + # "with pytest.raises(FileNotFoundError, match='part of error message'):" + # TODO: Fill out metrics like T30? It will be done by the backend eventually. + + def __init__(self, input_json_path: str | Path): + """Initialize the MoD-ART interface for the given JSON file. + + Parameters + ---------- + json_file_path : str | Path + Path to the JSON configuration file. + """ + super().__init__(input_json_path) + + def run_simulation(self) -> None: + """Prepare the input data and run the method proper. + """ + # Load the JSON configuration file. + with open(self.input_json_path, "r") as json_file: + result_container = json.load(json_file) + + # Create a folder for the "temporary" ART and MoD-ART data. + geo_path = Path(result_container['geo_path']) + temp_subfolder = geo_path.parent / (str(geo_path.stem) + '_MoDART_data') + result_container['MoDART_data_subfolder'] = str(temp_subfolder) + if not temp_subfolder.is_dir(): + Path.mkdir(temp_subfolder) + + # Save the updated JSON (with the added MoDART_data_subfolder field). + with open(self.input_json_path, "w") as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + # Convert the .geo file into the format expected by MoD-ART. + try: + save_converted_mesh(result_container['geo_path'], temp_subfolder) + except Exception as exc: + raise RuntimeError('Failed to reformat the input mesh as required.') from exc + + # Save the material information into the .csv file expected by MoD-ART. + try: + save_materials_file(self.input_json_path) + except Exception as exc: + raise RuntimeError('Failed to reformat the material properties as required.') from exc + + # Run a one-shot MoD-ART simulation. + self._modart_method() + + def _modart_method(self) -> None: + """ + Run a one-shot MoD-ART simulation, comprising: + - preparation of ART model (surface integrals); + - analysis of ART model (modal decomposition); + - generation of energy envelopes from MoD-ART parameters; + - generation of impulse responses from energy envelopes. + """ + # Load the JSON file and extract its relevant contents. + with open(self.input_json_path, "r") as json_file: + result_container = json.load(json_file) + environment_folder = result_container['MoDART_data_subfolder'] + audio_sample_rate = result_container['fs_auralization'] + response_duration = result_container['simulationSettings']['durat'] + echogram_sample_rate = result_container['simulationSettings']['f_e'] + multiprocess_pool_size = result_container['simulationSettings']['pool'] + humidity = result_container['simulationSettings']['humi'] + temperature = result_container['simulationSettings']['temp'] + pressure = result_container['simulationSettings']['pres'] + points_per_square_meter = result_container['simulationSettings']['ppsm'] + rays_per_hemisphere = result_container['simulationSettings']['rays'] + T60_threshold = result_container['simulationSettings']['T60'] + max_slopes_per_band = result_container['simulationSettings']['slopes'] + + # Each element of "results" is a simulation request for a different source in the same environment, + # which is analyzed only once. All share the same settings and the same list of receivers. + num_srcs = len(result_container['results']) + if num_srcs == 0: + raise ValueError('No source positions specified.') + + source_positions = np.zeros((num_srcs, 3)) + for src_idx in range(num_srcs): + src_dict = result_container['results'][src_idx] + + source_positions[src_idx] = np.array([src_dict['sourceX'], + src_dict['sourceY'], + src_dict['sourceZ']]) + + these_receiver_positions = np.array([[pos['x'], pos['y'], pos['z']] + for pos in src_dict['responses']]) + + if src_idx == 0: + receiver_positions = these_receiver_positions + elif these_receiver_positions.shape != receiver_positions.shape: + raise ValueError('The list of receiver positions is not the same for all sources.') + elif not np.allclose(receiver_positions, these_receiver_positions): + raise ValueError('The list of receiver positions is not the same for all sources.') + + num_rcvs = receiver_positions.shape[0] + if num_rcvs == 0: + raise ValueError('No receiver positions specified.') + + # Run the pre-processing (shared by all sources, receivers). + # Step 1: Prepare the ART model. + try: + compute_ART(folder_path=environment_folder, + multiprocess_pool_size=multiprocess_pool_size, + humidity=humidity, temperature=temperature, pressure=pressure, + points_per_square_meter=points_per_square_meter, + rays_per_hemisphere=rays_per_hemisphere) + except Exception as exc: + raise RuntimeError('Failed to create the ART model (environment pre-processing).') from exc + + # Claim that the ART model constitutes 40% of the overall progress (very arbitrary). + for src_idx in range(num_srcs): + result_container['results'][src_idx]['percentage'] = 40 + # Save the updated JSON. + with open(self.input_json_path, "w") as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + # Step 2: Analyze the ART model. + try: + compute_MoDART(folder_path=environment_folder, + echogram_sample_rate=echogram_sample_rate, + T60_threshold=T60_threshold, + max_slopes_per_band=max_slopes_per_band, + skip_T60_plots=True) + except Exception as exc: + raise RuntimeError('Failed to run the modal analysis (environment pre-processing).') from exc + + # Claim that the MoD-ART analysis constitutes another 40% of the overall progress (very arbitrary). + for src_idx in range(num_srcs): + result_container['results'][src_idx]['percentage'] = 80 + # Save the updated JSON. + with open(self.input_json_path, "w") as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + # Generate the echograms with MoD-ART. + try: + MoDART_tuple = run_MoDART(environment_folder, + source_positions, receiver_positions, + echogram_duration=response_duration, + echogram_sample_rate=echogram_sample_rate, + humidity=humidity, temperature=temperature, pressure=pressure, + num_rays=rays_per_hemisphere) + MoDART_echograms, frequencies, MoDART_data = MoDART_tuple + except Exception as exc: + raise RuntimeError(f'Failed to generate echograms for simulation #{src_idx+1}.') from exc + + # Some MoD-ART parameters will be saved in the JSON. + # For now, this is just for debugging/testing. + result_container['MoDART_data'] = { + 'T60': MoDART_data['T60'].tolist(), + 'Band idx': MoDART_data['Band idx'].tolist(), + 'Eigenvector shape': MoDART_data['V_hat'].shape + } + + for src_idx in range(num_srcs): + # Noise-shaping code (may need revising), in case an impulse response was to be returned. + """ + # Claim that the response generation constitutes the last 5% of the overall progress (very arbitrary). + result_container['results'][src_idx]['percentage'] = 95 + # Save the updated JSON. + with open(self.input_json_path, "w") as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + # Prepare the audio-rate time intervals at which we'll evaluate the upsampled echogram. + echogram_time_axis = np.arange(0, response_duration, 1 / echogram_sample_rate) + audio_time_axis = np.arange(0, response_duration, 1 / audio_sample_rate) + # We use a linear interpolation, because any other upsampling algorithm risks introducing negative values. + linear_spline = make_interp_spline(echogram_time_axis, MoDART_echograms, k=1, axis=-1) + upsampled_echograms = linear_spline(audio_time_axis) + + # Normalize w.r.t. the new sample rate, to preserve the energy-per-second definition of echogram values. + upsampled_echograms *= echogram_sample_rate / audio_sample_rate + + # Translate the energy envelopes to amplitude envelopes. + envelopes = np.sqrt(upsampled_echograms) + + # Amplitude-modulate band-passed stochastic signals to produce an impulse response. + try: + responses = noise_shaping(audio_sample_rate, + frequencies, envelopes) + except Exception as exc: + raise RuntimeError(f'Failed to generate responses for simulation #{src_idx+1}.') from exc + + # Write results back to JSON. + for rcv_idx in range(num_rcvs): + # Note that the first index of "responses" is for the single source position. + result_container['results'][src_idx]['responses'][rcv_idx]['receiverResults'] = responses[0, rcv_idx].tolist() + """ + + EDCs = schroeder_curves(audio_sample_rate, frequencies, MoDART_echograms) + EDCs = np.clip(EDCs, 1e-20, None) + EDCs = 10 * np.log10(EDCs) + + time_axis = np.arange(0, response_duration, 1 / echogram_sample_rate) + + for rcv_idx in range(num_rcvs): + for freq_idx, freq in enumerate(frequencies): + result_container['results'][src_idx]['responses'][rcv_idx]['receiverResults'].append( + { + "data": EDCs[src_idx, rcv_idx, freq_idx].tolist(), + "t": time_axis.tolist(), + "frequency": freq, + "type": "edc", + } + ) + + result_container['results'][src_idx]['percentage'] = 100 + # Save the updated JSON. + with open(self.input_json_path, "w") as json_output: + json_output.write(json.dumps(result_container, indent=4)) + + print("MoDART simulation completed successfully!") diff --git a/modart_method/pyproject.toml b/modart_method/pyproject.toml new file mode 100644 index 0000000..0398fd4 --- /dev/null +++ b/modart_method/pyproject.toml @@ -0,0 +1,73 @@ +[project] +name = "modart_interface" +version = "0.1.0" +description = "Python package implementing Acoustic Radiance Transfer and its Modal Decomposition." +requires-python = ">=3.11" +authors = [ + { name = "Matteo Scerbo", email = "matteo.scerbo@gmail.com" }, +] +keywords = [ + "acoustic simulation", + "geometrical acoustics", + "acoustic radiance transfer", +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +dependencies = [ + "raves>=0.1.2", + "gmsh==4.13.1", + "requests", +] + +[project.optional-dependencies] +deploy = [ + "twine", + "wheel", + "build", + "setuptools", + "bump-my-version", +] + +tests = [ + "pytest", + "pytest-cov", + "watchdog", + "ruff", + "coverage", +] + +docs = [ + "sphinx", + "autodocsumm>=0.2.14", + "pydata-sphinx-theme", + "sphinx_mdinclude", + "sphinx-design", + "sphinx-favicon", + "sphinx-reredirects", +] + +dev = ["modart_interface[deploy,tests,docs]"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["modart_interface"] + +[project.scripts] +modart_interface = "modart_interface:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/modart_method/tests/conftest.py b/modart_method/tests/conftest.py new file mode 100644 index 0000000..46e0f21 --- /dev/null +++ b/modart_method/tests/conftest.py @@ -0,0 +1,179 @@ +import json +import os +import pytest +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock + + +def default_data_path(): + """Get the path to the default data folder.""" + return os.path.join( + os.path.dirname(os.path.abspath(__file__))) + + +def load_default_input_data(): + """Load the example input data.""" + with open(os.path.join( + default_data_path(), + "test_input_modart.json"), 'r') as f: + data = json.load(f) + + return data + + +@pytest.fixture +def default_input_data(): + """Fixture to load the example input data.""" + return load_default_input_data() + + +@pytest.fixture +def create_temporary_input_file(): + """Fixture to create a temporary input JSON file for testing. + + Can be reused to write results to. + """ + + input_tmp = load_default_input_data() + geo_file = os.path.join( + default_data_path(), "test_room_modart.geo") + + with tempfile.TemporaryDirectory() as tmpdirname: + tmp_path = Path(tmpdirname) / "temp_input.json" + shutil.copy(geo_file, Path(tmpdirname)) + input_tmp['geo_path'] = os.path.join( + tmpdirname, "test_room_modart.geo") + with open(tmp_path, 'w') as f: + json.dump(input_tmp, f) + + yield str(tmp_path) + + return str(tmp_path) + + +def create_modified_settings_input_file_multi_factory(**settings): + """Factory to create a modified input file with multiple settings changed. + + Parameters + ---------- + **settings : dict + Key-value pairs to update in simulationSettings. + + Returns + ------- + callable + A generator function that yields the path to the temporary file. + + Examples + -------- + >>> factory = create_modified_settings_input_file_multi_factory( + ... slopes=2, + ... pool=1 + ... ) + >>> gen = factory() + >>> json_path = next(gen) + """ + + def _create_modified_settings_input_file(): + input_tmp = load_default_input_data() + + if 'absorption_coefficients' in settings: + modified_coefficients = settings['absorption_coefficients'] + + # Ensure absorption_coefficients exists + if 'absorption_coefficients' not in input_tmp: + input_tmp['absorption_coefficients'] = {} + + # Update all provided coefficients + input_tmp['absorption_coefficients'].update(modified_coefficients) + + if 'simulationSettings' in settings: + modified_settings = settings['simulationSettings'] + + # Ensure simulationSettings exists + if 'simulationSettings' not in input_tmp: + input_tmp['simulationSettings'] = {} + + # Update all provided settings + input_tmp['simulationSettings'].update(modified_settings) + + geo_file = os.path.join( + default_data_path(), "test_room_modart.geo") + + with tempfile.TemporaryDirectory() as tmpdirname: + tmp_path = Path(tmpdirname) / "temp_input.json" + shutil.copy(geo_file, Path(tmpdirname)) + input_tmp['geo_path'] = os.path.join( + tmpdirname, "test_room_modart.geo") + with open(tmp_path, 'w') as f: + json.dump(input_tmp, f) + + yield str(tmp_path) + + return _create_modified_settings_input_file + + +@pytest.fixture +def json_file_factory(): + """Factory fixture to create JSON files with custom simulation settings. + + This fixture returns a callable that creates temporary JSON files with + modified simulation settings. Use this in your test files with + @pytest.mark.parametrize for flexible parametrization. + + Returns + ------- + callable + A function that takes **settings and returns a generator that yields + the JSON file path. + + Examples + -------- + In your test file: + + >>> @pytest.fixture + >>> def config_file(request, json_file_factory): + ... gen = json_file_factory(**request.param) + ... return next(gen) + >>> + >>> @pytest.mark.parametrize('config_file', [ + ... {'slopes': 2, 'pool': 1}, + ... {'slopes': 3, 'pool': 4}, + ... ], indirect=True) + >>> def test_something(config_file): + ... interface = MoDARTMethod(config_file) + ... # test logic... + """ + def _factory(**settings): + return create_modified_settings_input_file_multi_factory(**settings)() + + return _factory + + +@pytest.fixture +def create_modified_input_file(request, json_file_factory): + """Fixture that creates a JSON file based on test parameters.""" + gen = json_file_factory(**request.param) + json_path = next(gen) + # Make sure that the generator is properly closed after the test function + # finalizes to ensure cleanup of temporary files + try: + yield json_path + finally: + gen.close() + + + +@pytest.fixture +def mock_requests_post(): + """Fixture to mock requests.post for CLI tests. + + Returns the mock object so tests can make assertions on it. + """ + with patch("modart_interface.definition.requests.post") as mock_post: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_post.return_value = mock_response + yield mock_post diff --git a/modart_method/tests/test_definition.py b/modart_method/tests/test_definition.py new file mode 100644 index 0000000..d7797ec --- /dev/null +++ b/modart_method/tests/test_definition.py @@ -0,0 +1,37 @@ +"""Test the SimulationMethod base class for modart method.""" +import pytest +from unittest.mock import patch +from pathlib import Path + +from modart_interface.definition import SimulationMethod + + +@patch.multiple(SimulationMethod, __abstractmethods__=set()) +def test_simulation_method_with_valid_file(create_temporary_input_file): + """Test SimulationMethod initialization with a valid file.""" + method = SimulationMethod(create_temporary_input_file) + assert method.input_json_path == create_temporary_input_file + + +@pytest.mark.parametrize("empty_path", [None, ""]) +@patch.multiple(SimulationMethod, __abstractmethods__=set()) +def test_simulation_method_with_none_path(empty_path): + """Test SimulationMethod initialization with None path.""" + with pytest.raises(FileNotFoundError, match="input_json_path cannot be None or empty"): + SimulationMethod(empty_path) + + +@patch.multiple(SimulationMethod, __abstractmethods__=set()) +def test_simulation_method_with_nonexistent_file(): + """Test SimulationMethod initialization with a non-existent file.""" + nonexistent_path = "/tmp/nonexistent_file_that_does_not_exist.json" + with pytest.raises(FileNotFoundError, match="Input JSON file not found"): + SimulationMethod(nonexistent_path) + + +@patch.multiple(SimulationMethod, __abstractmethods__=set()) +def test_simulation_method_with_path_object(create_temporary_input_file): + """Test SimulationMethod initialization with a Path object.""" + path_obj = Path(create_temporary_input_file) + method = SimulationMethod(path_obj) + assert method.input_json_path == path_obj diff --git a/modart_method/tests/test_fixtures.py b/modart_method/tests/test_fixtures.py new file mode 100644 index 0000000..6db77d1 --- /dev/null +++ b/modart_method/tests/test_fixtures.py @@ -0,0 +1,23 @@ +"""Test fixtures for MoDART method tests.""" +import pytest + + +def test_default_input_data_structure(default_input_data): + """Test that the default input data has the expected structure.""" + assert "results" in default_input_data + assert len(default_input_data["results"]) > 0 + for i in range(len(default_input_data["results"])): + assert "sourceX" in default_input_data["results"][i] + assert "sourceY" in default_input_data["results"][i] + assert "sourceZ" in default_input_data["results"][i] + assert "responses" in default_input_data["results"][i] + assert len(default_input_data["results"][i]["responses"]) > 0 + assert "geo_path" in default_input_data + assert "absorption_coefficients" in default_input_data + + +def test_create_temporary_input_file_fixture(create_temporary_input_file): + """Test that the temporary input file fixture works correctly.""" + import os + assert os.path.exists(create_temporary_input_file) + assert create_temporary_input_file.endswith(".json") diff --git a/modart_method/tests/test_input_modart.json b/modart_method/tests/test_input_modart.json new file mode 100644 index 0000000..639cbc9 --- /dev/null +++ b/modart_method/tests/test_input_modart.json @@ -0,0 +1,62 @@ +{ + "absorption_coefficients": { + "walls": "0.02, 0.03, 0.04, 0.08, 0.15", + "floor": "0.11, 0.22, 0.42, 0.57, 0.63", + "ceiling": "0.01, 0.01, 0.01, 0.01, 0.01" + }, + "geo_path": "tests/test_room_modart.geo", + "results": [ + { + "label": "Source 1", + "orderNumber": 1, + "percentage": 0, + "sourcePointId": "fbc3e3f4-62ba-409b-87c4-8ad6da93713d", + "sourceX": 0.3, + "sourceY": 0.3, + "sourceZ": 0.3, + "resultType": "MoDART", + "frequencies": [ + 125, + 250, + 500, + 1000, + 2000 + ], + "responses": [ + { + "label": "Receiver 1", + "orderNumber": 1, + "x": 0.6, + "y": 0.6, + "z": 0.3, + "pointId": "66821432-a860-4051-acf7-f3e719e479a3", + "parameters": { + "edt": [], + "t20": [], + "t30": [], + "c80": [], + "d50": [], + "ts": [], + "spl_t0_freq": [] + }, + "receiverResults": [] + } + ] + } + ], + "task_id": "6511d180-1431-4d89-bcd5-a81dceab6f3a", + "fs_auralization": 44100, + "simulationSettings": { + "durat": 1, + "f_e": 10000, + "T60": 0.1, + "slopes": 2, + "humi": 50, + "temp": 20, + "pres": 100, + "ppsm": 30, + "rays": 1000, + "pool": 4 + }, + "settingsPreset": "Default" +} diff --git a/modart_method/tests/test_modart_cli.py b/modart_method/tests/test_modart_cli.py new file mode 100644 index 0000000..7c5cf30 --- /dev/null +++ b/modart_method/tests/test_modart_cli.py @@ -0,0 +1,175 @@ +"""Test the MoDART method CLI.""" +import os +import json +import pytest +import numpy as np + +from modart_interface import main + + +""" +DEFAULT VALUES: +{'absorption_coefficients': {'walls': '0.02, 0.03, 0.04, 0.08, 0.15', + 'floor': '0.11, 0.22, 0.42, 0.57, 0.63', + 'ceiling': '0.01, 0.01, 0.01, 0.01, 0.01'}, + 'simulationSettings': {'durat': 1, + 'f_e': 10000, + 'T60': 0.1, + 'slopes': 2, + 'humi': 50, + 'temp': 20, + 'pres': 100, + 'ppsm': 30, + 'rays': 1000, + 'pool': 4}}, +""" +@pytest.mark.parametrize('create_modified_input_file', [ + {'simulationSettings': {'pool': 1}}, + {'simulationSettings': {'pool': 2}}, + {'simulationSettings': {'slopes': 1}}, + {'simulationSettings': {'slopes': 3}}, + {'absorption_coefficients': {'walls': '0, 0, 0, 0, 0', + 'floor': '0, 0, 0, 0, 0', + 'ceiling': '0, 0, 0, 0, 0'}, + 'simulationSettings': {'slopes': 1, 'humi': 0, 'temp': 0}}, + {'absorption_coefficients': {'walls': '0.9999, 0.9999, 0.9999, 0.9999, 0.9999', + 'floor': '0.9999, 0.9999, 0.9999, 0.9999, 0.9999', + 'ceiling': '0.9999, 0.9999, 0.9999, 0.9999, 0.9999'}, + 'simulationSettings': {'slopes': 1}}, + {'absorption_coefficients': {'walls': '1, 1, 1, 1, 1', + 'floor': '1, 1, 1, 1, 1', + 'ceiling': '1, 1, 1, 1, 1'}, + 'simulationSettings': {'slopes': 1}}, +], indirect=True) +def test_modart_method_cli(mock_requests_post, create_modified_input_file): + """Test the MoDART method CLI.""" + # Set JSON_PATH environment variable and call main() directly + os.environ["JSON_PATH"] = create_modified_input_file + + with open(create_modified_input_file, 'r') as f: + input_data = json.load(f) + + assert 'simulationSettings' in input_data + settings = input_data['simulationSettings'] + assert len(settings) > 0 + + assert 'absorption_coefficients' in input_data + coeffs = input_data['absorption_coefficients'] + assert len(coeffs) > 0 + + if all(coeff == '1, 1, 1, 1, 1' + for coeff in coeffs.values()): + # When all material absorptions are exactly 1, reverberation time is 0. + # The state transition matrix becomes singular, and decomposition is impossible. + with pytest.raises(RuntimeError, match='Failed to run the modal analysis'): + main() + else: + # With other settings, the decomposition should not have any issues. + # N.B.: All remaining tests are in this scope, where "main()" is successful. + main() + + with open(create_modified_input_file, 'r') as f: + output_data = json.load(f) + + #### ASSERTIONS FOR ALL TEST CASES #### + + assert 'results' in output_data + results = output_data['results'] + assert len(results) > 0 + for res in results: + assert 'responses' in res + responses = res['responses'] + assert len(responses) > 0 + for resp in responses: + assert 'receiverResults' in resp + rec_res = resp['receiverResults'] + assert rec_res is not None + assert len(rec_res) > 0 + for r in rec_res: + assert len(r) == 4 + assert 'data' in r + assert 't' in r + assert 'frequency' in r + assert 'type' in r + assert r['type'] == 'edc' + assert len(r['data']) == len(r['t']) + + assert np.all(np.isfinite(r['data'])) + + # The function returns a few MoD-ART parameters for debugging/testing. + assert 'MoDART_data' in output_data + MoDART_data = output_data['MoDART_data'] + assert len(MoDART_data) > 0 + + # The simulation settings ask for a number of slopes in 5 frequency bands, + # so there should be this many in total. + expected_modes = settings['slopes'] * 5 + + assert len(MoDART_data['T60']) == expected_modes + assert len(MoDART_data['Band idx']) == expected_modes + + # The band indices of the detected slopes should range from 0 to 4, + # and there should be a set number of each. + unique, unique_counts = np.unique(MoDART_data['Band idx'], return_counts=True) + assert np.all(unique == np.arange(5)) + assert np.all(unique_counts == settings['slopes']) + + # The test room has 6 patches with full visibility, so there should be 6*(6-1)=30 paths. + # If the second dimension is != 30, that means something went wrong in the mesh decoding. + assert MoDART_data['Eigenvector shape'] == [expected_modes, 30] + + #### ASSERTIONS SPECIFIC TO EACH TEST CASE #### + + if all(coeff == '0, 0, 0, 0, 0' + for coeff in coeffs.values()): + if settings['slopes'] != 1: + raise NotImplementedError('I did not prepare that much reference data.') + + # When all material absorptions are 0, reverberation time is only governed by air absorption. + assert np.allclose(MoDART_data['T60'], + [187.97, 181.98, 174.76, 153.67, 103.97], + rtol=0.05, atol=0.05) + + elif all(coeff == '0.9999, 0.9999, 0.9999, 0.9999, 0.9999' + for coeff in coeffs.values()): + if settings['slopes'] != 1: + raise NotImplementedError('I did not prepare that much reference data.') + + # When all material absorptions are close to 1, reverberation time is miniscule. + assert np.allclose(MoDART_data['T60'], + 5e-3, rtol=1e-3, atol=1e-3) + + else: + # Confirm that the T60 values are reasonably close to a reference run. + if settings['slopes'] == 1: + assert np.allclose(MoDART_data['T60'], + [0.56, 0.32, 0.19, 0.13, 0.1], + rtol=0.05, atol=0.05) + elif settings['slopes'] == 2: + assert np.allclose(MoDART_data['T60'], + [0.56, 0.03, 0.32, 0.03, 0.19, 0.03, 0.13, 0.03, 0.1, 0.02], + rtol=0.05, atol=0.05) + elif settings['slopes'] == 3: + assert np.allclose(MoDART_data['T60'], + [0.56, 0.03, 0.03, + 0.32, 0.03, 0.03, + 0.19, 0.03, 0.03, + 0.13, 0.03, 0.03, + 0.1, 0.02, 0.02], + rtol=0.05, atol=0.05) + else: + raise NotImplementedError('I did not prepare that much reference data.') + + # Verify that requests.post was called (save_results was executed) + mock_requests_post.assert_called_once() + + +def test_modart_method_cli_missing_json_path(mock_requests_post): + """Test the MoDART method CLI with missing JSON_PATH.""" + # Clear JSON_PATH environment variable + if "JSON_PATH" in os.environ: + del os.environ["JSON_PATH"] + + # Expect FileNotFoundError from SimulationMethod.__init__ + with pytest.raises(FileNotFoundError, match="input_json_path cannot be None or empty"): + main() diff --git a/modart_method/tests/test_room_modart.geo b/modart_method/tests/test_room_modart.geo new file mode 100644 index 0000000..f2048e9 --- /dev/null +++ b/modart_method/tests/test_room_modart.geo @@ -0,0 +1,48 @@ +Point(1) = { 0.0, 0.0, 0.0, 1.0 }; +Point(2) = { 0.0, 0.0, 0.66, 1.0 }; +Point(3) = { 0.0, 1.02, 0.0, 1.0 }; +Point(4) = { 0.0, 1.02, 0.66, 1.0 }; +Point(5) = { 1.104, 0.0, 0.0, 1.0 }; +Point(6) = { 1.104, 0.0, 0.66, 1.0 }; +Point(7) = { 1.242, 0.8, 0.0, 1.0 }; +Point(8) = { 1.242, 0.8, 0.66, 1.0 }; + +Line(1) = { 3, 7 }; +Line(2) = { 5, 7 }; +Line(3) = { 1, 5 }; +Line(4) = { 1, 3 }; +Line(5) = { 4, 8 }; +Line(6) = { 7, 8 }; +Line(7) = { 3, 4 }; +Line(8) = { 2, 6 }; +Line(9) = { 6, 8 }; +Line(10) = { 2, 4 }; +Line(11) = { 1, 2 }; +Line(12) = { 5, 6 }; + +Line Loop(1) = { 1, -2, -3, 4 }; +Line Loop(2) = { 5, -6, -1, 7 }; +Line Loop(3) = { 8, 9, -5, -10 }; +Line Loop(4) = { -8, -11, 3, 12 }; +Line Loop(5) = { 10, -7, -4, 11 }; +Line Loop(6) = { 2, 6, -9, -12 }; + +Plane Surface(1) = { 1 }; +Plane Surface(2) = { 2 }; +Plane Surface(3) = { 3 }; +Plane Surface(4) = { 4 }; +Plane Surface(5) = { 5 }; +Plane Surface(6) = { 6 }; + +Surface Loop(1) = { 1, 2, 3, 4, 5, 6 }; +Physical Surface("floor") = { 1 }; +Physical Surface("ceiling") = { 3 }; +Physical Surface("walls") = { 2, 4, 5, 6 }; +Volume(1) = { 1 }; +Physical Volume("RoomVolume") = { 1 }; +Physical Line("default") = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }; +Mesh.Algorithm = 6; +Mesh.Algorithm3D = 1; // Delaunay3D, works for boundary layer insertion. +Mesh.Optimize = 1; // Gmsh smoother, works with boundary layers (netgen version does not). +Mesh.CharacteristicLengthFromPoints = 1; +// Recombine Surface "*";