diff --git a/.github/workflows/simulation-core.yml b/.github/workflows/simulation-core.yml new file mode 100644 index 0000000..0e748fc --- /dev/null +++ b/.github/workflows/simulation-core.yml @@ -0,0 +1,33 @@ +name: CHORAS simulation core CI + +on: + push: + branches: + - '**' + +jobs: + uv-test: + name: Pytest for the simulation core + runs-on: ubuntu-latest + continue-on-error: true + + strategy: + matrix: + python-version: + - "3.11" + - "3.12" + - "3.13" + - "3.14" + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + + - name: Run test for pyroomacoustics method + run: | + cd choras-simulation-core + uv run --extra tests pytest tests/ diff --git a/choras-simulation-core/.gitignore b/choras-simulation-core/.gitignore new file mode 100644 index 0000000..5b1aba8 --- /dev/null +++ b/choras-simulation-core/.gitignore @@ -0,0 +1,61 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/choras-simulation-core/choras_simulation_core/__init__.py b/choras-simulation-core/choras_simulation_core/__init__.py new file mode 100644 index 0000000..f94446d --- /dev/null +++ b/choras-simulation-core/choras_simulation_core/__init__.py @@ -0,0 +1,18 @@ +"""CHORAS Simulation Core Package. + +This package provides shared base classes and utilities for all CHORAS +simulation methods. It defines the common interface that all simulation +methods must implement and provides structured exception types for +meaningful error reporting. + +""" + +from choras_simulation_core.base import SimulationMethod +from choras_simulation_core import exceptions + +__version__ = "0.1.0" + +__all__ = [ + "SimulationMethod", + "exceptions", +] diff --git a/choras-simulation-core/choras_simulation_core/base.py b/choras-simulation-core/choras_simulation_core/base.py new file mode 100644 index 0000000..c802739 --- /dev/null +++ b/choras-simulation-core/choras_simulation_core/base.py @@ -0,0 +1,141 @@ +"""Base class for CHORAS simulation methods. + +This module provides the abstract base class that all simulation methods +must inherit from. It handles common functionality like JSON file validation +and result transmission back to the backend. +""" + +import time +from abc import ABC, abstractmethod +from pathlib import Path + +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. + + Parameters + ---------- + input_json_path : str | Path | None + The path to the input JSON file containing simulation configuration. + + Raises + ------ + FileNotFoundError + If the input JSON file does not exist or is None/empty. + + Examples + -------- + >>> class MySimulationMethod(SimulationMethod): + ... def run_simulation(self): + ... # Implementation here + ... pass + >>> method = MySimulationMethod("/path/to/config.json") + >>> method.run_simulation() + >>> method.save_results() + + """ + + def __init__(self, input_json_path: str | Path | None): + """Initialize the simulation method. + + Parameters + ---------- + input_json_path : str | Path | None + The path to the input JSON file. Cannot be None or empty. + + Raises + ------ + FileNotFoundError + If the input JSON file does not exist or path is None/empty. + + """ + 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: + """Get the input JSON file path. + + Returns + ------- + str | Path + The path to the input JSON configuration file. + + """ + return self._input_json_path + + @abstractmethod + def run_simulation(self): + """Run the simulation for the given JSON configuration file. + + This method must be implemented by all subclasses to perform + the actual simulation computation. + + """ + 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. + + This method sends the simulation results stored in the input JSON file + back to the backend service via HTTP POST request. + + Parameters + ---------- + url : str, optional + The URL of the results server. Default is + "http://host.docker.internal:5001/receive" which is the + standard address for local execution via Docker. + max_retries : int, optional + The maximum number of retries if the request fails. + Default is 5. + delay : int, optional + The delay in seconds between retries. Default is 2. + + Returns + ------- + bool + True if results were successfully sent, False otherwise. + + """ + 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/choras-simulation-core/choras_simulation_core/exceptions.py b/choras-simulation-core/choras_simulation_core/exceptions.py new file mode 100644 index 0000000..7b07fa7 --- /dev/null +++ b/choras-simulation-core/choras_simulation_core/exceptions.py @@ -0,0 +1,108 @@ +"""Exception hierarchy for CHORAS simulation methods. + +This module defines a structured exception hierarchy that simulation methods +can use to provide meaningful, user-friendly error messages that will be +propagated to the frontend. +""" + + +class SimulationError(Exception): + """Base exception for all simulation-related errors. + + This is the base class for all custom exceptions raised by simulation + methods. It ensures that errors can be caught generically while still + maintaining specific error types. + + Parameters + ---------- + message : str + A user-friendly description of what went wrong. + + """ + + def __init__(self, message: str): + """Initialize the simulation error. + + Parameters + ---------- + message : str + A user-friendly error message. + + """ + self.message = message + super().__init__(self.message) + + +class ConfigurationError(SimulationError): + """Exception raised for configuration-related errors. + + Use this exception when the simulation fails due to invalid configuration, + missing parameters, invalid JSON structure, or incorrect settings. + + Examples + -------- + >>> raise ConfigurationError( + ... f"Absorption coefficient format for {material_id} incorrect - " + ... "Check material assignments" + ... ) + + """ + + pass + + +class GeometryError(SimulationError): + """Exception raised for geometry-related errors. + + Use this exception when the simulation fails due to geometry issues + such as missing mesh files, invalid geometry, malformed meshes, or + geometry processing errors. + + Examples + -------- + >>> raise GeometryError( + ... "The provided geometry is invalid - " + ... "Please verify the geometry file was uploaded correctly" + ... ) + + """ + + pass + + +class ComputationError(SimulationError): + """Exception raised for computation/solver errors. + + Use this exception when the simulation fails during the actual + computation phase, such as solver divergence, numerical instability, + or algorithm-specific failures. + + Examples + -------- + >>> raise ComputationError( + ... "Solver diverged after 1000 iterations - " + ... "Try reducing time step or increasing damping" + ... ) + + """ + + pass + + +class ResourceError(SimulationError): + """Exception raised for resource-related errors. + + Use this exception when the simulation fails due to insufficient + resources such as memory, disk space, file access permissions, or + other system resource issues. + + Examples + -------- + >>> raise ResourceError( + ... "Insufficient memory to allocate resources " + ... "Try reducing mesh density or use cloud resources" + ... ) + + """ + + pass diff --git a/choras-simulation-core/pyproject.toml b/choras-simulation-core/pyproject.toml new file mode 100644 index 0000000..a6c739a --- /dev/null +++ b/choras-simulation-core/pyproject.toml @@ -0,0 +1,88 @@ +[project] +name = "choras-simulation-core" +version = "0.1.0" +description = "Shared base classes and utilities for CHORAS simulation methods" +requires-python = ">=3.11" +authors = [ + { name = "Marco Berzborn", email = "m.berzborn@tue.nl" }, + { name = "The CHORAS developers" } +] +keywords = [ + "choras", + "acoustic simulation", + "simulation framework", + "base classes", +] + +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 = [ + "requests>=2.28.0", +] + +[project.optional-dependencies] +tests = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "ruff==0.15.16", +] + +dev = ["choras-simulation-core[tests]"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["choras_simulation_core"] + +[tool.setuptools.package-data] +choras_simulation_core = ["py.typed"] + +[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" \ No newline at end of file diff --git a/choras-simulation-core/tests/conftest.py b/choras-simulation-core/tests/conftest.py new file mode 100644 index 0000000..1b04df1 --- /dev/null +++ b/choras-simulation-core/tests/conftest.py @@ -0,0 +1,49 @@ +"""Fixtures for testing.""" + +import json +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def temp_json_file(): + """Fixture to create a temporary JSON file for testing. + + Yields the path to the temporary file and cleans it up after the test. + """ + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as f: + json.dump({"test": "data"}, f) + temp_path = f.name + + yield temp_path + + # Cleanup + Path(temp_path).unlink(missing_ok=True) + + +@pytest.fixture +def mock_requests_post(): + """Fixture to mock requests.post for testing save_results method. + + Returns the mock object so tests can make assertions on it. + """ + with patch("choras_simulation_core.base.requests.post") as mock_post: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_post.return_value = mock_response + yield mock_post + + +@pytest.fixture +def mock_time_sleep(): + """Fixture to mock time.sleep to speed up tests. + + Returns the mock object so tests can make assertions on it. + """ + with patch("choras_simulation_core.base.time.sleep") as mock_sleep: + yield mock_sleep diff --git a/choras-simulation-core/tests/test_base.py b/choras-simulation-core/tests/test_base.py new file mode 100644 index 0000000..c09e357 --- /dev/null +++ b/choras-simulation-core/tests/test_base.py @@ -0,0 +1,189 @@ +"""Tests for the SimulationMethod base class.""" + +from functools import wraps +from unittest.mock import patch + +import pytest + +from choras_simulation_core import SimulationMethod + + +def patch_abstract_methods(test_func): + """Decorator to patch abstract methods for testing. + + This decorator temporarily removes the __abstractmethods__ attribute + from SimulationMethod, allowing it to be instantiated for testing + without needing to implement the abstract run_simulation method. + """ + + @wraps(test_func) + def wrapper(*args, **kwargs): + with patch.multiple(SimulationMethod, __abstractmethods__=set()): + return test_func(*args, **kwargs) + + return wrapper + + +@patch_abstract_methods +def test_simulation_method_init_with_valid_path(temp_json_file): + """Test initialization with a valid JSON file.""" + method = SimulationMethod(temp_json_file) + assert method.input_json_path == temp_json_file + + +@patch_abstract_methods +def test_simulation_method_init_with_none(): + """Test initialization with None path raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="cannot be None or empty"): + SimulationMethod(None) + + +@patch_abstract_methods +def test_simulation_method_init_with_empty_string(): + """Test initialization with empty string raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="cannot be None or empty"): + SimulationMethod("") + + +@patch_abstract_methods +def test_simulation_method_init_with_nonexistent_file(): + """Test initialization with nonexistent file raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="Input JSON file not found"): + SimulationMethod("/nonexistent/path/file.json") + + +@patch_abstract_methods +def test_save_results_method_exists(temp_json_file): + """Test that save_results method is available and callable.""" + method = SimulationMethod(temp_json_file) + # Verify the method exists and is callable + assert callable(method.save_results) + + +@patch_abstract_methods +def test_input_json_path_property(temp_json_file): + """Test that input_json_path is accessible as a property.""" + method = SimulationMethod(temp_json_file) + # Test getter + assert method.input_json_path == temp_json_file + # Verify it's read-only (no setter defined) + assert hasattr(method, "input_json_path") + + +@patch_abstract_methods +def test_save_results_success(temp_json_file, mock_requests_post): + """Test that save_results successfully sends file on first attempt.""" + method = SimulationMethod(temp_json_file) + result = method.save_results(url="http://test.example.com/receive") + + # Verify success + assert result is True + + # Verify requests.post was called once + assert mock_requests_post.call_count == 1 + + # Verify the correct URL was used + mock_requests_post.assert_called_once() + call_args = mock_requests_post.call_args + assert call_args[0][0] == "http://test.example.com/receive" + + # Verify the file was sent + assert "file" in call_args[1]["files"] + + +@patch_abstract_methods +def test_save_results_retries_on_failure( + temp_json_file, mock_time_sleep, mock_requests_post +): + """Test that save_results retries on non-200 status codes.""" + # Mock responses: first two fail, third succeeds + responses = [500, 500, 200] + + def side_effect(*args, **kwargs): + mock_response = type("MockResponse", (), {})() + mock_response.status_code = responses.pop(0) + return mock_response + + mock_requests_post.side_effect = side_effect + + method = SimulationMethod(temp_json_file) + result = method.save_results(max_retries=3, delay=1) + + # Verify success after retries + assert result is True + + # Verify requests.post was called 3 times + assert mock_requests_post.call_count == 3 + + # Verify sleep was called between retries (2 times) + assert mock_time_sleep.call_count == 2 + mock_time_sleep.assert_called_with(1) + + +@patch_abstract_methods +def test_save_results_max_retries_exceeded( + temp_json_file, mock_time_sleep, mock_requests_post +): + """Test that save_results returns False after max retries.""" + # Mock all attempts to fail + mock_requests_post.return_value.status_code = 500 + + method = SimulationMethod(temp_json_file) + result = method.save_results(max_retries=3, delay=1) + + # Verify failure + assert result is False + + # Verify requests.post was called max_retries times + assert mock_requests_post.call_count == 3 + + # Verify sleep was called between all retries + assert mock_time_sleep.call_count == 3 + + +@patch_abstract_methods +def test_save_results_handles_request_exception( + temp_json_file, mock_time_sleep, mock_requests_post +): + """Test that save_results handles RequestException and retries.""" + import requests + + # Mock first two attempts to raise exception, third succeeds + side_effects = [ + requests.RequestException("Connection error"), + requests.RequestException("Timeout"), + type("MockResponse", (), {"status_code": 200})(), + ] + mock_requests_post.side_effect = side_effects + + method = SimulationMethod(temp_json_file) + result = method.save_results(max_retries=3, delay=1) + + # Verify success after handling exceptions + assert result is True + + # Verify requests.post was called 3 times + assert mock_requests_post.call_count == 3 + + # Verify sleep was called between retries + assert mock_time_sleep.call_count == 2 + + +@patch_abstract_methods +def test_save_results_uses_default_parameters( + temp_json_file, mock_requests_post +): + """Test that save_results uses correct default parameters.""" + method = SimulationMethod(temp_json_file) + method.save_results() + + # Verify default URL was used + call_args = mock_requests_post.call_args + assert call_args[0][0] == "http://host.docker.internal:5001/receive" + + +def test_abstract_method_must_be_implemented(temp_json_file): + """Test that abstract SimulationMethod cannot be instantiated.""" + # Without the decorator, this should raise TypeError + with pytest.raises(TypeError, match="Can't instantiate abstract class"): + SimulationMethod(temp_json_file) diff --git a/choras-simulation-core/tests/test_exceptions.py b/choras-simulation-core/tests/test_exceptions.py new file mode 100644 index 0000000..7bd289f --- /dev/null +++ b/choras-simulation-core/tests/test_exceptions.py @@ -0,0 +1,59 @@ +"""Tests for the custom exception classes.""" + +import pytest + +from choras_simulation_core.exceptions import ( + ComputationError, + ConfigurationError, + GeometryError, + ResourceError, + SimulationError, +) + + +@pytest.mark.parametrize( + ("exception_class", "error_message"), + [ + (ConfigurationError, "Test configuration error"), + (GeometryError, "Test geometry error"), + (ComputationError, "Test computation error"), + (ResourceError, "Test resource error"), + ], + ids=[ + "ConfigurationError", + "GeometryError", + "ComputationError", + "ResourceError", + ], +) +def test_exception_can_be_raised_and_caught(exception_class, error_message): + """Test correctly raising the exception type and message.""" + with pytest.raises(exception_class, match=error_message): + raise exception_class(error_message) + + +@pytest.mark.parametrize( + "exception_class", + [ + ConfigurationError, + GeometryError, + ComputationError, + ResourceError, + ], + ids=[ + "ConfigurationError", + "GeometryError", + "ComputationError", + "ResourceError", + ], +) +def test_exception_hierarchy(exception_class): + """Test that all custom exceptions inherit from SimulationError.""" + assert issubclass(exception_class, SimulationError) + + +def test_exception_message_attribute(): + """Test that SimulationError stores the message attribute.""" + error = ConfigurationError("Test message") + assert error.message == "Test message" + assert str(error) == "Test message"