Skip to content

Conversation

@eelcovv
Copy link
Owner

@eelcovv eelcovv commented Oct 28, 2025

Applied all sourcery recommendations to the linear code

Summary by Sourcery

Refactor and enhance mesh management and simulation workflow by introducing structured MeshConfig and EngineMesh objects, extracting transformation logic, improving HDF5 mesh database operations, extending CLI settings handling, and adding a mesh fitting feature with Chamfer distance. Update tests, docs, examples, and CI to align with these changes.

New Features:

  • Introduce MeshConfig and EngineMesh to manage mesh settings and metadata
  • Support mesh-specific wave periods and directions overriding global settings
  • Add base_mesh and base_origin for defining simulation coordinate origin
  • Implement mesh fitting module with find_best_matching_mesh and Chamfer distance evaluation

Enhancements:

  • Refactor mesh transformation into _apply_mesh_translation_and_rotation
  • Modularize simulation pipeline into _process_single_stl and _run_pipeline_for_mesh
  • Improve HDF5 database handling with hash deduplication and metadata writing
  • Streamline _setup_output_file to support varied STL file inputs and output_directory logic
  • Extend load_meshes_from_hdf5 to include mesh metadata and robust loading

CI:

  • Limit GitHub Actions Python matrix to supported versions

Documentation:

  • Update CONTRIBUTING.md with extras install and commit squashing guide
  • Add example scripts and YAML configurations for defraction_box and fitting_example

Tests:

  • Expand unit tests for new mesh config, pipeline, and fitting functionality

Eelco van Vliet and others added 20 commits October 28, 2025 11:12
This commit introduces a major refactoring of the core engine and adds a new mesh fitting capability. It also includes significant improvements to the development tooling and examples.

Core Engine Refactoring:
- Introduced `MeshConfig` and `EngineMesh` data structures to provide a more robust and explicit way of handling mesh configurations and transformations.
- Refactored the simulation pipeline in `run.py` and `engine.py` to be more modular and easier to follow.
- Improved path resolution and handling of settings from both CLI and YAML files.

Mesh Fitting Feature:
- Added a new `fitting` module to find the best-matching mesh from an HDF5 database based on a target transformation (translation, rotation).
- Implemented Chamfer distance calculation to robustly compare mesh similarities.
- Added a new example script (`fitting_example.py`) to demonstrate this functionality.

Tooling and Examples:
- Added new example scripts and corresponding YAML settings files to demonstrate core features like symmetry, rotations, and drafts.
- Enhanced the `Justfile` with new commands for development (`install-dev`), running tests with coverage (`test-cov`), and managing examples (`generate-all`, `fleetmaster-all`, `clean-examples`).

Fixes:
- Resolved several `mypy` type errors in the test suite and CLI command files.
- Fixed a `setuptools-scm` warning by removing a redundant version configuration in `pyproject.toml`.
- Updated dependencies in `pyproject.toml` and `uv.lock` to resolve conflicts and ensure a stable build.
- Updated `CONTRIBUTING.md` with instructions for squashing commits.feat: Refactor core engine and add mesh fitting feature

This commit introduces a major refactoring of the core engine and adds a new mesh fitting capability. It also includes significant improvements to the development tooling and examples.

Core Engine Refactoring:
- Introduced `MeshConfig` and `EngineMesh` data structures to provide a more robust and explicit way of handling mesh configurations and transformations.
- Refactored the simulation pipeline in `run.py` and `engine.py` to be more modular and easier to follow.
- Improved path resolution and handling of settings from both CLI and YAML files.

Mesh Fitting Feature:
- Added a new `fitting` module to find the best-matching mesh from an HDF5 database based on a target transformation (translation, rotation).
- Implemented Chamfer distance calculation to robustly compare mesh similarities.
- Added a new example script (`fitting_example.py`) to demonstrate this functionality.

Tooling and Examples:
- Added new example scripts and corresponding YAML settings files to demonstrate core features like symmetry, rotations, and drafts.
- Enhanced the `Justfile` with new commands for development (`install-dev`), running tests with coverage (`test-cov`), and managing examples (`generate-all`, `fleetmaster-all`, `clean-examples`).

Fixes:
- Resolved several `mypy` type errors in the test suite and CLI command files.
- Fixed a `setuptools-scm` warning by removing a redundant version configuration in `pyproject.toml`.
- Updated dependencies in `pyproject.toml` and `uv.lock` to resolve conflicts and ensure a stable build.
- Updated `CONTRIBUTING.md` with instructions for squashing commits.
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Oct 28, 2025

Reviewer's Guide

This PR refactors mesh handling and simulation pipelines by introducing structured mesh metadata (MeshConfig, EngineMesh), centralizing transformation and HDF5 operations in dedicated helpers, simplifying categorical dtype checks, and modularizing case processing. The settings model and CLI are updated to use MeshConfig, support base mesh origins, and resolve paths.

Sequence diagram for mesh processing and simulation pipeline

sequenceDiagram
    participant S as "SimulationSettings"
    participant E as "EngineMesh"
    participant DB as "HDF5 Database"
    participant IO as "load_meshes_from_hdf5()"
    participant PIPE as "_run_pipeline_for_mesh()"
    S->E: Create EngineMesh for each MeshConfig
    E->DB: Check for mesh in HDF5 (via IO)
    alt Mesh exists and not overwrite
        DB->E: Return mesh from database
    else Mesh not in DB or overwrite
        E->IO: Load/generate mesh from STL
        E->DB: Save mesh to HDF5
    end
    E->PIPE: Run simulation pipeline for mesh
    PIPE->DB: Save results to HDF5
Loading

ER diagram for mesh and simulation data in HDF5 database

erDiagram
    MESHES {
        sha256 string
        translation list[float]
        rotation list[float]
        cog list[float]
        inertia_tensor array
        stl_content binary
    }
    CASES {
        stl_mesh_name string
        transformation_matrix array
        cog_for_calculation list[float]
        simulation_results array
    }
    MESHES ||--o{ CASES : contains
    MESHES {
        volume float
        cog_x float
        cog_y float
        cog_z float
        bbox_lx float
        bbox_ly float
        bbox_lz float
    }
Loading

Class diagram for new mesh configuration and engine types

classDiagram
    class MeshConfig {
        +file: str
        +name: str | None
        +translation: list[float]
        +rotation: list[float]
        +cog: list[float] | None
        +wave_periods: float | list[float] | None
        +wave_directions: float | list[float] | None
        +check_vector_length(v)
        +check_cog_length(v)
    }
    class EngineMesh {
        +name: str
        +mesh: trimesh.Trimesh
        +config: MeshConfig
    }
    class SimulationSettings {
        +base_mesh: str | None
        +base_origin: list[float] | None
        +stl_files: list[MeshConfig]
        +output_directory: str | None
        +output_hdf5_file: str
        +wave_periods: float | list[float]
        +wave_directions: float | list[float]
        +forward_speed: float | list[float]
        +lid: bool
        +add_center_of_mass: bool
        +grid_symmetry: bool
        +update_cases: bool
        +combine_cases: bool
        +normalize_stl_files(v)
        +speed_must_be_non_negative(v)
    }
    MeshConfig <.. EngineMesh : config
    MeshConfig <.. SimulationSettings : used in stl_files
Loading

Class diagram for new and updated exceptions

classDiagram
    class SimulationConfigurationError {
    }
    class InvalidVectorLength {
        +__init__(message)
    }
    class HDF5AttributeError {
        +__init__(attribute_name)
    }
    class MeshLoadError {
        +__init__(mesh_name)
    }
    class DatabaseFileNotFoundError {
        +__init__(path)
    }
    SimulationConfigurationError <|-- InvalidVectorLength
    ValueError <|-- HDF5AttributeError
    RuntimeError <|-- MeshLoadError
    FileNotFoundError <|-- DatabaseFileNotFoundError
Loading

File-Level Changes

Change Details Files
Introduce structured mesh metadata
  • Define EngineMesh dataclass to bundle name, mesh, and config
  • Add MeshConfig Pydantic model for mesh file, translation, rotation, cog, and wave overrides
  • Normalize stl_files to MeshConfig instances in SimulationSettings
src/fleetmaster/core/engine.py
src/fleetmaster/core/settings.py
Unify mesh transformation logic
  • Update _prepare_trimesh_geometry to accept MeshConfig
  • Extract translation and rotation application into _apply_mesh_translation_and_rotation
  • Use center_of_mass or specified cog for rotation point
src/fleetmaster/core/engine.py
Modularize HDF5 mesh management
  • Extract hash computation to _get_mesh_hash
  • Factor existing‐mesh logic into _handle_existing_mesh
  • Move attribute and dataset writing into _write_mesh_to_group
  • Simplify add_mesh_to_database to orchestrate these helpers
src/fleetmaster/core/engine.py
Simplify categorical dtype handling
  • Remove pandas import
  • Replace pandas.CategoricalDtype check with hasattr(dtype, 'categories')
src/fleetmaster/core/engine.py
Refactor simulation case pipeline
  • Introduce _obtain_mesh and _load_or_generate_mesh for cache or generation
  • Add _process_and_save_single_case and _log_pipeline_parameters
  • Replace nested loops with itertools.product in process_all_cases_for_one_stl
src/fleetmaster/core/engine.py
Enhance settings and CLI for mesh configs
  • Add base_mesh and base_origin to SimulationSettings
  • Implement YAML parsing and path resolution in run command
  • Adapt CLI validation and merging logic to MeshConfig list
src/fleetmaster/core/settings.py
src/fleetmaster/commands/run.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • Engine.py has grown very large and touches multiple responsibilities (mesh I/O, HDF5 database ops, pipeline orchestration); consider splitting it into smaller modules (e.g. mesh transformations, database helpers, pipeline runner) to improve readability and maintainability.
  • MeshConfig.name is optional and derived later from the file path in multiple places; adding a validator to automatically populate name from file.stem when missing would eliminate repetitive derivation logic throughout the pipeline.
  • The fitting tests depend on a pre-built HDF5 file in examples/boxship.hdf5, which makes CI runs brittle; consider marking these as integration tests or providing a lightweight fixture to generate a minimal HDF5 in-memory for unit testing.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Engine.py has grown very large and touches multiple responsibilities (mesh I/O, HDF5 database ops, pipeline orchestration); consider splitting it into smaller modules (e.g. mesh transformations, database helpers, pipeline runner) to improve readability and maintainability.
- MeshConfig.name is optional and derived later from the file path in multiple places; adding a validator to automatically populate `name` from `file.stem` when missing would eliminate repetitive derivation logic throughout the pipeline.
- The fitting tests depend on a pre-built HDF5 file in `examples/boxship.hdf5`, which makes CI runs brittle; consider marking these as integration tests or providing a lightweight fixture to generate a minimal HDF5 in-memory for unit testing.

## Individual Comments

### Comment 1
<location> `src/fleetmaster/core/engine.py:128` </location>
<code_context>
-    translation_y: float = 0.0,
-    translation_z: float = 0.0,
-) -> trimesh.Trimesh:
+def _prepare_trimesh_geometry(stl_file: str, mesh_config: MeshConfig | None = None) -> trimesh.Trimesh:
     """
-    Loads an STL file and applies specified translations.
</code_context>

<issue_to_address>
**suggestion:** Consider handling non-existent STL file paths more robustly.

Catching the exception and raising a clearer error will improve usability and debugging, especially since this function is central to mesh operations.

Suggested implementation:

```python
def _prepare_trimesh_geometry(stl_file: str, mesh_config: MeshConfig | None = None) -> trimesh.Trimesh:
    """
    Loads an STL file and applies the specified translation and rotation.

    The rotation (roll, pitch, yaw) is performed around the center of gravity (cog)
    if specified in the mesh_config. If no cog is specified, the mesh's geometric
    center of mass is used as the rotation point. If no configuration is given,
    the untransformed loaded mesh is returned.

    Returns:
        A trimesh.Trimesh object representing the transformed geometry.

    Raises:
        ValueError: If the STL file does not exist or cannot be loaded.
    """
    try:
        mesh = trimesh.load(stl_file)
    except (FileNotFoundError, OSError) as e:
        raise ValueError(f"STL file '{stl_file}' does not exist or cannot be loaded: {e}")
    except Exception as e:
        raise ValueError(f"Failed to load STL file '{stl_file}': {e}")

```

You may need to update the rest of the function to use the loaded `mesh` variable as before. If the mesh loading logic is elsewhere, move it inside this try-except block.
</issue_to_address>

### Comment 2
<location> `src/fleetmaster/core/engine.py:263-267` </location>
<code_context>
+    boat.keep_immersed_part(free_surface=water_level)
+
+    # Check for empty mesh after keep_immersed_part
+    if boat.mesh.vertices.size == 0 or boat.mesh.faces.size == 0:
+        logger.warning("Resulting mesh is empty after keep_immersed_part. Check if water_level is above the mesh.")
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Warn about empty mesh after keep_immersed_part, but consider raising an error.

Downstream code may still operate on an empty mesh. To avoid invalid processing, consider raising an exception or returning None instead of just logging a warning.

```suggestion
    boat.keep_immersed_part(free_surface=water_level)

    # Check for empty mesh after keep_immersed_part
    if boat.mesh.vertices.size == 0 or boat.mesh.faces.size == 0:
        logger.error("Resulting mesh is empty after keep_immersed_part. Check if water_level is above the mesh.")
        raise ValueError("Boat mesh is empty after keep_immersed_part. Aborting further processing.")
```
</issue_to_address>

### Comment 3
<location> `src/fleetmaster/core/engine.py:587-596` </location>
<code_context>
+def _process_and_save_single_case(
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Consider handling exceptions during make_database and file writing.

Wrap make_database and database.to_netcdf in try/except blocks to ensure errors are logged and resources are cleaned up, preventing inconsistent file states.

Suggested implementation:

```python
import logging

def _process_and_save_single_case(
    boat: Any,  # cpt.FloatingBody is not fully typed, use Any to satisfy mypy
    mesh_name: str,
    case_params: dict[str, Any],
    output_file: Path,
    origin_translation: npt.NDArray[np.float64] | None,
) -> Any:
    """Process a single simulation case and save its results to the HDF5 file."""
    logger = logging.getLogger(__name__)
    group_name = _generate_case_group_name(
        mesh_name, case_params["water_depth"], case_params["water_level"], case_params["forward_speed"]
    )

```

```python
    group_name = _generate_case_group_name(
        mesh_name, case_params["water_depth"], case_params["water_level"], case_params["forward_speed"]
    )

    try:
        database = make_database(
            boat=boat,
            mesh_name=mesh_name,
            case_params=case_params,
            origin_translation=origin_translation,
        )
    except Exception as e:
        logger.error(f"Error during make_database for case {group_name}: {e}", exc_info=True)
        return None

    try:
        database.to_netcdf(output_file)
    except Exception as e:
        logger.error(f"Error writing NetCDF file for case {group_name} to {output_file}: {e}", exc_info=True)
        # Attempt to clean up any partially written file
        if output_file.exists():
            try:
                output_file.unlink()
                logger.info(f"Removed incomplete output file: {output_file}")
            except Exception as cleanup_error:
                logger.error(f"Failed to remove incomplete output file {output_file}: {cleanup_error}", exc_info=True)
        return None

    return database

```
</issue_to_address>

### Comment 4
<location> `src/fleetmaster/core/settings.py:39-42` </location>
<code_context>
+        default=None, description="Mesh-specific wave directions in degrees. Overrides global settings."
+    )
+
+    @field_validator("translation", "rotation")
+    def check_vector_length(cls, v: list[float]) -> list[float]:
+        if len(v) != 3:
+            msg = "Translation and rotation must a of length 3"
+            raise InvalidVectorLength(msg)
+        return v
+
+    @field_validator("cog")
</code_context>

<issue_to_address>
**suggestion:** Raise a more informative error for invalid vector length.

Consider including the provided value and its length in the error message to make debugging easier.

```suggestion
        if len(v) != 3:
            msg = (
                f"Translation and rotation must be a list of length 3, "
                f"but got value {v} with length {len(v)}"
            )
            raise InvalidVectorLength(msg)
        return v
```
</issue_to_address>

### Comment 5
<location> `src/fleetmaster/core/settings.py:86-90` </location>
<code_context>
         default=False, description="Combine all calculated cases for a single STL into one multi-dimensional dataset."
     )

+    @field_validator("stl_files", mode="before")
+    def normalize_stl_files(cls, v: Any) -> Any:
+        if not isinstance(v, list):
+            return v
+        return [MeshConfig(file=item) if isinstance(item, str) else item for item in v]
+
     # field validator checks the value of one specific field inmediately
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Consider validating that all items in stl_files are unique by file name.

Duplicate file names may cause issues during mesh processing and when storing data in the database.

```suggestion
    @field_validator("stl_files", mode="before")
    def normalize_stl_files(cls, v: Any) -> Any:
        if not isinstance(v, list):
            return v
        normalized = [MeshConfig(file=item) if isinstance(item, str) else item for item in v]
        file_names = [mesh.file if hasattr(mesh, "file") else None for mesh in normalized]
        duplicates = {name for name in file_names if file_names.count(name) > 1 and name is not None}
        if duplicates:
            raise ValueError(f"Duplicate STL file names found: {', '.join(duplicates)}")
        return normalized
```
</issue_to_address>

### Comment 6
<location> `src/fleetmaster/core/io.py:29-35` </location>
<code_context>
                 continue
             raw = group["stl_content"][()]
             try:
                 mesh = trimesh.load_mesh(io.BytesIO(raw.tobytes()), file_type="stl")
                 if isinstance(mesh, trimesh.Trimesh):
+                    mesh.metadata["name"] = name  # Store the name for later identification
+                    # Load attributes if they exist
+                    mesh.metadata["translation"] = group.attrs.get("translation")
+                    mesh.metadata["rotation"] = group.attrs.get("rotation")
+                    mesh.metadata["cog"] = group.attrs.get("cog")
                     meshes.append(mesh)
             except Exception:
</code_context>

<issue_to_address>
**suggestion:** Consider validating loaded mesh before appending.

Skip appending meshes that are not valid trimesh.Trimesh instances or are empty to avoid issues downstream.

```suggestion
                if isinstance(mesh, trimesh.Trimesh) and mesh.is_volume and mesh.vertices.shape[0] > 0 and mesh.faces.shape[0] > 0:
                    mesh.metadata["name"] = name  # Store the name for later identification
                    # Load attributes if they exist
                    mesh.metadata["translation"] = group.attrs.get("translation")
                    mesh.metadata["rotation"] = group.attrs.get("rotation")
                    mesh.metadata["cog"] = group.attrs.get("cog")
                    meshes.append(mesh)
                else:
                    logger.debug("Mesh %r is not a valid or non-empty Trimesh. Skipping.", name)
```
</issue_to_address>

### Comment 7
<location> `src/fleetmaster/commands/run.py:178` </location>
<code_context>
+                config[key] = parsed_range
+
+
+def _resolve_paths_in_config(config: dict[str, Any], settings_dir: Path) -> None:
+    """Resolves relative paths for 'base_mesh' and 'stl_files' in the config."""
+    # Resolve base_mesh path
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring repeated path resolution logic into a single helper function for clarity and maintainability.

Here’s a small refactoring that centralizes the “resolve one file‐entry” logic and then simply maps over your list.  This removes all the repeated `isinstance`/branching in `_resolve_paths_in_config` and keeps the exact same behavior:

```python
from pathlib import Path
from typing import Any, Union
from dataclasses import replace

FileEntry = Union[str, dict[str, Any], MeshConfig]

def _resolve_entry(item: FileEntry, settings_dir: Path) -> FileEntry:
    # pull out the raw path
    if isinstance(item, str):
        raw = item
    elif isinstance(item, dict):
        raw = item["file"]
    else:  # MeshConfig
        raw = item.file

    # make absolute
    resolved = str((settings_dir / raw).resolve()) if not Path(raw).is_absolute() else raw

    # re-package into same type
    if isinstance(item, str):
        return resolved
    if isinstance(item, dict):
        return {**item, "file": resolved}
    # MeshConfig is a frozen dataclass? use replace
    return replace(item, file=resolved)

def _resolve_paths_in_config(config: dict[str, Any], settings_dir: Path) -> None:
    if base := config.get("base_mesh"):
        # resolve just like above
        config["base_mesh"] = str((settings_dir / base).resolve()) \
                             if not Path(base).is_absolute() else base

    files = config.get("stl_files")
    if not files:
        return

    config["stl_files"] = [
        _resolve_entry(item, settings_dir)
        for item in files
    ]
```

• You now have one focused helper (`_resolve_entry`) instead of three separate branches.  
• The outer function becomes a single list‐comprehension.  
• All functionality (strings, dicts, MeshConfig) remains exactly the same.
</issue_to_address>

### Comment 8
<location> `src/fleetmaster/core/engine.py:689` </location>
<code_context>
def process_all_cases_for_one_stl(
    engine_mesh: EngineMesh,
    wave_frequencies: list | npt.NDArray[np.float64],
    wave_directions: list | npt.NDArray[np.float64],
    water_depths: list | npt.NDArray[np.float64],
    water_levels: list | npt.NDArray[np.float64],
    forwards_speeds: list | npt.NDArray[np.float64],
    lid: bool,
    grid_symmetry: bool,
    output_file: Path,
    update_cases: bool = False,
    combine_cases: bool = False,
    origin_translation: npt.NDArray[np.float64] | None = None,
) -> None:
    # 1. Use the prepared (and possibly translated) geometry to create the Capytaine body
    boat, final_mesh = _prepare_capytaine_body(
        engine_mesh=engine_mesh,
        lid=lid,
        grid_symmetry=grid_symmetry,
    )

    # 2. Add the final, immersed mesh geometry to the database. This version is now the translated one.
    if final_mesh is not None:
        add_mesh_to_database(
            output_file, final_mesh, engine_mesh.name, overwrite=update_cases, mesh_config=engine_mesh.config
        )

    all_datasets = []

    for water_level, water_depth, forward_speed in product(water_levels, water_depths, forwards_speeds):
        case_params = {
            "omegas": wave_frequencies,
            "wave_directions": wave_directions,
            "water_level": water_level,
            "water_depth": water_depth,
            "forward_speed": forward_speed,
            "update_cases": update_cases,
            "combine_cases": combine_cases,
        }
        result_db = _process_and_save_single_case(boat, engine_mesh.name, case_params, output_file, origin_translation)
        if combine_cases and result_db is not None:
            all_datasets.append(result_db)

    if combine_cases:
        if all_datasets:
            logger.info("Combining all calculated cases into a single multi-dimensional dataset.")
            combined_dataset = xr.combine_by_coords(all_datasets, combine_attrs="drop_conflicts")
            combined_group_name = f"{engine_mesh.name}_multi_dim"

            logger.info(f"Writing combined dataset to group '{combined_group_name}' in HDF5 file: {output_file}")
            with h5py.File(output_file, "a") as f:
                if combined_group_name in f:
                    del f[combined_group_name]
            combined_dataset.to_netcdf(output_file, mode="a", group=combined_group_name, engine="h5netcdf")
            with h5py.File(output_file, "a") as f:
                f[combined_group_name].attrs["stl_mesh_name"] = engine_mesh.name
        else:
            logger.warning(
                "The 'combine_cases' option is enabled, but no datasets were generated to combine. "
                "This can happen if all cases were already present in the output file and 'update_cases' was false."
            )

    logger.debug(f"Successfully wrote all data for mesh '{engine_mesh.name}' to HDF5.")

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Swap positions of nested conditionals ([`swap-nested-ifs`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/swap-nested-ifs/))
- Hoist nested repeated code outside conditional statements ([`hoist-similar-statement-from-if`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/hoist-similar-statement-from-if/))
- Extract code out into function ([`extract-method`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/extract-method/))
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

translation_y: float = 0.0,
translation_z: float = 0.0,
) -> trimesh.Trimesh:
def _prepare_trimesh_geometry(stl_file: str, mesh_config: MeshConfig | None = None) -> trimesh.Trimesh:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider handling non-existent STL file paths more robustly.

Catching the exception and raising a clearer error will improve usability and debugging, especially since this function is central to mesh operations.

Suggested implementation:

def _prepare_trimesh_geometry(stl_file: str, mesh_config: MeshConfig | None = None) -> trimesh.Trimesh:
    """
    Loads an STL file and applies the specified translation and rotation.

    The rotation (roll, pitch, yaw) is performed around the center of gravity (cog)
    if specified in the mesh_config. If no cog is specified, the mesh's geometric
    center of mass is used as the rotation point. If no configuration is given,
    the untransformed loaded mesh is returned.

    Returns:
        A trimesh.Trimesh object representing the transformed geometry.

    Raises:
        ValueError: If the STL file does not exist or cannot be loaded.
    """
    try:
        mesh = trimesh.load(stl_file)
    except (FileNotFoundError, OSError) as e:
        raise ValueError(f"STL file '{stl_file}' does not exist or cannot be loaded: {e}")
    except Exception as e:
        raise ValueError(f"Failed to load STL file '{stl_file}': {e}")

You may need to update the rest of the function to use the loaded mesh variable as before. If the mesh loading logic is elsewhere, move it inside this try-except block.

Comment on lines 263 to 267
boat.keep_immersed_part(free_surface=water_level)

# Check for empty mesh after keep_immersed_part
if boat.mesh.vertices.size == 0 or boat.mesh.faces.size == 0:
logger.warning("Resulting mesh is empty after keep_immersed_part. Check if water_level is above the mesh.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Warn about empty mesh after keep_immersed_part, but consider raising an error.

Downstream code may still operate on an empty mesh. To avoid invalid processing, consider raising an exception or returning None instead of just logging a warning.

Suggested change
boat.keep_immersed_part(free_surface=water_level)
# Check for empty mesh after keep_immersed_part
if boat.mesh.vertices.size == 0 or boat.mesh.faces.size == 0:
logger.warning("Resulting mesh is empty after keep_immersed_part. Check if water_level is above the mesh.")
boat.keep_immersed_part(free_surface=water_level)
# Check for empty mesh after keep_immersed_part
if boat.mesh.vertices.size == 0 or boat.mesh.faces.size == 0:
logger.error("Resulting mesh is empty after keep_immersed_part. Check if water_level is above the mesh.")
raise ValueError("Boat mesh is empty after keep_immersed_part. Aborting further processing.")

Comment on lines +39 to +42
if len(v) != 3:
msg = "Translation and rotation must a of length 3"
raise InvalidVectorLength(msg)
return v
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Raise a more informative error for invalid vector length.

Consider including the provided value and its length in the error message to make debugging easier.

Suggested change
if len(v) != 3:
msg = "Translation and rotation must a of length 3"
raise InvalidVectorLength(msg)
return v
if len(v) != 3:
msg = (
f"Translation and rotation must be a list of length 3, "
f"but got value {v} with length {len(v)}"
)
raise InvalidVectorLength(msg)
return v

Comment on lines 29 to 35
if isinstance(mesh, trimesh.Trimesh):
mesh.metadata["name"] = name # Store the name for later identification
# Load attributes if they exist
mesh.metadata["translation"] = group.attrs.get("translation")
mesh.metadata["rotation"] = group.attrs.get("rotation")
mesh.metadata["cog"] = group.attrs.get("cog")
meshes.append(mesh)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider validating loaded mesh before appending.

Skip appending meshes that are not valid trimesh.Trimesh instances or are empty to avoid issues downstream.

Suggested change
if isinstance(mesh, trimesh.Trimesh):
mesh.metadata["name"] = name # Store the name for later identification
# Load attributes if they exist
mesh.metadata["translation"] = group.attrs.get("translation")
mesh.metadata["rotation"] = group.attrs.get("rotation")
mesh.metadata["cog"] = group.attrs.get("cog")
meshes.append(mesh)
if isinstance(mesh, trimesh.Trimesh) and mesh.is_volume and mesh.vertices.shape[0] > 0 and mesh.faces.shape[0] > 0:
mesh.metadata["name"] = name # Store the name for later identification
# Load attributes if they exist
mesh.metadata["translation"] = group.attrs.get("translation")
mesh.metadata["rotation"] = group.attrs.get("rotation")
mesh.metadata["cog"] = group.attrs.get("cog")
meshes.append(mesh)
else:
logger.debug("Mesh %r is not a valid or non-empty Trimesh. Skipping.", name)

@eelcovv eelcovv merged commit 2538aa1 into main Oct 28, 2025
7 checks passed
@eelcovv eelcovv deleted the sourcery branch October 28, 2025 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant