diff --git a/.idea/inspectionProfiles/Ptera_Software_Default.xml b/.idea/inspectionProfiles/Ptera_Software_Default.xml
index 9b0399857..afb52adc4 100644
--- a/.idea/inspectionProfiles/Ptera_Software_Default.xml
+++ b/.idea/inspectionProfiles/Ptera_Software_Default.xml
@@ -27,6 +27,7 @@
+
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 9e2c057b1..0376e670c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -76,15 +76,26 @@ Requires Python 3.11, but active development is done in 3.13
- `wing.py`: Wing class with symmetry processing
- `wing_cross_section.py`: WingCrossSection class with validation
- `movements/`: Package with movement classes (definitions for time-dependent motion)
- - `_functions.py`: Movement utility functions
- - `airplane_movement.py`: Airplane motion definitions
- - `movement.py`: Core Movement class
- - `operating_point_movement.py`: Operating condition changes
- - `wing_cross_section_movement.py`: Wing cross section motion
- - `wing_movement.py`: Wing flapping motion
+ - `aeroelastic_airplane_movement.py`: AeroelasticAirplaneMovement skeleton
+ - `aeroelastic_movement.py`: AeroelasticMovement skeleton
+ - `aeroelastic_operating_point_movement.py`: AeroelasticOperatingPointMovement skeleton
+ - `aeroelastic_wing_cross_section_movement.py`: AeroelasticWingCrossSectionMovement skeleton
+ - `aeroelastic_wing_movement.py`: AeroelasticWingMovement skeleton
+ - `airplane_movement.py`: AirplaneMovement class
+ - `free_flight_airplane_movement.py`: FreeFlightAirplaneMovement skeleton
+ - `free_flight_movement.py`: FreeFlightMovement skeleton
+ - `free_flight_operating_point_movement.py`: FreeFlightOperatingPointMovement skeleton
+ - `free_flight_wing_cross_section_movement.py`: FreeFlightWingCrossSectionMovement skeleton
+ - `free_flight_wing_movement.py`: FreeFlightWingMovement skeleton
+ - `movement.py`: Movement class
+ - `operating_point_movement.py`: OperatingPointMovement class
+ - `wing_cross_section_movement.py`: WingCrossSectionMovement class
+ - `wing_movement.py`: WingMovement class
- `_aerodynamics_functions.py`: Induced velocity functions
+ - `_core.py`: Core classes for the movement and problem hierarchies
- `_functions.py`: Shared utility functions
- `_logging.py`: Contains function for setting up logging
+ - `_oscillation.py`: Oscillation functions for movement classes
- `_panel.py`: Panel class for discretized mesh elements
- `_parameter_validation.py`: Input validation functions
- `_serialization.py`: JSON serialization and deserialization (save/load)
@@ -112,40 +123,62 @@ Requires Python 3.11, but active development is done in 3.13
- `test_serialization_output.py`
- `test_steady_convergence.py`
- `test_steady_horseshoe_vortex_lattice_method.py`
+ - `test_steady_horseshoe_vortex_lattice_method_surface_effect.py`
- `test_steady_ring_vortex_lattice_method.py`
+ - `test_steady_ring_vortex_lattice_method_surface_effect.py`
- `test_steady_trim.py`
- `test_unsteady_convergence.py`
- `test_unsteady_ring_vortex_lattice_method_multiple_wing_static_geometry.py`
- `test_unsteady_ring_vortex_lattice_method_multiple_wing_variable_geometry.py`
- `test_unsteady_ring_vortex_lattice_method_static_geometry.py`
+ - `test_unsteady_ring_vortex_lattice_method_surface_effect.py`
- `test_unsteady_ring_vortex_lattice_method_variable_geometry.py`
- `test_unsteady_ring_vortex_lattice_method_wake_truncation.py`
- `unit/`: Unit tests for individual classes and functions
- `fixtures/`: Fixtures for unit tests
- - `aerodynamics_function_fixtures.py`
+ - `aerodynamics_functions_fixtures.py`
- `airplane_movement_fixtures.py`
+ - `core_airplane_movement_fixtures.py`
+ - `core_movement_fixtures.py`
+ - `core_operating_point_movement_fixtures.py`
+ - `core_wing_cross_section_movement_fixtures.py`
+ - `core_wing_movement_fixtures.py`
- `geometry_fixtures.py`
- - `horseshoe_fixtures.py`
+ - `horseshoe_vortex_fixtures.py`
+ - `line_vortex_fixtures.py`
- `movement_fixtures.py`
- - `movement_function_fixtures.py`
- `operating_point_fixtures.py`
- `operating_point_movement_fixtures.py`
+ - `oscillation_fixtures.py`
+ - `panel_fixtures.py`
+ - `parameter_validation_fixtures.py`
- `problem_fixtures.py`
- `ring_vortex_fixtures.py`
- `serialization_fixtures.py`
+ - `solver_fixtures.py`
- `wing_cross_section_movement_fixtures.py`
- `wing_movement_fixtures.py`
- `test_aerodynamics_functions.py`
- `test_airfoil.py`
- `test_airplane.py`
- `test_airplane_movement.py`
+ - `test_core.py`
+ - `test_core_airplane_movement.py`
+ - `test_core_movement.py`
+ - `test_core_operating_point_movement.py`
+ - `test_core_unsteady_problem.py`
+ - `test_core_wing_cross_section_movement.py`
+ - `test_core_wing_movement.py`
- `test_horseshoe_vortex.py`
+ - `test_line_vortex.py`
+ - `test_logging.py`
- `test_movement.py`
- - `test_movement_functions.py`
- `test_operating_point.py`
- `test_operating_point_movement.py`
+ - `test_oscillation.py`
- `test_package_init.py`
- `test_panel.py`
+ - `test_parameter_validation.py`
- `test_problems.py`
- `test_ring_vortex.py`
- `test_serialization.py`
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 022b3b6dc..5cac91036 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,7 +6,7 @@ We are excited that you are interested in contributing to **Ptera Software**! Th
## Before Contributing
-Please review the following documents before making contributions. These documents are also available on the [Ptera Software documentation website](https://pterasoftware.readthedocs.io/).
+Please review the following documents before making contributions. These documents are also available on the [Ptera Software documentation website](https://docs.pterasoftware.com/).
1. [README](README.md)
2. [Code of Conduct](CODE_OF_CONDUCT.md)
diff --git a/README.md b/README.md
index 7f0daabc4..106efe2f4 100644
--- a/README.md
+++ b/README.md
@@ -120,13 +120,13 @@ If you haven't already, install Ptera Software from PyPI (see [Quick Start](#qui
pip install pterasoftware
```
-Your IDE should automatically provide docstring hints for the available classes and functions. For more detailed documentation, visit the [Ptera Software documentation site](https://pterasoftware.readthedocs.io/).
+Your IDE should automatically provide docstring hints for the available classes and functions. For more detailed documentation, visit the [Ptera Software documentation site](https://docs.pterasoftware.com/).
### From Source
If you want to browse the example scripts or dig into the source code, you will need a local copy of the repository. Follow the environment setup instructions in the [Contributing Guidelines](CONTRIBUTING.md#contributing-code) to clone the repository, create a virtual environment, and install dependencies.
-Once set up, the `examples/` directory contains scripts that demonstrate the full range of Ptera Software's features and solvers. These scripts are also available on the [documentation site](https://pterasoftware.readthedocs.io/en/latest/examples.html).
+Once set up, the `examples/` directory contains scripts that demonstrate the full range of Ptera Software's features and solvers. These scripts are also available on the [documentation site](https://docs.pterasoftware.com/en/latest/examples.html).
## Example Output
@@ -150,7 +150,7 @@ Since the release of version 1.0.0, Ptera Software is now validated against expe
## Documentation
-For detailed API documentation and guides, visit the [Ptera Software documentation site](https://pterasoftware.readthedocs.io/).
+For detailed API documentation and guides, visit the [Ptera Software documentation site](https://docs.pterasoftware.com/).
## How to Contribute
diff --git a/docs/CLASSES_AND_IMMUTABILITY.md b/docs/CLASSES_AND_IMMUTABILITY.md
index 64c907e16..090d0de13 100644
--- a/docs/CLASSES_AND_IMMUTABILITY.md
+++ b/docs/CLASSES_AND_IMMUTABILITY.md
@@ -2,12 +2,12 @@
This document describes the consistent pattern of immutability and lazy caching across the following core data and geometry classes in the Ptera Software codebase:
-- `UnsteadyProblem`
-- `Movement`
-- `AirplaneMovement`
-- `WingMovement`
-- `WingCrossSectionMovement`
-- `OperatingPointMovement`
+- `CoreUnsteadyProblem` / `UnsteadyProblem`
+- `CoreMovement` / `Movement`
+- `CoreAirplaneMovement` / `AirplaneMovement`
+- `CoreWingMovement` / `WingMovement`
+- `CoreWingCrossSectionMovement` / `WingCrossSectionMovement`
+- `CoreOperatingPointMovement` / `OperatingPointMovement`
- `SteadyProblem`
- `OperatingPoint`
- `Airplane`
@@ -19,6 +19,8 @@ This document describes the consistent pattern of immutability and lazy caching
- `HorseshoeVortex`
- `LineVortex`
+The `Core*` classes live in `pterasoftware/_core.py` and own the shared slots and properties. The public classes extend their core parents, sometimes adding additional slots and sometimes inheriting everything with an empty `__slots__`. See each class section below for details on which attributes are defined at which level.
+
## Design Principles
### Class Attribute Categories
@@ -85,22 +87,24 @@ Store collections as tuples internally to prevent external mutation via `.append
---
-## UnsteadyProblem Class (`problems.py`)
+## CoreUnsteadyProblem / UnsteadyProblem Class (`_core.py`, `problems.py`)
+
+`UnsteadyProblem` extends `CoreUnsteadyProblem`. `CoreUnsteadyProblem` owns all attributes except `movement` and `steady_problems`, which are defined on `UnsteadyProblem`.
### Attribute Classification
#### Immutable (set in `__init__`, never modified)
-| Attribute | Type | Notes |
-|------------------------|-----------------------------|-----------------------|
-| `movement` | `Movement` | Movement definition |
-| `only_final_results` | `bool` | Results flag |
-| `num_steps` | `int` | Copied from Movement |
-| `delta_time` | `float` | Copied from Movement |
-| `first_averaging_step` | `int` | Computed during init |
-| `first_results_step` | `int` | Computed during init |
-| `max_wake_rows` | `int \| None` | Copied from Movement |
-| `steady_problems` | `tuple[SteadyProblem, ...]` | Generated during init |
+| Attribute | Type | Defined On | Notes |
+|------------------------|-----------------------------|-------------------------|-----------------------|
+| `movement` | `Movement` | `UnsteadyProblem` | Movement definition |
+| `only_final_results` | `bool` | `CoreUnsteadyProblem` | Results flag |
+| `num_steps` | `int` | `CoreUnsteadyProblem` | Copied from Movement |
+| `delta_time` | `float` | `CoreUnsteadyProblem` | Copied from Movement |
+| `first_averaging_step` | `int` | `CoreUnsteadyProblem` | Computed during init |
+| `first_results_step` | `int` | `CoreUnsteadyProblem` | Computed during init |
+| `max_wake_rows` | `int \| None` | `CoreUnsteadyProblem` | Copied from Movement |
+| `steady_problems` | `tuple[SteadyProblem, ...]` | `UnsteadyProblem` | Generated during init |
#### Mutable (populated by solver)
@@ -119,40 +123,44 @@ Store collections as tuples internally to prevent external mutation via `.append
| `finalRmsMoments_W_CgP1` | `list[np.ndarray]` | RMS moments |
| `finalRmsMomentCoefficients_W_CgP1` | `list[np.ndarray]` | RMS moment coefficients |
-**Note**: The solver result lists must remain mutable as they are populated after initialization by the solver. These are initialized as empty lists and appended to during the solve.
+**Note**: The mutable solver result lists are defined on `CoreUnsteadyProblem` and must remain mutable as they are populated after initialization by the solver. These are initialized as empty lists and appended to during the solve.
-## Movement Class (`movements/movement.py`)
+## CoreMovement / Movement Class (`_core.py`, `movements/movement.py`)
+
+`Movement` extends `CoreMovement`. `CoreMovement` owns the shared slots (`airplane_movements`, `operating_point_movement`, `delta_time`, `num_steps`, `max_wake_rows`) and derived properties (`lcm_period`, `max_period`, `min_period`, `static`). `Movement` adds cycle/chord counting, wake sizing parameters, and batch pre-generation of `Airplane`s and `OperatingPoint`s.
### Attribute Classification
#### Immutable (set in `__init__`, never modified)
-| Attribute | Type | Notes |
-|----------------------------|------------------------------------|---------------------------|
-| `airplane_movements` | `tuple[AirplaneMovement, ...]` | Tuple prevents mutation |
-| `operating_point_movement` | `OperatingPointMovement` | Operating point changes |
-| `delta_time` | `float` | Time step |
-| `num_cycles` | `int \| None` | Number of cycles |
-| `num_chords` | `int \| None` | Number of chord lengths |
-| `num_steps` | `int` | Total time steps |
-| `max_wake_rows` | `int \| None` | Max wake rows per Wing |
-| `max_wake_chords` | `int \| None` | Max wake in chord lengths |
-| `max_wake_cycles` | `int \| None` | Max wake in motion cycles |
-| `airplanes` | `tuple[tuple[Airplane, ...], ...]` | Generated Airplanes |
-| `operating_points` | `tuple[OperatingPoint, ...]` | Generated OperatingPoints |
+| Attribute | Type | Defined On | Notes |
+|----------------------------|------------------------------------|----------------|---------------------------|
+| `airplane_movements` | `tuple[AirplaneMovement, ...]` | `CoreMovement` | Tuple prevents mutation |
+| `operating_point_movement` | `OperatingPointMovement` | `CoreMovement` | Operating point changes |
+| `delta_time` | `float` | `CoreMovement` | Time step |
+| `num_steps` | `int` | `CoreMovement` | Total time steps |
+| `max_wake_rows` | `int \| None` | `CoreMovement` | Max wake rows per Wing |
+| `num_cycles` | `int \| None` | `Movement` | Number of cycles |
+| `num_chords` | `int \| None` | `Movement` | Number of chord lengths |
+| `max_wake_chords` | `int \| None` | `Movement` | Max wake in chord lengths |
+| `max_wake_cycles` | `int \| None` | `Movement` | Max wake in motion cycles |
+| `airplanes` | `tuple[tuple[Airplane, ...], ...]` | `Movement` | Generated Airplanes |
+| `operating_points` | `tuple[OperatingPoint, ...]` | `Movement` | Generated OperatingPoints |
#### Derived from Immutable (use manual lazy caching)
-| Property | Depends On | Notes |
-|--------------|--------------------------------------------------|--------|
-| `lcm_period` | `airplane_movements`, `operating_point_movement` | Cached |
-| `max_period` | `airplane_movements`, `operating_point_movement` | Cached |
-| `min_period` | `airplane_movements`, `operating_point_movement` | Cached |
-| `static` | `max_period` | Cached |
+| Property | Depends On | Defined On | Notes |
+|--------------|--------------------------------------------------|----------------|--------|
+| `lcm_period` | `airplane_movements`, `operating_point_movement` | `CoreMovement` | Cached |
+| `max_period` | `airplane_movements`, `operating_point_movement` | `CoreMovement` | Cached |
+| `min_period` | `airplane_movements`, `operating_point_movement` | `CoreMovement` | Cached |
+| `static` | `max_period` | `CoreMovement` | Cached |
+
+**Note on `airplanes` and `operating_points`**: These are defined on `Movement` and generated during `__init__` by calling the child movements' `generate_*` methods. They are stored as nested tuples to prevent modification after generation.
-**Note on `airplanes` and `operating_points`**: These are generated during `__init__` by calling the child movements' `generate_*` methods. Are stored as nested tuples to prevent modification after generation.
+## CoreAirplaneMovement / AirplaneMovement Class (`_core.py`, `movements/airplane_movement.py`)
-## AirplaneMovement Class (`movements/airplane_movement.py`)
+`AirplaneMovement` extends `CoreAirplaneMovement`. All slots are defined on `CoreAirplaneMovement`; `AirplaneMovement` has empty `__slots__` and only narrows the `wing_movements` type to require `WingMovement` children.
### Attribute Classification
@@ -174,7 +182,9 @@ Store collections as tuples internally to prevent external mutation via `.append
| `all_periods` | Own periods + child `all_periods` | Tuple of unique non zero periods (cached) |
| `max_period` | Own periods + child `max_period` | Scalar float, longest period (cached) |
-## WingMovement Class (`movements/wing_movement.py`)
+## CoreWingMovement / WingMovement Class (`_core.py`, `movements/wing_movement.py`)
+
+`WingMovement` extends `CoreWingMovement`. All slots are defined on `CoreWingMovement`; `WingMovement` has empty `__slots__` and only narrows the `wing_cross_section_movements` type to require `WingCrossSectionMovement` children.
### Attribute Classification
@@ -201,7 +211,9 @@ Store collections as tuples internally to prevent external mutation via `.append
| `all_periods` | Own periods + child `all_periods` | Tuple of unique non zero periods (cached) |
| `max_period` | Own periods + child `max_period` | Scalar float, longest period (cached) |
-## WingCrossSectionMovement Class (`movements/wing_cross_section_movement.py`)
+## CoreWingCrossSectionMovement / WingCrossSectionMovement Class (`_core.py`, `movements/wing_cross_section_movement.py`)
+
+`WingCrossSectionMovement` extends `CoreWingCrossSectionMovement`. All slots are defined on `CoreWingCrossSectionMovement`; `WingCrossSectionMovement` has empty `__slots__`.
### Attribute Classification
@@ -226,7 +238,9 @@ Store collections as tuples internally to prevent external mutation via `.append
| `all_periods` | Period arrays | Tuple of unique non zero periods (cached) |
| `max_period` | Period arrays | Scalar float, longest period (cached) |
-## OperatingPointMovement Class (`movements/operating_point_movement.py`)
+## CoreOperatingPointMovement / OperatingPointMovement Class (`_core.py`, `movements/operating_point_movement.py`)
+
+`OperatingPointMovement` extends `CoreOperatingPointMovement`. All slots are defined on `CoreOperatingPointMovement`; `OperatingPointMovement` has empty `__slots__`.
### Attribute Classification
diff --git a/docs/RUNNING_TESTS_AND_TYPE_CHECKS.md b/docs/RUNNING_TESTS_AND_TYPE_CHECKS.md
index 4d3a69f3c..f0cea402e 100644
--- a/docs/RUNNING_TESTS_AND_TYPE_CHECKS.md
+++ b/docs/RUNNING_TESTS_AND_TYPE_CHECKS.md
@@ -23,7 +23,7 @@ To run a specific test module in `tests\unit\`:
For example:
```
-".venv/Scripts/python.exe" -m unittest tests.unit.test_wing_cross_section_movement -v
+".venv/Scripts/python.exe" -m unittest tests.unit.test_core_wing_cross_section_movement -v
```
## Running MyPy Type Checking
diff --git a/docs/TYPE_HINT_AND_DOCSTRING_STYLE.md b/docs/TYPE_HINT_AND_DOCSTRING_STYLE.md
index b049200dd..9c30054c0 100644
--- a/docs/TYPE_HINT_AND_DOCSTRING_STYLE.md
+++ b/docs/TYPE_HINT_AND_DOCSTRING_STYLE.md
@@ -8,6 +8,7 @@ This document defines the conventions for type hints and docstrings in the Ptera
- [Docstring Format](#docstring-format)
- [Module-Level Docstrings](#module-level-docstrings)
- [Class Docstrings](#class-docstrings)
+ - [Public Subclasses of Private Parents](#public-subclasses-of-private-parents)
- [Function and Method Docstrings](#function-and-method-docstrings)
- [Examples](#examples)
@@ -417,6 +418,154 @@ def __init__(
- Only document parameters that are NEW to the subclass
- Call `super().__init__()` with inherited parameters
+### Public Subclasses of Private Parents
+
+When a public class inherits from a private parent (a class in an underscore prefixed module like `_core.py`), the conventions above are inverted. The public child keeps a self-contained docstring that documents inherited methods and parameters as its own. The private parent's class docstring and `__init__` docstring use minimal descriptions that reference the public child. However, public methods and properties on the private parent must have self-contained docstrings because they appear on the public child's RTD page via inheritance (see "Private Parent Method and Property Docstrings" below).
+
+This is because:
+
+1. The ReadTheDocs site is purely public API. Private parents are excluded from the generated documentation, but inherited public methods and properties DO appear on the public child's page.
+2. Users should never need to navigate to a private module to understand the public API.
+3. Contributors reading the private parent's source code can easily navigate to the public child for full documentation of the class and `__init__`.
+
+#### Private Parent Class Docstring Template
+
+```python
+class _CoreClass:
+ """A core class used to contain the shared foundation of PublicClass and its
+ feature variant siblings.
+
+ See PublicClass for full documentation of the shared interface.
+
+
+ """
+```
+
+**Key points:**
+
+- Reference the public child for full documentation of the shared interface
+- Include a brief architectural description for contributors
+- Do not duplicate the full method listing or parameter documentation
+
+#### Private Parent `__init__` Docstring Template
+
+```python
+def __init__(
+ self,
+ param1: Type1,
+ param2: Type2,
+) -> None:
+ """The initialization method.
+
+ See PublicClass's initialization method for full parameter descriptions.
+
+ :param param1: Brief description.
+ :param param2: Brief description.
+ :return: None
+ """
+```
+
+**Key points:**
+
+- Reference the public child's `__init__` docstring for full parameter descriptions
+- Include brief parameter descriptions (enough for contributors to understand the code without navigating away)
+
+#### Public Child Class Docstring Template
+
+```python
+class PublicClass(_core.CoreClass):
+ """A class used to .
+
+ **Contains the following methods:**
+
+ inherited_method_1: Short description.
+
+ inherited_method_2: Short description.
+
+ new_method_1: Short description (if any).
+ """
+```
+
+**Key points:**
+
+- Do not mention the private parent in the short description or the methods listing
+- List all methods (inherited and new) as if they were the child's own
+- The class reads as a standalone public API entry point
+
+#### Public Child `__init__` Docstring Template
+
+```python
+def __init__(
+ self,
+ inherited_param1: Type1,
+ inherited_param2: Type2,
+ new_param: Type3,
+) -> None:
+ """The initialization method.
+
+ :param inherited_param1: Full description.
+ :param inherited_param2: Full description.
+ :param new_param: Full description.
+ :return: None
+ """
+ super().__init__(inherited_param1, inherited_param2)
+ self.new_param = new_param
+```
+
+**Key points:**
+
+- Document all parameters fully (inherited and new)
+- Do not reference the private parent
+
+#### Private Parent Method and Property Docstrings
+
+Public methods and properties defined on a private parent are inherited by all public children and appear on their RTD pages. Their docstrings must therefore be self-contained and written for a public audience, unlike the class and `__init__` docstrings which can defer to the public child.
+
+**Rules:**
+
+1. **No deferral language.** Do not write "see child class for full details" or similar, since the docstring IS the documentation the user sees on the child's page.
+2. **No references to specific sibling types.** A `CoreWingMovement` method docstring must not mention `WingCrossSectionMovement` or `FreeFlightWingCrossSectionMovement`, because the docstring appears on all siblings' RTD pages. Instead, reference the universal geometry class that the movement class manages (e.g., `WingCrossSection`), since geometry classes have no feature subclasses and are always correct.
+3. **Use "each X's movement class" framing** when referring to child movement objects. For example, write "each `WingCrossSection`'s movement class" rather than "its `WingCrossSection`s' movement classes". This avoids implying that a movement class owns geometry objects (movement classes own other movement classes; geometry classes own geometry classes).
+
+**Example (correct):**
+
+```python
+# On CoreWingMovement
+@property
+def wing_cross_section_movements(self) -> tuple:
+ """The movement classes for each of this Wing's WingCrossSections.
+
+ :return: A tuple of movement classes, one per WingCrossSection.
+ """
+```
+
+**Example (incorrect):**
+
+```python
+# References a specific sibling type
+@property
+def wing_cross_section_movements(self) -> tuple:
+ """The WingCrossSectionMovements for this WingMovement.
+ ...
+ """
+
+# Uses deferral language
+def generate_wing_at_time_step(self, ...) -> Wing:
+ """Generates a Wing at a single time step.
+
+ See WingMovement for full details.
+ ...
+ """
+```
+
+**Scope:** This rule applies only to public methods and properties on core classes (those that will be inherited and displayed on RTD). Private helper methods (underscore prefixed) on core classes are internal and can use any convenient wording.
+
+#### Multiple Public Siblings
+
+When multiple public classes share the same private parent (e.g., `Movement`, `FreeFlightMovement`, and `AeroelasticMovement` all extending `CoreMovement`), each sibling maintains its own self-contained docstring. The inherited method descriptions can be tailored to each sibling's context (e.g., "Movement's sub movement objects" vs "FreeFlightMovement's sub movement objects").
+
### Property Docstring Template
```python
diff --git a/docs/website/_autoapi_templates/python/class.rst b/docs/website/_autoapi_templates/python/class.rst
index 8f8a42510..0b0c9c0fc 100644
--- a/docs/website/_autoapi_templates/python/class.rst
+++ b/docs/website/_autoapi_templates/python/class.rst
@@ -23,8 +23,16 @@
{% endfor %}
{% if obj.bases %}
{% if "show-inheritance" in autoapi_options %}
+ {% set public_bases = [] %}
+ {% for base in obj.bases %}
+ {% if "._" not in base %}
+ {% set _ = public_bases.append(base) %}
+ {% endif %}
+ {% endfor %}
+ {% if public_bases %}
- Bases: {% for base in obj.bases %}{{ base|link_objs }}{% if not loop.last %}, {% endif %}{% endfor %}
+ Bases: {% for base in public_bases %}{{ base|link_objs }}{% if not loop.last %}, {% endif %}{% endfor %}
+ {% endif %}
{% endif %}
diff --git a/docs/website/conf.py b/docs/website/conf.py
index c584ba8b4..7d9217e51 100644
--- a/docs/website/conf.py
+++ b/docs/website/conf.py
@@ -212,7 +212,26 @@ def _html_page_context(app, pagename, templatename, context, doctree):
)
+def _rewrite_repo_root_links(app, docname, source):
+ """Rewrite relative links in files included from the repo root.
+
+ Files like CONTRIBUTING.md live at the repo root and use paths like
+ ``docs/CODE_STYLE.md`` which are correct on GitHub. When Sphinx
+ includes them via ``{include}``, those paths are resolved relative to
+ ``docs/website/`` where the wrapper lives, so ``docs/CODE_STYLE.md``
+ cannot be found. This handler replaces the wrapper's ``{include}``
+ directive with the actual file content, rewriting ``docs/*.md`` paths
+ to ``*.md`` so they resolve correctly in the Sphinx build.
+ """
+ contributing_path = REPO_ROOT / "CONTRIBUTING.md"
+ if docname == "CONTRIBUTING" and contributing_path.exists():
+ text = contributing_path.read_text()
+ text = re.sub(r"\(docs/([A-Z_]+\.md)\)", r"(\1)", text)
+ source[0] = text
+
+
def setup(app):
+ app.connect("source-read", _rewrite_repo_root_links)
app.connect("html-page-context", _html_page_context)
# Copy extra assets to the site root after build
@@ -272,11 +291,14 @@ def _copy_extra_assets(app, exception):
"*/ui_resources/*",
"*/airfoils/*",
"*/models/*",
+ "*/movements/free_flight_*",
+ "*/movements/aeroelastic_*",
]
autoapi_options = [
"members",
"show-module-summary",
"show-inheritance",
+ "inherited-members",
]
autoapi_template_dir = "_autoapi_templates"
diff --git a/pterasoftware/_core.py b/pterasoftware/_core.py
new file mode 100644
index 000000000..f10529625
--- /dev/null
+++ b/pterasoftware/_core.py
@@ -0,0 +1,2363 @@
+"""Contains the core classes for the movement and problem hierarchies."""
+
+from __future__ import annotations
+
+import copy
+import math
+from collections.abc import Callable, Sequence
+
+import numpy as np
+
+from . import _oscillation, _parameter_validation, _transformations, geometry
+from . import operating_point as operating_point_mod
+
+
+def lcm(a: float, b: float) -> float:
+ """Calculates the least common multiple of two numbers.
+
+ :param a: First number (period in seconds).
+ :param b: Second number (period in seconds).
+ :return: LCM of a and b. Returns 0.0 if either input is 0.0.
+ """
+ if a == 0.0 or b == 0.0:
+ return 0.0
+ # Convert to integers (periods are typically whole multiples of delta_time).
+ # Use sufficiently large multiplier to preserve precision.
+ multiplier = 1000000
+ a_int = int(round(a * multiplier))
+ b_int = int(round(b * multiplier))
+ lcm_int = abs(a_int * b_int) // math.gcd(a_int, b_int)
+ return lcm_int / multiplier
+
+
+def lcm_multiple(periods: list[float]) -> float:
+ """Calculates the least common multiple of multiple periods.
+
+ :param periods: A list of periods in seconds.
+ :return: LCM of all periods. Returns 0.0 if all periods are 0.0.
+ """
+ if not periods or all(p == 0.0 for p in periods):
+ return 0.0
+ non_zero_periods = [p for p in periods if p != 0.0]
+ if not non_zero_periods:
+ return 0.0
+ result = non_zero_periods[0]
+ for period in non_zero_periods[1:]:
+ result = lcm(result, period)
+ return result
+
+
+class CoreOperatingPointMovement:
+ """A core class used to contain the shared foundation of OperatingPointMovement and
+ its feature variant siblings.
+
+ See OperatingPointMovement for full documentation of the shared interface.
+
+ CoreOperatingPointMovement holds the base OperatingPoint and oscillation parameters,
+ and provides generate_operating_point_at_time_step() for creating OperatingPoints
+ one step at a time.
+ """
+
+ __slots__ = (
+ "_base_operating_point",
+ "_ampVCg__E",
+ "_periodVCg__E",
+ "_spacingVCg__E",
+ "_phaseVCg__E",
+ "_max_period",
+ )
+
+ def __init__(
+ self,
+ base_operating_point: operating_point_mod.OperatingPoint,
+ ampVCg__E: float | int = 0.0,
+ periodVCg__E: float | int = 0.0,
+ spacingVCg__E: str | Callable[[float], float] = "sine",
+ phaseVCg__E: float | int = 0.0,
+ ) -> None:
+ """The initialization method.
+
+ See OperatingPointMovement's initialization method for full parameter
+ descriptions.
+
+ :param base_operating_point: The base OperatingPoint.
+ :param ampVCg__E: The amplitude of vCg__E oscillation in meters per second.
+ :param periodVCg__E: The period of vCg__E oscillation in seconds.
+ :param spacingVCg__E: The spacing type: "sine", "uniform", or a callable.
+ :param phaseVCg__E: The phase offset in degrees.
+ :return: None
+ """
+ # Validate and store immutable attributes.
+ if not isinstance(base_operating_point, operating_point_mod.OperatingPoint):
+ raise TypeError("base_operating_point must be an OperatingPoint")
+ self._base_operating_point = base_operating_point
+
+ self._ampVCg__E = _parameter_validation.number_in_range_return_float(
+ ampVCg__E, "ampVCg__E", min_val=0.0, min_inclusive=True
+ )
+
+ periodVCg__E = _parameter_validation.number_in_range_return_float(
+ periodVCg__E, "periodVCg__E", min_val=0.0, min_inclusive=True
+ )
+ if self._ampVCg__E == 0 and periodVCg__E != 0:
+ raise ValueError("If ampVCg__E is 0.0, then periodVCg__E must also be 0.0.")
+ self._periodVCg__E = periodVCg__E
+
+ if isinstance(spacingVCg__E, str):
+ if spacingVCg__E not in ["sine", "uniform"]:
+ raise ValueError(
+ f"spacingVCg__E must be 'sine', 'uniform', or a callable, "
+ f"got string '{spacingVCg__E}'."
+ )
+ elif not callable(spacingVCg__E):
+ raise TypeError(
+ f"spacingVCg__E must be 'sine', 'uniform', or a callable, got "
+ f"{type(spacingVCg__E).__name__}."
+ )
+ self._spacingVCg__E = spacingVCg__E
+
+ phaseVCg__E = _parameter_validation.number_in_range_return_float(
+ phaseVCg__E, "phaseVCg__E", -180.0, False, 180.0, True
+ )
+ if self._ampVCg__E == 0 and phaseVCg__E != 0:
+ raise ValueError("If ampVCg__E is 0.0, then phaseVCg__E must also be 0.0.")
+ self._phaseVCg__E = phaseVCg__E
+
+ # Initialize the cache for the property derived from the immutable
+ # attributes.
+ self._max_period: float | None = None
+
+ # --- Immutable: read only properties ---
+ @property
+ def base_operating_point(self) -> operating_point_mod.OperatingPoint:
+ return self._base_operating_point
+
+ @property
+ def ampVCg__E(self) -> float:
+ return self._ampVCg__E
+
+ @property
+ def periodVCg__E(self) -> float:
+ return self._periodVCg__E
+
+ @property
+ def spacingVCg__E(self) -> str | Callable[[float], float]:
+ return self._spacingVCg__E
+
+ @property
+ def phaseVCg__E(self) -> float:
+ return self._phaseVCg__E
+
+ # --- Immutable derived: manual lazy caching ---
+ @property
+ def max_period(self) -> float:
+ """The longest period of this OperatingPoint's motion.
+
+ :return: The longest period in seconds. If the motion is static, this will be
+ 0.0.
+ """
+ if self._max_period is None:
+ self._max_period = self._periodVCg__E
+ return self._max_period
+
+ # --- Other methods ---
+ def generate_operating_point_at_time_step(
+ self, step: int, delta_time: float | int
+ ) -> operating_point_mod.OperatingPoint:
+ """Creates the OperatingPoint at a single time step.
+
+ :param step: The time step index. Must be a non negative int.
+ :param delta_time: The time between each time step in seconds. Must be a
+ positive number (int or float).
+ :return: The OperatingPoint at this time step.
+ """
+ time = step * delta_time
+
+ # Evaluate the oscillating function for VCg__E.
+ if self._spacingVCg__E == "sine":
+ this_vCg__E = _oscillation.oscillating_sin_at_time(
+ amp=self._ampVCg__E,
+ period=self._periodVCg__E,
+ phase=self._phaseVCg__E,
+ base=self._base_operating_point.vCg__E,
+ time=time,
+ )
+ elif self._spacingVCg__E == "uniform":
+ this_vCg__E = _oscillation.oscillating_lin_at_time(
+ amp=self._ampVCg__E,
+ period=self._periodVCg__E,
+ phase=self._phaseVCg__E,
+ base=self._base_operating_point.vCg__E,
+ time=time,
+ )
+ elif callable(self._spacingVCg__E):
+ this_vCg__E = _oscillation.oscillating_custom_at_time(
+ amp=self._ampVCg__E,
+ period=self._periodVCg__E,
+ phase=self._phaseVCg__E,
+ base=self._base_operating_point.vCg__E,
+ time=time,
+ custom_function=self._spacingVCg__E,
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {self._spacingVCg__E}")
+
+ return operating_point_mod.OperatingPoint(
+ rho=self._base_operating_point.rho,
+ vCg__E=this_vCg__E,
+ alpha=self._base_operating_point.alpha,
+ beta=self._base_operating_point.beta,
+ externalFX_W=self._base_operating_point.externalFX_W,
+ nu=self._base_operating_point.nu,
+ angles_E_to_BP1_izyx=self._base_operating_point.angles_E_to_BP1_izyx,
+ CgP1_E_Eo=self._base_operating_point.CgP1_E_Eo,
+ surfaceNormal_E=self._base_operating_point.surfaceNormal_E,
+ surfacePoint_E_Eo=self._base_operating_point.surfacePoint_E_Eo,
+ )
+
+ def generate_operating_points(
+ self, num_steps: int, delta_time: float | int
+ ) -> list[operating_point_mod.OperatingPoint]:
+ """Creates the OperatingPoint at each time step, and returns them in a list.
+
+ :param num_steps: The number of time steps in this movement. It must be a
+ positive int.
+ :param delta_time: The time between each time step. It must be a positive number
+ (int or float), and will be converted internally to a float. The units are
+ in seconds.
+ :return: The list of OperatingPoints associated with this
+ CoreOperatingPointMovement.
+ """
+ num_steps = _parameter_validation.int_in_range_return_int(
+ num_steps,
+ "num_steps",
+ min_val=1,
+ min_inclusive=True,
+ )
+ delta_time = _parameter_validation.number_in_range_return_float(
+ delta_time, "delta_time", min_val=0.0, min_inclusive=False
+ )
+
+ operating_points = []
+ for step in range(num_steps):
+ operating_points.append(
+ self.generate_operating_point_at_time_step(step, delta_time)
+ )
+
+ return operating_points
+
+
+class CoreWingCrossSectionMovement:
+ """A core class used to contain the shared foundation of WingCrossSectionMovement
+ and its feature variant siblings.
+
+ See WingCrossSectionMovement for full documentation of the shared interface.
+
+ CoreWingCrossSectionMovement holds the base WingCrossSection and oscillation
+ parameters, and provides generate_wing_cross_section_at_time_step() for creating
+ WingCrossSections one step at a time.
+ """
+
+ __slots__ = (
+ "_base_wing_cross_section",
+ "_ampLp_Wcsp_Lpp",
+ "_periodLp_Wcsp_Lpp",
+ "_spacingLp_Wcsp_Lpp",
+ "_phaseLp_Wcsp_Lpp",
+ "_ampAngles_Wcsp_to_Wcs_ixyz",
+ "_periodAngles_Wcsp_to_Wcs_ixyz",
+ "_spacingAngles_Wcsp_to_Wcs_ixyz",
+ "_phaseAngles_Wcsp_to_Wcs_ixyz",
+ "_all_periods",
+ "_max_period",
+ )
+
+ def __init__(
+ self,
+ base_wing_cross_section: geometry.wing_cross_section.WingCrossSection,
+ ampLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp: np.ndarray | Sequence[str | Callable[[float], float]] = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ periodAngles_Wcsp_to_Wcs_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ spacingAngles_Wcsp_to_Wcs_ixyz: (
+ np.ndarray | Sequence[str | Callable[[float], float]]
+ ) = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseAngles_Wcsp_to_Wcs_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ ) -> None:
+ """The initialization method.
+
+ See WingCrossSectionMovement's initialization method for full parameter
+ descriptions.
+
+ :param base_wing_cross_section: The base WingCrossSection.
+ :param ampLp_Wcsp_Lpp: The amplitudes of Lp_Wcsp_Lpp oscillation in meters.
+ :param periodLp_Wcsp_Lpp: The periods of Lp_Wcsp_Lpp oscillation in seconds.
+ :param spacingLp_Wcsp_Lpp: The spacing types for Lp_Wcsp_Lpp oscillation.
+ :param phaseLp_Wcsp_Lpp: The phase offsets of Lp_Wcsp_Lpp oscillation in
+ degrees.
+ :param ampAngles_Wcsp_to_Wcs_ixyz: The amplitudes of angles_Wcsp_to_Wcs_ixyz
+ oscillation in degrees.
+ :param periodAngles_Wcsp_to_Wcs_ixyz: The periods of angles_Wcsp_to_Wcs_ixyz
+ oscillation in seconds.
+ :param spacingAngles_Wcsp_to_Wcs_ixyz: The spacing types for
+ angles_Wcsp_to_Wcs_ixyz oscillation.
+ :param phaseAngles_Wcsp_to_Wcs_ixyz: The phase offsets of
+ angles_Wcsp_to_Wcs_ixyz oscillation in degrees.
+ :return: None
+ """
+ # Validate and store immutable attributes. Set those that are numpy
+ # arrays to be read only.
+ if not isinstance(
+ base_wing_cross_section,
+ geometry.wing_cross_section.WingCrossSection,
+ ):
+ raise TypeError("base_wing_cross_section must be a WingCrossSection.")
+ self._base_wing_cross_section = base_wing_cross_section
+
+ ampLp_Wcsp_Lpp = _parameter_validation.threeD_number_vectorLike_return_float(
+ ampLp_Wcsp_Lpp, "ampLp_Wcsp_Lpp"
+ )
+ if not np.all(ampLp_Wcsp_Lpp >= 0.0):
+ raise ValueError("All elements in ampLp_Wcsp_Lpp must be non negative.")
+ self._ampLp_Wcsp_Lpp = ampLp_Wcsp_Lpp
+ self._ampLp_Wcsp_Lpp.flags.writeable = False
+
+ periodLp_Wcsp_Lpp = _parameter_validation.threeD_number_vectorLike_return_float(
+ periodLp_Wcsp_Lpp, "periodLp_Wcsp_Lpp"
+ )
+ if not np.all(periodLp_Wcsp_Lpp >= 0.0):
+ raise ValueError("All elements in periodLp_Wcsp_Lpp must be non negative.")
+ for period_index, period in enumerate(periodLp_Wcsp_Lpp):
+ amp = self._ampLp_Wcsp_Lpp[period_index]
+ if amp == 0 and period != 0:
+ raise ValueError(
+ "If an element in ampLp_Wcsp_Lpp is 0.0, the "
+ "corresponding element in periodLp_Wcsp_Lpp must be "
+ "also be 0.0."
+ )
+ self._periodLp_Wcsp_Lpp = periodLp_Wcsp_Lpp
+ self._periodLp_Wcsp_Lpp.flags.writeable = False
+
+ # Store as tuple to prevent external mutation.
+ self._spacingLp_Wcsp_Lpp = (
+ _parameter_validation.threeD_spacing_vectorLike_return_tuple(
+ spacingLp_Wcsp_Lpp, "spacingLp_Wcsp_Lpp"
+ )
+ )
+
+ phaseLp_Wcsp_Lpp = _parameter_validation.threeD_number_vectorLike_return_float(
+ phaseLp_Wcsp_Lpp, "phaseLp_Wcsp_Lpp"
+ )
+ if not (
+ np.all(phaseLp_Wcsp_Lpp > -180.0) and np.all(phaseLp_Wcsp_Lpp <= 180.0)
+ ):
+ raise ValueError(
+ "All elements in phaseLp_Wcsp_Lpp must be in the range "
+ "(-180.0, 180.0]."
+ )
+ for phase_index, phase in enumerate(phaseLp_Wcsp_Lpp):
+ amp = self._ampLp_Wcsp_Lpp[phase_index]
+ if amp == 0 and phase != 0:
+ raise ValueError(
+ "If an element in ampLp_Wcsp_Lpp is 0.0, the "
+ "corresponding element in phaseLp_Wcsp_Lpp must be "
+ "also be 0.0."
+ )
+ self._phaseLp_Wcsp_Lpp = phaseLp_Wcsp_Lpp
+ self._phaseLp_Wcsp_Lpp.flags.writeable = False
+
+ ampAngles_Wcsp_to_Wcs_ixyz = (
+ _parameter_validation.threeD_number_vectorLike_return_float(
+ ampAngles_Wcsp_to_Wcs_ixyz, "ampAngles_Wcsp_to_Wcs_ixyz"
+ )
+ )
+ if not (
+ np.all(ampAngles_Wcsp_to_Wcs_ixyz >= 0.0)
+ and np.all(ampAngles_Wcsp_to_Wcs_ixyz <= 180.0)
+ ):
+ raise ValueError(
+ "All elements in ampAngles_Wcsp_to_Wcs_ixyz must be in "
+ "the range [0.0, 180.0]."
+ )
+ self._ampAngles_Wcsp_to_Wcs_ixyz = ampAngles_Wcsp_to_Wcs_ixyz
+ self._ampAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
+
+ periodAngles_Wcsp_to_Wcs_ixyz = (
+ _parameter_validation.threeD_number_vectorLike_return_float(
+ periodAngles_Wcsp_to_Wcs_ixyz,
+ "periodAngles_Wcsp_to_Wcs_ixyz",
+ )
+ )
+ if not np.all(periodAngles_Wcsp_to_Wcs_ixyz >= 0.0):
+ raise ValueError(
+ "All elements in periodAngles_Wcsp_to_Wcs_ixyz must be " "non negative."
+ )
+ for period_index, period in enumerate(periodAngles_Wcsp_to_Wcs_ixyz):
+ amp = self._ampAngles_Wcsp_to_Wcs_ixyz[period_index]
+ if amp == 0 and period != 0:
+ raise ValueError(
+ "If an element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0, "
+ "the corresponding element in "
+ "periodAngles_Wcsp_to_Wcs_ixyz must be also be 0.0."
+ )
+ self._periodAngles_Wcsp_to_Wcs_ixyz = periodAngles_Wcsp_to_Wcs_ixyz
+ self._periodAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
+
+ # Store as tuple to prevent external mutation.
+ self._spacingAngles_Wcsp_to_Wcs_ixyz = (
+ _parameter_validation.threeD_spacing_vectorLike_return_tuple(
+ spacingAngles_Wcsp_to_Wcs_ixyz,
+ "spacingAngles_Wcsp_to_Wcs_ixyz",
+ )
+ )
+
+ phaseAngles_Wcsp_to_Wcs_ixyz = (
+ _parameter_validation.threeD_number_vectorLike_return_float(
+ phaseAngles_Wcsp_to_Wcs_ixyz,
+ "phaseAngles_Wcsp_to_Wcs_ixyz",
+ )
+ )
+ if not (
+ np.all(phaseAngles_Wcsp_to_Wcs_ixyz > -180.0)
+ and np.all(phaseAngles_Wcsp_to_Wcs_ixyz <= 180.0)
+ ):
+ raise ValueError(
+ "All elements in phaseAngles_Wcsp_to_Wcs_ixyz must be in "
+ "the range (-180.0, 180.0]."
+ )
+ for phase_index, phase in enumerate(phaseAngles_Wcsp_to_Wcs_ixyz):
+ amp = self._ampAngles_Wcsp_to_Wcs_ixyz[phase_index]
+ if amp == 0 and phase != 0:
+ raise ValueError(
+ "If an element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0, "
+ "the corresponding element in "
+ "phaseAngles_Wcsp_to_Wcs_ixyz must be also be 0.0."
+ )
+ self._phaseAngles_Wcsp_to_Wcs_ixyz = phaseAngles_Wcsp_to_Wcs_ixyz
+ self._phaseAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
+
+ # Initialize the caches for the properties derived from the immutable
+ # attributes.
+ self._all_periods: tuple[float, ...] | None = None
+ self._max_period: float | None = None
+
+ # --- Deep copy method ---
+ def __deepcopy__(self, memo: dict) -> CoreWingCrossSectionMovement:
+ """Creates a deep copy of this CoreWingCrossSectionMovement.
+
+ See WingCrossSectionMovement for full documentation.
+
+ :param memo: A dict used by the copy module to track already copied objects and
+ avoid infinite recursion.
+ :return: A new instance with copied attributes.
+ """
+ # Create a new instance without calling __init__ to avoid redundant
+ # validation. Use type(self) so subclasses get the correct type.
+ new_movement = object.__new__(type(self))
+
+ # Store this instance in memo to handle potential circular references.
+ memo[id(self)] = new_movement
+
+ # Deep copy the base WingCrossSection to ensure independence
+ # (immutable).
+ new_movement._base_wing_cross_section = copy.deepcopy(
+ self._base_wing_cross_section, memo
+ )
+
+ # Copy numpy arrays and make them read only.
+ new_movement._ampLp_Wcsp_Lpp = self._ampLp_Wcsp_Lpp.copy()
+ new_movement._ampLp_Wcsp_Lpp.flags.writeable = False
+
+ new_movement._periodLp_Wcsp_Lpp = self._periodLp_Wcsp_Lpp.copy()
+ new_movement._periodLp_Wcsp_Lpp.flags.writeable = False
+
+ new_movement._phaseLp_Wcsp_Lpp = self._phaseLp_Wcsp_Lpp.copy()
+ new_movement._phaseLp_Wcsp_Lpp.flags.writeable = False
+
+ new_movement._ampAngles_Wcsp_to_Wcs_ixyz = (
+ self._ampAngles_Wcsp_to_Wcs_ixyz.copy()
+ )
+ new_movement._ampAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
+
+ new_movement._periodAngles_Wcsp_to_Wcs_ixyz = (
+ self._periodAngles_Wcsp_to_Wcs_ixyz.copy()
+ )
+ new_movement._periodAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
+
+ new_movement._phaseAngles_Wcsp_to_Wcs_ixyz = (
+ self._phaseAngles_Wcsp_to_Wcs_ixyz.copy()
+ )
+ new_movement._phaseAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
+
+ # Copy tuples directly (they are immutable).
+ new_movement._spacingLp_Wcsp_Lpp = self._spacingLp_Wcsp_Lpp
+ new_movement._spacingAngles_Wcsp_to_Wcs_ixyz = (
+ self._spacingAngles_Wcsp_to_Wcs_ixyz
+ )
+
+ # Initialize cache variables to None (caches will be recomputed on
+ # access).
+ new_movement._all_periods = None
+ new_movement._max_period = None
+
+ return new_movement
+
+ # --- Immutable: read only properties ---
+ @property
+ def base_wing_cross_section(
+ self,
+ ) -> geometry.wing_cross_section.WingCrossSection:
+ return self._base_wing_cross_section
+
+ @property
+ def ampLp_Wcsp_Lpp(self) -> np.ndarray:
+ return self._ampLp_Wcsp_Lpp
+
+ @property
+ def periodLp_Wcsp_Lpp(self) -> np.ndarray:
+ return self._periodLp_Wcsp_Lpp
+
+ @property
+ def spacingLp_Wcsp_Lpp(
+ self,
+ ) -> tuple[str | Callable[[float], float], ...]:
+ return self._spacingLp_Wcsp_Lpp
+
+ @property
+ def phaseLp_Wcsp_Lpp(self) -> np.ndarray:
+ return self._phaseLp_Wcsp_Lpp
+
+ @property
+ def ampAngles_Wcsp_to_Wcs_ixyz(self) -> np.ndarray:
+ return self._ampAngles_Wcsp_to_Wcs_ixyz
+
+ @property
+ def periodAngles_Wcsp_to_Wcs_ixyz(self) -> np.ndarray:
+ return self._periodAngles_Wcsp_to_Wcs_ixyz
+
+ @property
+ def spacingAngles_Wcsp_to_Wcs_ixyz(
+ self,
+ ) -> tuple[str | Callable[[float], float], ...]:
+ return self._spacingAngles_Wcsp_to_Wcs_ixyz
+
+ @property
+ def phaseAngles_Wcsp_to_Wcs_ixyz(self) -> np.ndarray:
+ return self._phaseAngles_Wcsp_to_Wcs_ixyz
+
+ # --- Immutable derived: manual lazy caching ---
+ @property
+ def all_periods(self) -> tuple[float, ...]:
+ """All unique non zero periods of this WingCrossSection's motion.
+
+ :return: A tuple of all unique non zero periods in seconds. If the motion is
+ static, this will be an empty tuple.
+ """
+ if self._all_periods is None:
+ periods = []
+
+ # Collect all periods from positional motion.
+ for period in self._periodLp_Wcsp_Lpp:
+ if period > 0.0:
+ periods.append(float(period))
+
+ # Collect all periods from angular motion.
+ for period in self._periodAngles_Wcsp_to_Wcs_ixyz:
+ if period > 0.0:
+ periods.append(float(period))
+
+ self._all_periods = tuple(periods)
+ return self._all_periods
+
+ @property
+ def max_period(self) -> float:
+ """The longest period of this WingCrossSection's motion.
+
+ :return: The longest period in seconds. If the motion is static, this will be
+ 0.0.
+ """
+ if self._max_period is None:
+ self._max_period = float(
+ max(
+ np.max(self._periodLp_Wcsp_Lpp),
+ np.max(self._periodAngles_Wcsp_to_Wcs_ixyz),
+ )
+ )
+ return self._max_period
+
+ # --- Other methods ---
+ def generate_wing_cross_section_at_time_step(
+ self, step: int, delta_time: float | int
+ ) -> geometry.wing_cross_section.WingCrossSection:
+ """Creates the WingCrossSection at a single time step.
+
+ :param step: The time step index. Must be a non negative int.
+ :param delta_time: The time between each time step in seconds. Must be a
+ positive number (int or float).
+ :return: The WingCrossSection at this time step.
+ """
+ time = step * delta_time
+
+ # Evaluate the oscillating value for each dimension of Lp_Wcsp_Lpp.
+ thisLp_Wcsp_Lpp = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingLp_Wcsp_Lpp[dim]
+ this_amp = self._ampLp_Wcsp_Lpp[dim]
+ this_period = self._periodLp_Wcsp_Lpp[dim]
+ this_phase = self._phaseLp_Wcsp_Lpp[dim]
+ this_base = self._base_wing_cross_section.Lp_Wcsp_Lpp[dim]
+
+ if this_spacing == "sine":
+ thisLp_Wcsp_Lpp[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ thisLp_Wcsp_Lpp[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ thisLp_Wcsp_Lpp[dim] = _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ # Evaluate the oscillating value for each dimension of
+ # angles_Wcsp_to_Wcs_ixyz.
+ theseAngles_Wcsp_to_Wcs_ixyz = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingAngles_Wcsp_to_Wcs_ixyz[dim]
+ this_amp = self._ampAngles_Wcsp_to_Wcs_ixyz[dim]
+ this_period = self._periodAngles_Wcsp_to_Wcs_ixyz[dim]
+ this_phase = self._phaseAngles_Wcsp_to_Wcs_ixyz[dim]
+ this_base = self._base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim]
+
+ if this_spacing == "sine":
+ theseAngles_Wcsp_to_Wcs_ixyz[dim] = (
+ _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ )
+ elif this_spacing == "uniform":
+ theseAngles_Wcsp_to_Wcs_ixyz[dim] = (
+ _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ )
+ elif callable(this_spacing):
+ theseAngles_Wcsp_to_Wcs_ixyz[dim] = (
+ _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ return geometry.wing_cross_section.WingCrossSection(
+ airfoil=self._base_wing_cross_section.airfoil,
+ num_spanwise_panels=self._base_wing_cross_section.num_spanwise_panels,
+ chord=self._base_wing_cross_section.chord,
+ Lp_Wcsp_Lpp=thisLp_Wcsp_Lpp,
+ angles_Wcsp_to_Wcs_ixyz=theseAngles_Wcsp_to_Wcs_ixyz,
+ control_surface_symmetry_type=(
+ self._base_wing_cross_section.control_surface_symmetry_type
+ ),
+ control_surface_hinge_point=(
+ self._base_wing_cross_section.control_surface_hinge_point
+ ),
+ control_surface_deflection=(
+ self._base_wing_cross_section.control_surface_deflection
+ ),
+ spanwise_spacing=self._base_wing_cross_section.spanwise_spacing,
+ )
+
+ def generate_wing_cross_sections(
+ self,
+ num_steps: int,
+ delta_time: float | int,
+ ) -> list[geometry.wing_cross_section.WingCrossSection]:
+ """Creates the WingCrossSection at each time step, and returns them in a list.
+
+ :param num_steps: The number of time steps in this movement. It must be a
+ positive int.
+ :param delta_time: The time between each time step. It must be a positive number
+ (int or float), and will be converted internally to a float. The units are
+ in seconds.
+ :return: The list of WingCrossSections associated with this
+ CoreWingCrossSectionMovement.
+ """
+ num_steps = _parameter_validation.int_in_range_return_int(
+ num_steps,
+ "num_steps",
+ min_val=1,
+ min_inclusive=True,
+ )
+ delta_time = _parameter_validation.number_in_range_return_float(
+ delta_time, "delta_time", min_val=0.0, min_inclusive=False
+ )
+
+ wing_cross_sections = []
+ for step in range(num_steps):
+ wing_cross_sections.append(
+ self.generate_wing_cross_section_at_time_step(step, delta_time)
+ )
+
+ return wing_cross_sections
+
+
+class CoreWingMovement:
+ """A core class used to contain the shared foundation of WingMovement and its
+ feature variant siblings.
+
+ See WingMovement for full documentation of the shared interface.
+
+ CoreWingMovement holds the base Wing, WingCrossSectionMovements, and oscillation
+ parameters, and provides generate_wing_at_time_step() for creating Wings one step at
+ a time.
+ """
+
+ __slots__ = (
+ "_base_wing",
+ "_wing_cross_section_movements",
+ "_ampLer_Gs_Cgs",
+ "_periodLer_Gs_Cgs",
+ "_spacingLer_Gs_Cgs",
+ "_phaseLer_Gs_Cgs",
+ "_ampAngles_Gs_to_Wn_ixyz",
+ "_periodAngles_Gs_to_Wn_ixyz",
+ "_spacingAngles_Gs_to_Wn_ixyz",
+ "_phaseAngles_Gs_to_Wn_ixyz",
+ "_rotationPointOffset_Gs_Ler",
+ "_all_periods",
+ "_max_period",
+ )
+
+ def __init__(
+ self,
+ base_wing: geometry.wing.Wing,
+ wing_cross_section_movements: Sequence[CoreWingCrossSectionMovement],
+ ampLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs: np.ndarray | Sequence[str | Callable[[float], float]] = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ periodAngles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ spacingAngles_Gs_to_Wn_ixyz: (
+ np.ndarray | Sequence[str | Callable[[float], float]]
+ ) = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseAngles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ rotationPointOffset_Gs_Ler: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ ) -> None:
+ """The initialization method.
+
+ See WingMovement's initialization method for full parameter descriptions.
+
+ :param base_wing: The base Wing.
+ :param wing_cross_section_movements: The CoreWingCrossSectionMovements for each
+ WingCrossSection.
+ :param ampLer_Gs_Cgs: The amplitudes of Ler_Gs_Cgs oscillation in meters.
+ :param periodLer_Gs_Cgs: The periods of Ler_Gs_Cgs oscillation in seconds.
+ :param spacingLer_Gs_Cgs: The spacing types for Ler_Gs_Cgs oscillation.
+ :param phaseLer_Gs_Cgs: The phase offsets of Ler_Gs_Cgs oscillation in degrees.
+ :param ampAngles_Gs_to_Wn_ixyz: The amplitudes of angles_Gs_to_Wn_ixyz
+ oscillation in degrees.
+ :param periodAngles_Gs_to_Wn_ixyz: The periods of angles_Gs_to_Wn_ixyz
+ oscillation in seconds.
+ :param spacingAngles_Gs_to_Wn_ixyz: The spacing types for angles_Gs_to_Wn_ixyz
+ oscillation.
+ :param phaseAngles_Gs_to_Wn_ixyz: The phase offsets of angles_Gs_to_Wn_ixyz
+ oscillation in degrees.
+ :param rotationPointOffset_Gs_Ler: The position of the rotation point for
+ angular motion in meters.
+ :return: None
+ """
+ # Validate and store immutable attributes. Set those that are numpy
+ # arrays to be read only.
+ if not isinstance(base_wing, geometry.wing.Wing):
+ raise TypeError("base_wing must be a Wing.")
+ self._base_wing = base_wing
+
+ if not isinstance(wing_cross_section_movements, list):
+ raise TypeError("wing_cross_section_movements must be a list.")
+ if len(wing_cross_section_movements) != len(
+ self._base_wing.wing_cross_sections
+ ):
+ raise ValueError(
+ "wing_cross_section_movements must have the same length "
+ "as base_wing.wing_cross_sections."
+ )
+ for wing_cross_section_movement in wing_cross_section_movements:
+ if not isinstance(
+ wing_cross_section_movement,
+ CoreWingCrossSectionMovement,
+ ):
+ raise TypeError(
+ "Every element in wing_cross_section_movements must "
+ "be a CoreWingCrossSectionMovement."
+ )
+ # Store as tuple to prevent external mutation.
+ self._wing_cross_section_movements: tuple[CoreWingCrossSectionMovement, ...] = (
+ tuple(wing_cross_section_movements)
+ )
+
+ ampLer_Gs_Cgs = _parameter_validation.threeD_number_vectorLike_return_float(
+ ampLer_Gs_Cgs, "ampLer_Gs_Cgs"
+ )
+ if not np.all(ampLer_Gs_Cgs >= 0.0):
+ raise ValueError("All elements in ampLer_Gs_Cgs must be non negative.")
+ self._ampLer_Gs_Cgs = ampLer_Gs_Cgs
+ self._ampLer_Gs_Cgs.flags.writeable = False
+
+ periodLer_Gs_Cgs = _parameter_validation.threeD_number_vectorLike_return_float(
+ periodLer_Gs_Cgs, "periodLer_Gs_Cgs"
+ )
+ if not np.all(periodLer_Gs_Cgs >= 0.0):
+ raise ValueError("All elements in periodLer_Gs_Cgs must be non negative.")
+ for period_index, period in enumerate(periodLer_Gs_Cgs):
+ amp = self._ampLer_Gs_Cgs[period_index]
+ if amp == 0 and period != 0:
+ raise ValueError(
+ "If an element in ampLer_Gs_Cgs is 0.0, the "
+ "corresponding element in periodLer_Gs_Cgs must be "
+ "also be 0.0."
+ )
+ self._periodLer_Gs_Cgs = periodLer_Gs_Cgs
+ self._periodLer_Gs_Cgs.flags.writeable = False
+
+ # Store as tuple to prevent external mutation.
+ self._spacingLer_Gs_Cgs = (
+ _parameter_validation.threeD_spacing_vectorLike_return_tuple(
+ spacingLer_Gs_Cgs, "spacingLer_Gs_Cgs"
+ )
+ )
+
+ phaseLer_Gs_Cgs = _parameter_validation.threeD_number_vectorLike_return_float(
+ phaseLer_Gs_Cgs, "phaseLer_Gs_Cgs"
+ )
+ if not (np.all(phaseLer_Gs_Cgs > -180.0) and np.all(phaseLer_Gs_Cgs <= 180.0)):
+ raise ValueError(
+ "All elements in phaseLer_Gs_Cgs must be in the range "
+ "(-180.0, 180.0]."
+ )
+ for phase_index, phase in enumerate(phaseLer_Gs_Cgs):
+ amp = self._ampLer_Gs_Cgs[phase_index]
+ if amp == 0 and phase != 0:
+ raise ValueError(
+ "If an element in ampLer_Gs_Cgs is 0.0, the "
+ "corresponding element in phaseLer_Gs_Cgs must be "
+ "also be 0.0."
+ )
+ self._phaseLer_Gs_Cgs = phaseLer_Gs_Cgs
+ self._phaseLer_Gs_Cgs.flags.writeable = False
+
+ ampAngles_Gs_to_Wn_ixyz = (
+ _parameter_validation.threeD_number_vectorLike_return_float(
+ ampAngles_Gs_to_Wn_ixyz, "ampAngles_Gs_to_Wn_ixyz"
+ )
+ )
+ if not (
+ np.all(ampAngles_Gs_to_Wn_ixyz >= 0.0)
+ and np.all(ampAngles_Gs_to_Wn_ixyz <= 180.0)
+ ):
+ raise ValueError(
+ "All elements in ampAngles_Gs_to_Wn_ixyz must be in "
+ "the range [0.0, 180.0]."
+ )
+ self._ampAngles_Gs_to_Wn_ixyz = ampAngles_Gs_to_Wn_ixyz
+ self._ampAngles_Gs_to_Wn_ixyz.flags.writeable = False
+
+ periodAngles_Gs_to_Wn_ixyz = (
+ _parameter_validation.threeD_number_vectorLike_return_float(
+ periodAngles_Gs_to_Wn_ixyz,
+ "periodAngles_Gs_to_Wn_ixyz",
+ )
+ )
+ if not np.all(periodAngles_Gs_to_Wn_ixyz >= 0.0):
+ raise ValueError(
+ "All elements in periodAngles_Gs_to_Wn_ixyz must be " "non negative."
+ )
+ for period_index, period in enumerate(periodAngles_Gs_to_Wn_ixyz):
+ amp = self._ampAngles_Gs_to_Wn_ixyz[period_index]
+ if amp == 0 and period != 0:
+ raise ValueError(
+ "If an element in ampAngles_Gs_to_Wn_ixyz is 0.0, "
+ "the corresponding element in "
+ "periodAngles_Gs_to_Wn_ixyz must be also be 0.0."
+ )
+ self._periodAngles_Gs_to_Wn_ixyz = periodAngles_Gs_to_Wn_ixyz
+ self._periodAngles_Gs_to_Wn_ixyz.flags.writeable = False
+
+ # Store as tuple to prevent external mutation.
+ self._spacingAngles_Gs_to_Wn_ixyz = (
+ _parameter_validation.threeD_spacing_vectorLike_return_tuple(
+ spacingAngles_Gs_to_Wn_ixyz,
+ "spacingAngles_Gs_to_Wn_ixyz",
+ )
+ )
+
+ phaseAngles_Gs_to_Wn_ixyz = (
+ _parameter_validation.threeD_number_vectorLike_return_float(
+ phaseAngles_Gs_to_Wn_ixyz,
+ "phaseAngles_Gs_to_Wn_ixyz",
+ )
+ )
+ if not (
+ np.all(phaseAngles_Gs_to_Wn_ixyz > -180.0)
+ and np.all(phaseAngles_Gs_to_Wn_ixyz <= 180.0)
+ ):
+ raise ValueError(
+ "All elements in phaseAngles_Gs_to_Wn_ixyz must be in "
+ "the range (-180.0, 180.0]."
+ )
+ for phase_index, phase in enumerate(phaseAngles_Gs_to_Wn_ixyz):
+ amp = self._ampAngles_Gs_to_Wn_ixyz[phase_index]
+ if amp == 0 and phase != 0:
+ raise ValueError(
+ "If an element in ampAngles_Gs_to_Wn_ixyz is 0.0, "
+ "the corresponding element in "
+ "phaseAngles_Gs_to_Wn_ixyz must be also be 0.0."
+ )
+ self._phaseAngles_Gs_to_Wn_ixyz = phaseAngles_Gs_to_Wn_ixyz
+ self._phaseAngles_Gs_to_Wn_ixyz.flags.writeable = False
+
+ rotationPointOffset_Gs_Ler = (
+ _parameter_validation.threeD_number_vectorLike_return_float(
+ rotationPointOffset_Gs_Ler,
+ "rotationPointOffset_Gs_Ler",
+ )
+ )
+ self._rotationPointOffset_Gs_Ler = rotationPointOffset_Gs_Ler
+ self._rotationPointOffset_Gs_Ler.flags.writeable = False
+
+ # Initialize the caches for the properties derived from the immutable
+ # attributes.
+ self._all_periods: tuple[float, ...] | None = None
+ self._max_period: float | None = None
+
+ # --- Deep copy method ---
+ def __deepcopy__(self, memo: dict) -> CoreWingMovement:
+ """Creates a deep copy of this CoreWingMovement.
+
+ See WingMovement for full documentation.
+
+ :param memo: A dict used by the copy module to track already copied objects and
+ avoid infinite recursion.
+ :return: A new instance with copied attributes.
+ """
+ # Create a new instance without calling __init__ to avoid redundant
+ # validation. Use type(self) so subclasses get the correct type.
+ new_movement = object.__new__(type(self))
+
+ # Store this instance in memo to handle potential circular references.
+ memo[id(self)] = new_movement
+
+ # Deep copy the base Wing to ensure independence (immutable).
+ new_movement._base_wing = copy.deepcopy(self._base_wing, memo)
+
+ # Deep copy the WingCrossSectionMovements and store as tuple.
+ new_movement._wing_cross_section_movements = tuple(
+ copy.deepcopy(wing_cross_section_movement, memo)
+ for wing_cross_section_movement in self._wing_cross_section_movements
+ )
+
+ # Copy numpy arrays and make them read only.
+ new_movement._ampLer_Gs_Cgs = self._ampLer_Gs_Cgs.copy()
+ new_movement._ampLer_Gs_Cgs.flags.writeable = False
+
+ new_movement._periodLer_Gs_Cgs = self._periodLer_Gs_Cgs.copy()
+ new_movement._periodLer_Gs_Cgs.flags.writeable = False
+
+ new_movement._phaseLer_Gs_Cgs = self._phaseLer_Gs_Cgs.copy()
+ new_movement._phaseLer_Gs_Cgs.flags.writeable = False
+
+ new_movement._ampAngles_Gs_to_Wn_ixyz = self._ampAngles_Gs_to_Wn_ixyz.copy()
+ new_movement._ampAngles_Gs_to_Wn_ixyz.flags.writeable = False
+
+ new_movement._periodAngles_Gs_to_Wn_ixyz = (
+ self._periodAngles_Gs_to_Wn_ixyz.copy()
+ )
+ new_movement._periodAngles_Gs_to_Wn_ixyz.flags.writeable = False
+
+ new_movement._phaseAngles_Gs_to_Wn_ixyz = self._phaseAngles_Gs_to_Wn_ixyz.copy()
+ new_movement._phaseAngles_Gs_to_Wn_ixyz.flags.writeable = False
+
+ new_movement._rotationPointOffset_Gs_Ler = (
+ self._rotationPointOffset_Gs_Ler.copy()
+ )
+ new_movement._rotationPointOffset_Gs_Ler.flags.writeable = False
+
+ # Copy tuples directly (they are immutable).
+ new_movement._spacingLer_Gs_Cgs = self._spacingLer_Gs_Cgs
+ new_movement._spacingAngles_Gs_to_Wn_ixyz = self._spacingAngles_Gs_to_Wn_ixyz
+
+ # Initialize cache variables to None (caches will be recomputed on
+ # access).
+ new_movement._all_periods = None
+ new_movement._max_period = None
+
+ return new_movement
+
+ # --- Immutable: read only properties ---
+ @property
+ def base_wing(self) -> geometry.wing.Wing:
+ return self._base_wing
+
+ @property
+ def wing_cross_section_movements(
+ self,
+ ) -> tuple[CoreWingCrossSectionMovement, ...]:
+ return self._wing_cross_section_movements
+
+ @property
+ def ampLer_Gs_Cgs(self) -> np.ndarray:
+ return self._ampLer_Gs_Cgs
+
+ @property
+ def periodLer_Gs_Cgs(self) -> np.ndarray:
+ return self._periodLer_Gs_Cgs
+
+ @property
+ def spacingLer_Gs_Cgs(
+ self,
+ ) -> tuple[str | Callable[[float], float], ...]:
+ return self._spacingLer_Gs_Cgs
+
+ @property
+ def phaseLer_Gs_Cgs(self) -> np.ndarray:
+ return self._phaseLer_Gs_Cgs
+
+ @property
+ def ampAngles_Gs_to_Wn_ixyz(self) -> np.ndarray:
+ return self._ampAngles_Gs_to_Wn_ixyz
+
+ @property
+ def periodAngles_Gs_to_Wn_ixyz(self) -> np.ndarray:
+ return self._periodAngles_Gs_to_Wn_ixyz
+
+ @property
+ def spacingAngles_Gs_to_Wn_ixyz(
+ self,
+ ) -> tuple[str | Callable[[float], float], ...]:
+ return self._spacingAngles_Gs_to_Wn_ixyz
+
+ @property
+ def phaseAngles_Gs_to_Wn_ixyz(self) -> np.ndarray:
+ return self._phaseAngles_Gs_to_Wn_ixyz
+
+ @property
+ def rotationPointOffset_Gs_Ler(self) -> np.ndarray:
+ return self._rotationPointOffset_Gs_Ler
+
+ # --- Immutable derived: manual lazy caching ---
+ @property
+ def all_periods(self) -> tuple[float, ...]:
+ """All unique non zero periods of motion from this Wing and each
+ WingCrossSection's movement class.
+
+ :return: A tuple of all unique non zero periods in seconds. If all motion is
+ static, this will be an empty tuple.
+ """
+ if self._all_periods is None:
+ periods: list[float] = []
+
+ # Collect all periods from WingCrossSectionMovements.
+ for wing_cross_section_movement in self._wing_cross_section_movements:
+ periods.extend(wing_cross_section_movement.all_periods)
+
+ # Collect all periods from CoreWingMovement's own motion.
+ for period in self._periodLer_Gs_Cgs:
+ if period > 0.0:
+ periods.append(float(period))
+ for period in self._periodAngles_Gs_to_Wn_ixyz:
+ if period > 0.0:
+ periods.append(float(period))
+
+ self._all_periods = tuple(periods)
+ return self._all_periods
+
+ @property
+ def max_period(self) -> float:
+ """The longest period of motion across this Wing and each WingCrossSection's
+ movement class.
+
+ :return: The longest period in seconds. If all the motion is static, this will
+ be 0.0.
+ """
+ if self._max_period is None:
+ wing_cross_section_movement_max_periods = []
+ for wing_cross_section_movement in self._wing_cross_section_movements:
+ wing_cross_section_movement_max_periods.append(
+ wing_cross_section_movement.max_period
+ )
+ max_wing_cross_section_movement_period = max(
+ wing_cross_section_movement_max_periods
+ )
+
+ self._max_period = float(
+ max(
+ max_wing_cross_section_movement_period,
+ np.max(self._periodLer_Gs_Cgs),
+ np.max(self._periodAngles_Gs_to_Wn_ixyz),
+ )
+ )
+ return self._max_period
+
+ # --- Other methods ---
+ def generate_wing_at_time_step(
+ self, step: int, delta_time: float | int
+ ) -> geometry.wing.Wing:
+ """Creates the Wing at a single time step.
+
+ :param step: The time step index. Must be a non negative int.
+ :param delta_time: The time between each time step in seconds. Must be a
+ positive number (int or float).
+ :return: The Wing at this time step.
+ """
+ time = step * delta_time
+
+ # Evaluate the oscillating value for each dimension of Ler_Gs_Cgs.
+ thisLer_Gs_Cgs = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingLer_Gs_Cgs[dim]
+ this_amp = self._ampLer_Gs_Cgs[dim]
+ this_period = self._periodLer_Gs_Cgs[dim]
+ this_phase = self._phaseLer_Gs_Cgs[dim]
+ this_base = self._base_wing.Ler_Gs_Cgs[dim]
+
+ if this_spacing == "sine":
+ thisLer_Gs_Cgs[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ thisLer_Gs_Cgs[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ thisLer_Gs_Cgs[dim] = _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ # Evaluate the oscillating value for each dimension of
+ # angles_Gs_to_Wn_ixyz.
+ theseAngles_Gs_to_Wn_ixyz = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingAngles_Gs_to_Wn_ixyz[dim]
+ this_amp = self._ampAngles_Gs_to_Wn_ixyz[dim]
+ this_period = self._periodAngles_Gs_to_Wn_ixyz[dim]
+ this_phase = self._phaseAngles_Gs_to_Wn_ixyz[dim]
+ this_base = self._base_wing.angles_Gs_to_Wn_ixyz[dim]
+
+ if this_spacing == "sine":
+ theseAngles_Gs_to_Wn_ixyz[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ theseAngles_Gs_to_Wn_ixyz[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ theseAngles_Gs_to_Wn_ixyz[dim] = (
+ _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ # Generate the WingCrossSections for this time step.
+ these_wing_cross_sections = []
+ for wing_cross_section_movement in self._wing_cross_section_movements:
+ these_wing_cross_sections.append(
+ wing_cross_section_movement.generate_wing_cross_section_at_time_step(
+ step, delta_time
+ )
+ )
+
+ # If there is a non zero rotation point offset, adjust the position
+ # to account for rotation about the offset point instead of the
+ # leading edge root.
+ if not np.allclose(self._rotationPointOffset_Gs_Ler, np.zeros(3, dtype=float)):
+ # TODO: Refactor this procedure for producing offset rotations
+ # to be a function in _transformations.py.
+ # Get the active rotation matrix for this step's angles.
+ rot_T_act = _transformations.generate_rot_T(
+ theseAngles_Gs_to_Wn_ixyz,
+ passive=False,
+ intrinsic=True,
+ order="xyz",
+ )
+ rot_R_act = rot_T_act[:3, :3]
+
+ # Compute the position adjustment due to the offset rotation
+ # point.
+ offsetRotationPointAdjustment_Gs = (
+ np.eye(3, dtype=float) - rot_R_act
+ ) @ self._rotationPointOffset_Gs_Ler
+
+ # Apply the position adjustment to the leading edge root.
+ thisLer_Gs_Cgs = thisLer_Gs_Cgs + offsetRotationPointAdjustment_Gs
+
+ return geometry.wing.Wing(
+ wing_cross_sections=these_wing_cross_sections,
+ name=self._base_wing.name,
+ Ler_Gs_Cgs=thisLer_Gs_Cgs,
+ angles_Gs_to_Wn_ixyz=theseAngles_Gs_to_Wn_ixyz,
+ symmetric=self._base_wing.symmetric,
+ mirror_only=self._base_wing.mirror_only,
+ symmetryNormal_G=self._base_wing.symmetryNormal_G,
+ symmetryPoint_G_Cg=self._base_wing.symmetryPoint_G_Cg,
+ num_chordwise_panels=self._base_wing.num_chordwise_panels,
+ chordwise_spacing=self._base_wing.chordwise_spacing,
+ )
+
+ def generate_wings(
+ self, num_steps: int, delta_time: float | int
+ ) -> list[geometry.wing.Wing]:
+ """Creates the Wing at each time step, and returns them in a list.
+
+ :param num_steps: The number of time steps in this movement. It must be a
+ positive int.
+ :param delta_time: The time between each time step. It must be a positive number
+ (int or float), and will be converted internally to a float. The units are
+ in seconds.
+ :return: The list of Wings associated with this CoreWingMovement.
+ """
+ num_steps = _parameter_validation.int_in_range_return_int(
+ num_steps,
+ "num_steps",
+ min_val=1,
+ min_inclusive=True,
+ )
+ delta_time = _parameter_validation.number_in_range_return_float(
+ delta_time, "delta_time", min_val=0.0, min_inclusive=False
+ )
+
+ wings = []
+ for step in range(num_steps):
+ wings.append(self.generate_wing_at_time_step(step, delta_time))
+
+ return wings
+
+
+class CoreAirplaneMovement:
+ """A core class used to contain the shared foundation of AirplaneMovement and its
+ feature variant siblings.
+
+ See AirplaneMovement for full documentation of the shared interface.
+
+ CoreAirplaneMovement holds the base Airplane, WingMovements, and oscillation
+ parameters, and provides generate_airplane_at_time_step() for creating Airplanes one
+ step at a time.
+ """
+
+ __slots__ = (
+ "_base_airplane",
+ "_wing_movements",
+ "_ampCg_GP1_CgP1",
+ "_periodCg_GP1_CgP1",
+ "_spacingCg_GP1_CgP1",
+ "_phaseCg_GP1_CgP1",
+ "_all_periods",
+ "_max_period",
+ )
+
+ def __init__(
+ self,
+ base_airplane: geometry.airplane.Airplane,
+ wing_movements: Sequence[CoreWingMovement],
+ ampCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ spacingCg_GP1_CgP1: np.ndarray | Sequence[str | Callable[[float], float]] = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ ) -> None:
+ """The initialization method.
+
+ See AirplaneMovement's initialization method for full parameter descriptions.
+
+ :param base_airplane: The base Airplane.
+ :param wing_movements: The CoreWingMovements for each Wing.
+ :param ampCg_GP1_CgP1: The amplitudes of Cg_GP1_CgP1 oscillation in meters.
+ :param periodCg_GP1_CgP1: The periods of Cg_GP1_CgP1 oscillation in seconds.
+ :param spacingCg_GP1_CgP1: The spacing types for Cg_GP1_CgP1 oscillation.
+ :param phaseCg_GP1_CgP1: The phase offsets of Cg_GP1_CgP1 oscillation in
+ degrees.
+ :return: None
+ """
+ # Validate and store immutable attributes. Set those that are numpy
+ # arrays to be read only.
+ if not isinstance(base_airplane, geometry.airplane.Airplane):
+ raise TypeError("base_airplane must be an Airplane.")
+ self._base_airplane = base_airplane
+
+ if not isinstance(wing_movements, list):
+ raise TypeError("wing_movements must be a list.")
+ if len(wing_movements) != len(self._base_airplane.wings):
+ raise ValueError(
+ "wing_movements must have the same length as " "base_airplane.wings."
+ )
+ for wing_movement in wing_movements:
+ if not isinstance(wing_movement, CoreWingMovement):
+ raise TypeError(
+ "Every element in wing_movements must be a " "CoreWingMovement."
+ )
+ # Store as tuple to prevent external mutation.
+ self._wing_movements: tuple[CoreWingMovement, ...] = tuple(wing_movements)
+
+ ampCg_GP1_CgP1 = _parameter_validation.threeD_number_vectorLike_return_float(
+ ampCg_GP1_CgP1, "ampCg_GP1_CgP1"
+ )
+ if not np.all(ampCg_GP1_CgP1 >= 0.0):
+ raise ValueError("All elements in ampCg_GP1_CgP1 must be non negative.")
+ self._ampCg_GP1_CgP1 = ampCg_GP1_CgP1
+ self._ampCg_GP1_CgP1.flags.writeable = False
+
+ periodCg_GP1_CgP1 = _parameter_validation.threeD_number_vectorLike_return_float(
+ periodCg_GP1_CgP1, "periodCg_GP1_CgP1"
+ )
+ if not np.all(periodCg_GP1_CgP1 >= 0.0):
+ raise ValueError("All elements in periodCg_GP1_CgP1 must be non negative.")
+ for period_index, period in enumerate(periodCg_GP1_CgP1):
+ amp = self._ampCg_GP1_CgP1[period_index]
+ if amp == 0 and period != 0:
+ raise ValueError(
+ "If an element in ampCg_GP1_CgP1 is 0.0, the "
+ "corresponding element in periodCg_GP1_CgP1 must "
+ "be also be 0.0."
+ )
+ self._periodCg_GP1_CgP1 = periodCg_GP1_CgP1
+ self._periodCg_GP1_CgP1.flags.writeable = False
+
+ # Store as tuple to prevent external mutation.
+ self._spacingCg_GP1_CgP1 = (
+ _parameter_validation.threeD_spacing_vectorLike_return_tuple(
+ spacingCg_GP1_CgP1, "spacingCg_GP1_CgP1"
+ )
+ )
+
+ phaseCg_GP1_CgP1 = _parameter_validation.threeD_number_vectorLike_return_float(
+ phaseCg_GP1_CgP1, "phaseCg_GP1_CgP1"
+ )
+ if not (
+ np.all(phaseCg_GP1_CgP1 > -180.0) and np.all(phaseCg_GP1_CgP1 <= 180.0)
+ ):
+ raise ValueError(
+ "All elements in phaseCg_GP1_CgP1 must be in the range "
+ "(-180.0, 180.0]."
+ )
+ for phase_index, phase in enumerate(phaseCg_GP1_CgP1):
+ amp = self._ampCg_GP1_CgP1[phase_index]
+ if amp == 0 and phase != 0:
+ raise ValueError(
+ "If an element in ampCg_GP1_CgP1 is 0.0, the "
+ "corresponding element in phaseCg_GP1_CgP1 must "
+ "be also be 0.0."
+ )
+ self._phaseCg_GP1_CgP1 = phaseCg_GP1_CgP1
+ self._phaseCg_GP1_CgP1.flags.writeable = False
+
+ # Initialize the caches for the properties derived from the immutable
+ # attributes.
+ self._all_periods: tuple[float, ...] | None = None
+ self._max_period: float | None = None
+
+ # --- Deep copy method ---
+ def __deepcopy__(self, memo: dict) -> CoreAirplaneMovement:
+ """Creates a deep copy of this CoreAirplaneMovement.
+
+ See AirplaneMovement for full documentation.
+
+ :param memo: A dict used by the copy module to track already copied objects and
+ avoid infinite recursion.
+ :return: A new instance with copied attributes.
+ """
+ # Create a new instance without calling __init__ to avoid redundant
+ # validation. Use type(self) so subclasses get the correct type.
+ new_movement = object.__new__(type(self))
+
+ # Store this instance in memo to handle potential circular references.
+ memo[id(self)] = new_movement
+
+ # Deep copy the base Airplane to ensure independence (immutable).
+ new_movement._base_airplane = copy.deepcopy(self._base_airplane, memo)
+
+ # Deep copy WingMovements and store as tuple.
+ new_movement._wing_movements = tuple(
+ copy.deepcopy(wing_movement, memo) for wing_movement in self._wing_movements
+ )
+
+ # Copy numpy arrays and make them read only.
+ new_movement._ampCg_GP1_CgP1 = self._ampCg_GP1_CgP1.copy()
+ new_movement._ampCg_GP1_CgP1.flags.writeable = False
+
+ new_movement._periodCg_GP1_CgP1 = self._periodCg_GP1_CgP1.copy()
+ new_movement._periodCg_GP1_CgP1.flags.writeable = False
+
+ new_movement._phaseCg_GP1_CgP1 = self._phaseCg_GP1_CgP1.copy()
+ new_movement._phaseCg_GP1_CgP1.flags.writeable = False
+
+ # Copy tuple directly (it is immutable).
+ new_movement._spacingCg_GP1_CgP1 = self._spacingCg_GP1_CgP1
+
+ # Initialize cache variables to None (caches will be recomputed on
+ # access).
+ new_movement._all_periods = None
+ new_movement._max_period = None
+
+ return new_movement
+
+ # --- Immutable: read only properties ---
+ @property
+ def base_airplane(self) -> geometry.airplane.Airplane:
+ return self._base_airplane
+
+ @property
+ def wing_movements(self) -> tuple[CoreWingMovement, ...]:
+ return self._wing_movements
+
+ @property
+ def ampCg_GP1_CgP1(self) -> np.ndarray:
+ return self._ampCg_GP1_CgP1
+
+ @property
+ def periodCg_GP1_CgP1(self) -> np.ndarray:
+ return self._periodCg_GP1_CgP1
+
+ @property
+ def spacingCg_GP1_CgP1(
+ self,
+ ) -> tuple[str | Callable[[float], float], ...]:
+ return self._spacingCg_GP1_CgP1
+
+ @property
+ def phaseCg_GP1_CgP1(self) -> np.ndarray:
+ return self._phaseCg_GP1_CgP1
+
+ # --- Immutable derived: manual lazy caching ---
+ @property
+ def all_periods(self) -> tuple[float, ...]:
+ """All unique non zero periods of motion from this Airplane, each Wing's
+ movement class, and each WingCrossSection's movement class.
+
+ :return: A tuple of all unique non zero periods in seconds. If all motion is
+ static, this will be an empty tuple.
+ """
+ if self._all_periods is None:
+ periods: list[float] = []
+
+ # Collect all periods from WingMovement(s).
+ for wing_movement in self._wing_movements:
+ periods.extend(wing_movement.all_periods)
+
+ # Collect all periods from CoreAirplaneMovement's own motion.
+ for period in self._periodCg_GP1_CgP1:
+ if period > 0.0:
+ periods.append(float(period))
+
+ self._all_periods = tuple(periods)
+ return self._all_periods
+
+ @property
+ def max_period(self) -> float:
+ """The longest period of motion across this Airplane, each Wing's movement
+ class, and each WingCrossSection's movement class.
+
+ :return: The longest period in seconds. If all the motion is static, this will
+ be 0.0.
+ """
+ if self._max_period is None:
+ wing_movement_max_periods = []
+ for wing_movement in self._wing_movements:
+ wing_movement_max_periods.append(wing_movement.max_period)
+ max_wing_movement_period = max(wing_movement_max_periods)
+
+ self._max_period = float(
+ max(
+ max_wing_movement_period,
+ np.max(self._periodCg_GP1_CgP1),
+ )
+ )
+ return self._max_period
+
+ # --- Other methods ---
+ def generate_airplane_at_time_step(
+ self, step: int, delta_time: float | int
+ ) -> geometry.airplane.Airplane:
+ """Creates the Airplane at a single time step.
+
+ :param step: The time step index. Must be a non negative int.
+ :param delta_time: The time between each time step in seconds. Must be a
+ positive number (int or float).
+ :return: The Airplane at this time step.
+ """
+ time = step * delta_time
+
+ # Evaluate the oscillating value for each dimension of Cg_GP1_CgP1.
+ thisCg_GP1_CgP1 = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingCg_GP1_CgP1[dim]
+ this_amp = self._ampCg_GP1_CgP1[dim]
+ this_period = self._periodCg_GP1_CgP1[dim]
+ this_phase = self._phaseCg_GP1_CgP1[dim]
+ this_base = self._base_airplane.Cg_GP1_CgP1[dim]
+
+ if this_spacing == "sine":
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ # Generate the Wings for this time step.
+ these_wings = []
+ for wing_movement in self._wing_movements:
+ these_wings.append(
+ wing_movement.generate_wing_at_time_step(step, delta_time)
+ )
+
+ return geometry.airplane.Airplane(
+ wings=these_wings,
+ name=self._base_airplane.name,
+ Cg_GP1_CgP1=thisCg_GP1_CgP1,
+ weight=self._base_airplane.weight,
+ )
+
+ def generate_airplanes(
+ self, num_steps: int, delta_time: float | int
+ ) -> list[geometry.airplane.Airplane]:
+ """Creates the Airplane at each time step, and returns them in a list.
+
+ For static geometry (no periodic motion), this method optimizes performance by
+ creating the first Airplane with full mesh generation, then using deepcopy for
+ subsequent time steps. This avoids redundant mesh generation when the geometry
+ is identical across all steps.
+
+ :param num_steps: The number of time steps in this movement. It must be a
+ positive int.
+ :param delta_time: The time between each time step. It must be a positive number
+ (float or int), and will be converted internally to a float. The units are
+ in seconds.
+ :return: The list of Airplanes associated with this CoreAirplaneMovement.
+ """
+ num_steps = _parameter_validation.int_in_range_return_int(
+ num_steps,
+ "num_steps",
+ min_val=1,
+ min_inclusive=True,
+ )
+ delta_time = _parameter_validation.number_in_range_return_float(
+ delta_time, "delta_time", min_val=0.0, min_inclusive=False
+ )
+
+ # Check if geometry is static (no periodic motion).
+ is_static_geometry = self.max_period == 0.0
+
+ if is_static_geometry:
+ # Optimization for static geometry: create first Airplane with full mesh
+ # generation, then deepcopy for subsequent steps.
+ return self._generate_airplanes_static(num_steps, delta_time)
+ else:
+ # For variable geometry, use the standard approach.
+ return self._generate_airplanes_variable(num_steps, delta_time)
+
+ def _generate_airplanes_static(
+ self, num_steps: int, delta_time: float
+ ) -> list[geometry.airplane.Airplane]:
+ """Generates Airplanes for static geometry using deepcopy optimization.
+
+ Creates the first Airplane with full mesh generation, then uses deepcopy for
+ subsequent time steps to avoid redundant mesh generation.
+
+ :param num_steps: The number of time steps.
+ :param delta_time: The time between each time step in seconds.
+ :return: The list of Airplanes.
+ """
+ # Create the first Airplane (triggers full mesh generation).
+ first_airplane = self.generate_airplane_at_time_step(0, delta_time)
+
+ # Create list with first Airplane.
+ airplanes = [first_airplane]
+
+ # Create copies for remaining steps with different Cg_GP1_CgP1 positions.
+ for step in range(1, num_steps):
+ time = step * delta_time
+
+ # Evaluate Cg_GP1_CgP1 at this step.
+ thisCg_GP1_CgP1 = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingCg_GP1_CgP1[dim]
+ this_amp = self._ampCg_GP1_CgP1[dim]
+ this_period = self._periodCg_GP1_CgP1[dim]
+ this_phase = self._phaseCg_GP1_CgP1[dim]
+ this_base = self._base_airplane.Cg_GP1_CgP1[dim]
+
+ if this_spacing == "sine":
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ copied_airplane = first_airplane.deep_copy_with_Cg_GP1_CgP1(thisCg_GP1_CgP1)
+ airplanes.append(copied_airplane)
+
+ return airplanes
+
+ def _generate_airplanes_variable(
+ self, num_steps: int, delta_time: float
+ ) -> list[geometry.airplane.Airplane]:
+ """Generates Airplanes for variable (periodic) geometry.
+
+ Uses a conservative optimization approach that validates periodicity before
+ applying deepcopy optimization. If validation fails, falls back to standard
+ generation.
+
+ :param num_steps: The number of time steps.
+ :param delta_time: The time between each time step in seconds.
+ :return: The list of Airplanes.
+ """
+ # Step 1: Calculate geometry LCM period.
+ geometry_lcm = self._geometry_lcm_period()
+
+ # Step 2: Pre-validation checks.
+ # Check if period aligns cleanly with delta_time.
+ steps_per_period_float = geometry_lcm / delta_time
+ steps_per_period = int(round(steps_per_period_float))
+
+ alignment_error = abs(steps_per_period_float - steps_per_period)
+ if alignment_error > 1e-6:
+ # Period doesn't align with time steps. Fall back to standard generation.
+ return self._generate_airplanes_variable_standard(num_steps, delta_time)
+
+ # Check if there's meaningful benefit.
+ if num_steps <= steps_per_period:
+ # No repetition occurs. No benefit from optimization.
+ return self._generate_airplanes_variable_standard(num_steps, delta_time)
+
+ # Step 3: Generate first period + one validation step.
+ validation_num_steps = steps_per_period + 1
+
+ # Create an empty 2D ndarray to hold Wings for validation steps.
+ wings = np.empty(
+ (len(self._wing_movements), validation_num_steps), dtype=object
+ )
+
+ # Iterate through the CoreWingMovements.
+ for wing_movement_id, wing_movement in enumerate(self._wing_movements):
+ this_wings_list_of_wings = np.array(
+ wing_movement.generate_wings(
+ num_steps=validation_num_steps, delta_time=delta_time
+ )
+ )
+ wings[wing_movement_id, :] = this_wings_list_of_wings
+
+ # Step 4: Validate periodicity.
+ # Compare step 0 geometry to step steps_per_period.
+ if not self._geometry_matches(
+ wings_step_a=wings[:, 0],
+ wings_step_b=wings[:, steps_per_period],
+ tolerance=1e-9,
+ ):
+ # Geometry doesn't repeat as expected. Fall back to standard generation.
+ return self._generate_airplanes_variable_standard(num_steps, delta_time)
+
+ # Step 5: Create Airplanes for first period.
+ first_period_airplanes = []
+ for step in range(steps_per_period):
+ this_airplane = self.generate_airplane_at_time_step(step, delta_time)
+ first_period_airplanes.append(this_airplane)
+
+ # Step 6: Create copies for remaining steps with different Cg_GP1_CgP1.
+ airplanes = list(first_period_airplanes)
+ for step in range(steps_per_period, num_steps):
+ source_step = step % steps_per_period
+
+ # Evaluate Cg_GP1_CgP1 at this step.
+ time = step * delta_time
+ thisCg_GP1_CgP1 = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingCg_GP1_CgP1[dim]
+ this_amp = self._ampCg_GP1_CgP1[dim]
+ this_period = self._periodCg_GP1_CgP1[dim]
+ this_phase = self._phaseCg_GP1_CgP1[dim]
+ this_base = self._base_airplane.Cg_GP1_CgP1[dim]
+
+ if this_spacing == "sine":
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ copied_airplane = first_period_airplanes[
+ source_step
+ ].deep_copy_with_Cg_GP1_CgP1(thisCg_GP1_CgP1)
+ airplanes.append(copied_airplane)
+
+ return airplanes
+
+ def _generate_airplanes_variable_standard(
+ self, num_steps: int, delta_time: float
+ ) -> list[geometry.airplane.Airplane]:
+ """Generates Airplanes for variable geometry using standard approach.
+
+ Creates new Airplanes for each time step without optimization.
+
+ :param num_steps: The number of time steps.
+ :param delta_time: The time between each time step in seconds.
+ :return: The list of Airplanes.
+ """
+ airplanes = []
+ for step in range(num_steps):
+ airplanes.append(self.generate_airplane_at_time_step(step, delta_time))
+
+ return airplanes
+
+ def _geometry_lcm_period(self) -> float:
+ """Calculates the LCM of all geometry related periods.
+
+ Excludes OperatingPoint periods since those don't affect geometry. Uses the
+ all_periods property which already collects only geometry related periods.
+
+ :return: The LCM of all geometry related periods in seconds. Returns 0.0 if all
+ geometry is static.
+ """
+ return lcm_multiple(list(self.all_periods))
+
+ @staticmethod
+ def _geometry_matches(
+ wings_step_a: np.ndarray,
+ wings_step_b: np.ndarray,
+ tolerance: float = 1e-9,
+ ) -> bool:
+ """Compares two sets of Wings to verify their geometry matches within tolerance.
+
+ Checks Wing position (Ler_Gs_Cgs), Wing angles (angles_Gs_to_Wn_ixyz), and Panel
+ corner positions (Frpp_G_Cg, Flpp_G_Cg, Blpp_G_Cg, Brpp_G_Cg).
+
+ :param wings_step_a: A (num_wings,) ndarray of Wings from the first time step.
+ :param wings_step_b: A (num_wings,) ndarray of Wings from the second time step.
+ :param tolerance: The tolerance for floating point comparison. The default is
+ 1e-9.
+ :return: True if all geometry attributes match within tolerance, False
+ otherwise.
+ """
+ if len(wings_step_a) != len(wings_step_b):
+ return False
+
+ for wing_a, wing_b in zip(wings_step_a, wings_step_b):
+ # Check Wing position.
+ if not np.allclose(
+ wing_a.Ler_Gs_Cgs, wing_b.Ler_Gs_Cgs, atol=tolerance, rtol=0.0
+ ):
+ return False
+
+ # Check Wing angles.
+ if not np.allclose(
+ wing_a.angles_Gs_to_Wn_ixyz,
+ wing_b.angles_Gs_to_Wn_ixyz,
+ atol=tolerance,
+ rtol=0.0,
+ ):
+ return False
+
+ # Check Panel corner positions if Wings are meshed.
+ if wing_a.panels is not None and wing_b.panels is not None:
+ if wing_a.panels.shape != wing_b.panels.shape:
+ return False
+
+ for i in range(wing_a.panels.shape[0]):
+ for j in range(wing_a.panels.shape[1]):
+ panel_a = wing_a.panels[i, j]
+ panel_b = wing_b.panels[i, j]
+
+ # Check all four corner positions.
+ if not np.allclose(
+ panel_a.Frpp_G_Cg,
+ panel_b.Frpp_G_Cg,
+ atol=tolerance,
+ rtol=0.0,
+ ):
+ return False
+ if not np.allclose(
+ panel_a.Flpp_G_Cg,
+ panel_b.Flpp_G_Cg,
+ atol=tolerance,
+ rtol=0.0,
+ ):
+ return False
+ if not np.allclose(
+ panel_a.Blpp_G_Cg,
+ panel_b.Blpp_G_Cg,
+ atol=tolerance,
+ rtol=0.0,
+ ):
+ return False
+ if not np.allclose(
+ panel_a.Brpp_G_Cg,
+ panel_b.Brpp_G_Cg,
+ atol=tolerance,
+ rtol=0.0,
+ ):
+ return False
+
+ return True
+
+
+class CoreMovement:
+ """A core class used to contain the shared foundation of Movement and its feature
+ variant siblings.
+
+ See Movement for full documentation of the shared interface.
+
+ CoreMovement holds the fundamental parameters and shared derived properties that all
+ Movement variants need. Unlike Movement, CoreMovement requires delta_time and
+ num_steps to be provided directly and does not perform automatic estimation or batch
+ generation.
+ """
+
+ __slots__ = (
+ "_airplane_movements",
+ "_operating_point_movement",
+ "_delta_time",
+ "_num_steps",
+ "_max_wake_rows",
+ "_lcm_period",
+ "_max_period",
+ "_min_period",
+ "_static",
+ )
+
+ def __init__(
+ self,
+ airplane_movements: Sequence[CoreAirplaneMovement],
+ operating_point_movement: CoreOperatingPointMovement,
+ delta_time: float | int,
+ num_steps: int,
+ max_wake_rows: int | None = None,
+ ) -> None:
+ """The initialization method.
+
+ See Movement's initialization method for full parameter descriptions.
+
+ :param airplane_movements: The CoreAirplaneMovements for each Airplane.
+ :param operating_point_movement: The CoreOperatingPointMovement for operating
+ conditions.
+ :param delta_time: The time step size in seconds. Must be positive.
+ :param num_steps: The number of time steps. Must be a positive int.
+ :param max_wake_rows: The maximum chordwise wake rows per Wing. The default is
+ None (no truncation).
+ :return: None
+ """
+ # Validate and store the CoreAirplaneMovements.
+ if not isinstance(airplane_movements, list):
+ raise TypeError("airplane_movements must be a list.")
+ if len(airplane_movements) < 1:
+ raise ValueError("airplane_movements must have at least one element.")
+ for airplane_movement in airplane_movements:
+ if not isinstance(airplane_movement, CoreAirplaneMovement):
+ raise TypeError(
+ "Every element in airplane_movements must be a "
+ "CoreAirplaneMovement."
+ )
+ # Store as tuple to prevent external mutation.
+ self._airplane_movements: tuple[CoreAirplaneMovement, ...] = tuple(
+ airplane_movements
+ )
+
+ # Validate and store the CoreOperatingPointMovement.
+ if not isinstance(
+ operating_point_movement,
+ CoreOperatingPointMovement,
+ ):
+ raise TypeError(
+ "operating_point_movement must be a " "CoreOperatingPointMovement."
+ )
+ self._operating_point_movement = operating_point_movement
+
+ # Initialize the caches for the properties derived from the immutable
+ # attributes. These are initialized early because static is accessed
+ # below during __init__ to validate max_wake_rows.
+ self._lcm_period: float | None = None
+ self._max_period: float | None = None
+ self._min_period: float | None = None
+ self._static: bool | None = None
+
+ # Validate and store delta_time.
+ delta_time = _parameter_validation.number_in_range_return_float(
+ delta_time, "delta_time", min_val=0.0, min_inclusive=False
+ )
+ self._delta_time: float = delta_time
+
+ # Validate and store num_steps.
+ num_steps = _parameter_validation.int_in_range_return_int(
+ num_steps,
+ "num_steps",
+ min_val=1,
+ min_inclusive=True,
+ )
+ self._num_steps: int = num_steps
+
+ # Validate and store max_wake_rows.
+ if max_wake_rows is not None:
+ max_wake_rows = _parameter_validation.int_in_range_return_int(
+ max_wake_rows,
+ "max_wake_rows",
+ min_val=1,
+ min_inclusive=True,
+ )
+ self._max_wake_rows = max_wake_rows
+
+ # --- Immutable: read only properties ---
+ @property
+ def airplane_movements(
+ self,
+ ) -> tuple[CoreAirplaneMovement, ...]:
+ return self._airplane_movements
+
+ @property
+ def operating_point_movement(
+ self,
+ ) -> CoreOperatingPointMovement:
+ return self._operating_point_movement
+
+ @property
+ def delta_time(self) -> float:
+ return self._delta_time
+
+ @property
+ def num_steps(self) -> int:
+ return self._num_steps
+
+ @property
+ def max_wake_rows(self) -> int | None:
+ return self._max_wake_rows
+
+ # --- Immutable derived: manual lazy caching ---
+ @property
+ def lcm_period(self) -> float:
+ """The least common multiple of all motion periods, ensuring all motions
+ complete an integer number of cycles when cycle averaging forces and moments.
+
+ Using the LCM ensures that when cycle averaging forces and moments, we capture a
+ complete cycle of all motions, not just the longest one. For example, if one
+ motion has a period of 2.0 s and another has a period of 3.0 s, the LCM is 6.0,
+ which contains exactly 3 cycles of the first motion and 2 cycles of the second.
+
+ :return: The LCM period in seconds. If all the motion is static, this will be
+ 0.0.
+ """
+ if self._lcm_period is None:
+ # Collect all periods from AirplaneMovements.
+ all_periods: list[float] = []
+ for airplane_movement in self._airplane_movements:
+ all_periods.extend(airplane_movement.all_periods)
+
+ # Add the OperatingPointMovement period.
+ all_periods.append(self._operating_point_movement.max_period)
+
+ self._lcm_period = lcm_multiple(all_periods)
+ return self._lcm_period
+
+ @property
+ def max_period(self) -> float:
+ """The longest period of motion across each Airplane's movement class, each
+ Wing's movement class, each WingCrossSection's movement class, and the
+ OperatingPoint's movement class.
+
+ For cycle averaging calculations, lcm_period should be used instead of
+ max_period to ensure all motions complete an integer number of cycles.
+
+ :return: The longest period in seconds. If all the motion is static, this will
+ be 0.0.
+ """
+ if self._max_period is None:
+ # Iterate through the AirplaneMovements and find the one with the
+ # largest max period.
+ airplane_movement_max_periods = []
+ for airplane_movement in self._airplane_movements:
+ airplane_movement_max_periods.append(airplane_movement.max_period)
+ max_airplane_period = max(airplane_movement_max_periods)
+
+ # The global max period is the maximum of the max
+ # AirplaneMovement period and the OperatingPointMovement max
+ # period.
+ self._max_period = max(
+ max_airplane_period,
+ self._operating_point_movement.max_period,
+ )
+ return self._max_period
+
+ @property
+ def min_period(self) -> float:
+ """The shortest non zero period of motion across each Airplane's movement class,
+ each Wing's movement class, each WingCrossSection's movement class, and the
+ OperatingPoint's movement class.
+
+ :return: The shortest non zero period in seconds. If all the motion is static,
+ this will be 0.0.
+ """
+ if self._min_period is None:
+ # Collect all periods from AirplaneMovements.
+ all_periods: list[float] = []
+ for airplane_movement in self._airplane_movements:
+ all_periods.extend(airplane_movement.all_periods)
+
+ # Add the OperatingPointMovement period.
+ op_period = self._operating_point_movement.max_period
+ if op_period != 0.0:
+ all_periods.append(op_period)
+
+ # Filter out zero periods and find the minimum.
+ non_zero_periods = [p for p in all_periods if p != 0.0]
+ if not non_zero_periods:
+ self._min_period = 0.0
+ else:
+ self._min_period = min(non_zero_periods)
+ return self._min_period
+
+ @property
+ def static(self) -> bool:
+ """Flags if all motion across each Airplane's movement class, each Wing's
+ movement class, each WingCrossSection's movement class, and the OperatingPoint's
+ movement class is static.
+
+ :return: True if all motion is static. False otherwise.
+ """
+ if self._static is None:
+ self._static = self.max_period == 0
+ return self._static
+
+
+class CoreUnsteadyProblem:
+ """A core class used to contain the shared foundation of UnsteadyProblem and its
+ feature variant siblings.
+
+ See UnsteadyProblem for full documentation of the shared interface.
+
+ CoreUnsteadyProblem holds the time stepping parameters, wake truncation setting,
+ result storage mode, and the mutable load result lists that the solver populates.
+ Unlike UnsteadyProblem, it does not take a Movement or pre create SteadyProblems.
+ Feature variants (FreeFlightUnsteadyProblem, AeroelasticUnsteadyProblem) extend this
+ class and provide SteadyProblems dynamically at each time step.
+ """
+
+ __slots__ = (
+ "_only_final_results",
+ "_num_steps",
+ "_delta_time",
+ "_max_wake_rows",
+ "_first_averaging_step",
+ "_first_results_step",
+ "finalForces_W",
+ "finalForceCoefficients_W",
+ "finalMoments_W_CgP1",
+ "finalMomentCoefficients_W_CgP1",
+ "finalMeanForces_W",
+ "finalMeanForceCoefficients_W",
+ "finalMeanMoments_W_CgP1",
+ "finalMeanMomentCoefficients_W_CgP1",
+ "finalRmsForces_W",
+ "finalRmsForceCoefficients_W",
+ "finalRmsMoments_W_CgP1",
+ "finalRmsMomentCoefficients_W_CgP1",
+ )
+
+ def __init__(
+ self,
+ only_final_results: bool | np.bool_,
+ delta_time: float | int,
+ num_steps: int,
+ max_wake_rows: int | None,
+ lcm_period: float | int,
+ ) -> None:
+ """The initialization method.
+
+ See UnsteadyProblem's initialization method for full parameter descriptions.
+
+ :param only_final_results: Determines whether the solver will only calculate
+ loads for the final time step or final cycle.
+ :param delta_time: The time step size in seconds. Must be positive.
+ :param num_steps: The number of time steps. Must be a positive int.
+ :param max_wake_rows: The maximum chordwise wake rows per Wing. None means no
+ truncation.
+ :param lcm_period: The least common multiple of all motion periods in seconds.
+ Used to compute the first averaging step. Must be non negative. A value of
+ 0.0 indicates static motion.
+ :return: None
+ """
+ # Validate and store immutable attributes.
+ self._only_final_results: bool = _parameter_validation.boolLike_return_bool(
+ only_final_results, "only_final_results"
+ )
+
+ self._delta_time: float = _parameter_validation.number_in_range_return_float(
+ delta_time, "delta_time", min_val=0.0, min_inclusive=False
+ )
+
+ self._num_steps: int = _parameter_validation.int_in_range_return_int(
+ num_steps, "num_steps", min_val=1, min_inclusive=True
+ )
+
+ if max_wake_rows is not None:
+ max_wake_rows = _parameter_validation.int_in_range_return_int(
+ max_wake_rows,
+ "max_wake_rows",
+ min_val=1,
+ min_inclusive=True,
+ )
+ self._max_wake_rows: int | None = max_wake_rows
+
+ lcm_period = _parameter_validation.number_in_range_return_float(
+ lcm_period, "lcm_period", min_val=0.0, min_inclusive=True
+ )
+
+ # For CoreUnsteadyProblems with a static CoreMovement, we are typically
+ # interested in the final time step's forces and moments, which, assuming
+ # convergence, will be the most accurate. For CoreUnsteadyProblems with cyclic
+ # movement (e.g., flapping wings), we are typically interested in the forces
+ # and moments averaged over the last cycle simulated. Use the LCM of all motion
+ # periods to ensure we average over a complete cycle of all motions.
+ self._first_averaging_step: int
+ if lcm_period == 0:
+ self._first_averaging_step = self._num_steps - 1
+ else:
+ self._first_averaging_step = max(
+ 0,
+ math.floor(self._num_steps - (lcm_period / self._delta_time)),
+ )
+
+ # If we only want to calculate forces and moments for the final cycle (for
+ # cyclic motion) or for the final time step (for static motion), set the first
+ # step to calculate results to the first averaging step. Otherwise, set it to
+ # zero, which is the first time step.
+ self._first_results_step: int
+ if self._only_final_results:
+ self._first_results_step = self._first_averaging_step
+ else:
+ self._first_results_step = 0
+
+ # Initialize empty lists to hold the final loads and load coefficients each
+ # Airplane experiences. These will only be populated if this
+ # CoreUnsteadyProblem's motion is static. These are mutable and populated by
+ # the solver.
+ self.finalForces_W: list[np.ndarray] = []
+ self.finalForceCoefficients_W: list[np.ndarray] = []
+ self.finalMoments_W_CgP1: list[np.ndarray] = []
+ self.finalMomentCoefficients_W_CgP1: list[np.ndarray] = []
+
+ # Initialize empty lists to hold the final cycle averaged loads and load
+ # coefficients each Airplane experiences. These will only be populated if this
+ # CoreUnsteadyProblem's motion is cyclic. These are mutable and populated by
+ # the solver.
+ self.finalMeanForces_W: list[np.ndarray] = []
+ self.finalMeanForceCoefficients_W: list[np.ndarray] = []
+ self.finalMeanMoments_W_CgP1: list[np.ndarray] = []
+ self.finalMeanMomentCoefficients_W_CgP1: list[np.ndarray] = []
+
+ # Initialize empty lists to hold the final cycle root mean squared loads and
+ # load coefficients each Airplane experiences. These will only be populated for
+ # variable geometry problems. These are mutable and populated by the solver.
+ self.finalRmsForces_W: list[np.ndarray] = []
+ self.finalRmsForceCoefficients_W: list[np.ndarray] = []
+ self.finalRmsMoments_W_CgP1: list[np.ndarray] = []
+ self.finalRmsMomentCoefficients_W_CgP1: list[np.ndarray] = []
+
+ # --- Immutable: read only properties ---
+ @property
+ def only_final_results(self) -> bool:
+ return self._only_final_results
+
+ @property
+ def num_steps(self) -> int:
+ return self._num_steps
+
+ @property
+ def delta_time(self) -> float:
+ return self._delta_time
+
+ @property
+ def first_averaging_step(self) -> int:
+ return self._first_averaging_step
+
+ @property
+ def first_results_step(self) -> int:
+ return self._first_results_step
+
+ @property
+ def max_wake_rows(self) -> int | None:
+ return self._max_wake_rows
diff --git a/pterasoftware/_oscillation.py b/pterasoftware/_oscillation.py
new file mode 100644
index 000000000..340403d39
--- /dev/null
+++ b/pterasoftware/_oscillation.py
@@ -0,0 +1,267 @@
+"""Contains useful functions for the movement classes."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+import numpy as np
+import scipy.signal as sp_sig
+
+
+def oscillating_sin_at_time(
+ amp: float,
+ period: float,
+ phase: float,
+ base: float,
+ time: float,
+) -> float:
+ """Returns the result of a customizable sine function evaluated at a time.
+
+ **Note:**
+
+ This function doesn't perform any input validation on its parameters. The
+ requirements for the parameters must be validated before passing them in.
+
+ :param amp: The amplitude of the sine function. It must be non negative and can have
+ any units as long as they correspond with the units of base.
+ :param period: The period of the sine function. It must be non negative. If 0.0, amp
+ must also be 0.0, and the function will return 0.0. Its units are in seconds.
+ :param phase: The phase offset of the sine function. It must be in the range
+ (-180.0, 180.0]. Positive values correspond to phase lead. If both amp and
+ period are 0.0, phase must also be 0.0. Its units are in degrees.
+ :param base: The mean value about which the sine function oscillates. It can have
+ any units as long as they correspond with the units of amp.
+ :param time: The time at which to evaluate the sine function. It must be non
+ negative. Its units are in seconds.
+ :return: The value of the sine function evaluated at given time. Its units will
+ match those of amp and base.
+ """
+ # Convert the function characteristics into classic wave function constants.
+ a = amp
+ b = 0.0
+ if amp != 0.0:
+ b = 2.0 * np.pi / period
+ h = np.deg2rad(phase)
+ k = base
+
+ return float(a * np.sin(b * time + h) + k)
+
+
+def oscillating_lin_at_time(
+ amp: float,
+ period: float,
+ phase: float,
+ base: float,
+ time: float,
+) -> float:
+ """Returns the result of a customizable triangular wave function evaluated at a
+ time.
+
+ **Note:**
+
+ This function doesn't perform any input validation on its parameters. The
+ requirements for the parameters must be validated before passing them in.
+
+ :param amp: The amplitude of the triangular wave function. It must be non negative,
+ and can have any units as long as they correspond with the units of base.
+ :param period: The period of the triangular wave function. It must be non negative.
+ If 0.0, amp must also be 0.0, and the function will return 0.0. Its units are in
+ seconds.
+ :param phase: The phase offset of the triangular wave function. It must be in the
+ range (-180.0, 180.0]. Positive values correspond to phase lead. If both amp and
+ period are 0.0, phase must also be 0.0. Its units are in degrees.
+ :param base: The mean value about which the triangular wave function oscillates. It
+ can have any units as long as they correspond with the units of amp.
+ :param time: The time at which to evaluate the triangular wave function. It must be
+ non negative. Its units are in seconds.
+ :return: The value of the triangular wave function evaluated at the given time. Its
+ units will match those of amp and base.
+ """
+ # Convert the function characteristics into classic wave function constants.
+ a = amp
+ b = 0.0
+ if amp != 0.0:
+ b = 2.0 * np.pi / period
+ h = (np.pi / 2.0) + np.deg2rad(phase)
+ k = base
+
+ return float(a * sp_sig.sawtooth((b * time + h), 0.5) + k)
+
+
+def oscillating_custom_at_time(
+ amp: float,
+ period: float,
+ phase: float,
+ base: float,
+ time: float,
+ custom_function: Callable[[float], float],
+) -> float:
+ """Returns the result of a custom oscillating function evaluated at a time.
+
+ This function is intended for advanced users. The custom function is validated to
+ ensure it meets requirements, but users should thoroughly test their functions
+ before use in simulations.
+
+ **Note:**
+
+ This function only performs input validation on the custom_function parameter. The
+ requirements for the other parameters must be validated before passing them in.
+
+ **Custom Function Requirements:**
+
+ Must start at 0.0 with f(0.0) = 0.0.
+
+ Must return to 0.0 after one period with f(2.0 * pi) = 0.0.
+
+ Must have amplitude of 1.0, meaning (max - min) / 2.0 = 1.0
+
+ Must be periodic with period 2.0 * pi such that f(x) = f(x + 2.0 * pi)
+
+ Must return finite values only with no NaN or Inf
+
+ Must accept a float as input and return a float
+
+ Functions with non zero mean are allowed but will shift the effective center of
+ oscillation away from the base value. This can be useful for creating asymmetric
+ motion (e.g., faster upstroke than downstroke in flapping).
+
+ **Parameter Interaction:**
+
+ The custom function is transformed by the amps, periods, phases, and bases
+ parameters. The output is calculated as amps * custom_function(2.0 * pi * time /
+ periods + deg2rad(phases)) + bases. The amps parameter scales the vertical amplitude
+ of the custom function. The periods parameter scales the horizontal period of the
+ custom function. The phases parameter shifts the function horizontally in degrees.
+ The bases parameter shifts the function vertically.
+
+ :param amp: The amplitude of the custom function. It must be non negative, and can
+ have any units as long as they correspond with the units of base.
+ :param period: The period of the custom function. It must be non negative. If 0.0,
+ amp must also be 0.0, and the function will return 0.0. Its units are in
+ seconds.
+ :param phase: The phase offset of the custom function. It must be in the range
+ (-180.0, 180.0]. Positive values correspond to phase lead. If both amp and
+ period are 0.0, phase must also be 0.0. Its units are in degrees.
+ :param base: The mean value about which the custom function oscillates. It can have
+ any units as long as they correspond with the units of amp.
+ :param time: The time at which to evaluate the custom function. It must be non
+ negative. Its units are in seconds.
+ :param custom_function: A custom oscillating function that defines the waveform
+ shape. The function must meet all requirements listed above. It must accept a
+ float as input and return a float. The function will be scaled and shifted by
+ the amps, periods, phases, and bases parameters. Example valid functions,
+ assuming numpy is imported as np, include np.sin for a standard sine wave,
+ lambda x: 2.0 * np.sin(x) - np.sin(2.0 * x) for a custom harmonic, or lambda x:
+ np.where(x < np.pi, x / np.pi, 2.0 - x / np.pi) for a triangle wave. Custom
+ functions are validated before use, and if validation fails, a detailed error
+ message will indicate which requirement was not met.
+ :return: The value of the custom function evaluated at the given time. Its units
+ will match those of amp and base.
+ """
+ # Validate the custom function before using it.
+ _validate_custom_spacing_function(custom_function)
+
+ # Convert the function characteristics into classic wave function constants.
+ a = amp
+ b = 0.0
+ if amp != 0.0:
+ b = 2.0 * np.pi / period
+ h = np.deg2rad(phase)
+ k = base
+
+ # Calculate the output or raise an exception if custom_functions throws.
+ try:
+ return float(a * custom_function(b * time + h) + k)
+ except Exception as e: # pragma: no cover
+ raise ValueError(
+ f"Calling your custom_function on the inputs resulted in the following "
+ f"exception:\n{e}"
+ )
+
+
+def _validate_custom_spacing_function(
+ custom_function: Callable[[float], float],
+) -> None:
+ """Validates that a custom spacing function meets requirements for use in
+ oscillating_custom_at_time.
+
+ See the oscillating_custom_at_time docstring for the exact requirements for the
+ custom function.
+
+ :param custom_function: The custom spacing function to validate.
+ :return: None
+ """
+ # Test the function over two full periods. Use an odd number of points so that one
+ # point lies exactly on 2.0 * pi.
+ test_times = np.linspace(0.0, 4.0 * np.pi, 201, dtype=float)
+
+ test_output = np.zeros_like(test_times)
+ try:
+ for this_test_time_id, this_test_time in enumerate(test_times):
+ test_output[this_test_time_id] = custom_function(this_test_time)
+ except Exception as e:
+ raise ValueError(
+ f"The custom spacing function failed when called with test input: {e}."
+ )
+
+ for this_output_id, this_output in enumerate(test_output):
+ if not isinstance(this_output, float):
+ raise ValueError(
+ f"The custom spacing function must return a float for a float input, "
+ f"but it returned {type(this_output)} for the input "
+ f"{test_times[this_output_id]}."
+ )
+
+ # Check for finite values.
+ if not np.isfinite(test_output).all():
+ raise ValueError(
+ "Custom spacing function must return finite values only (no NaN or Inf)."
+ )
+
+ # Extract one period of data for validation (first period).
+ first_period_indices = test_times < 2.0 * np.pi
+ first_period_output = test_output[first_period_indices]
+
+ tolerance = 0.05
+
+ # Check that the function starts at 0.0.
+ start_value = test_output[0]
+ if not np.isclose(start_value, 0.0, atol=tolerance):
+ raise ValueError(
+ f"Custom spacing function must start at 0.0. f(0.0) = {start_value:.4f}, "
+ f"but should be within {tolerance} of 0.0."
+ )
+
+ # Check that the function returns to 0.0 after one period.
+ # Find the index closest to 2.0 * pi.
+ end_period_idx = np.argmin(np.abs(test_times - 2.0 * np.pi))
+ end_value = test_output[end_period_idx]
+ if not np.isclose(end_value, 0.0, atol=tolerance):
+ raise ValueError(
+ f"Custom spacing function must return to 0.0 after one period. "
+ f"f(2.0 * pi) = {end_value:.4f}, but should be within {tolerance} of 0.0."
+ )
+
+ # Check that the amplitude = 1.0.
+ max_value = float(np.max(first_period_output))
+ min_value = float(np.min(first_period_output))
+ amplitude = (max_value - min_value) / 2.0
+ if not np.isclose(amplitude, 1.0, atol=tolerance):
+ raise ValueError(
+ f"Custom spacing function must have an amplitude of 1.0. "
+ f"Amplitude = {amplitude:.4f}, but should be within {tolerance} of 1.0."
+ )
+
+ # Check periodicity by comparing the first and second periods.
+ second_period_indices = (test_times >= 2.0 * np.pi) & (test_times < 4.0 * np.pi)
+ second_period_output = test_output[second_period_indices]
+
+ # They should have the same length if properly sampled.
+ if len(first_period_output) == len(second_period_output):
+ if not np.allclose(first_period_output, second_period_output, atol=tolerance):
+ max_diff = np.max(np.abs(first_period_output - second_period_output))
+ raise ValueError(
+ f"Custom spacing function must be periodic with period 2.0 * pi. "
+ f"Maximum difference between first and second period: {max_diff:.4f}, "
+ f"but should be within {tolerance}."
+ )
diff --git a/pterasoftware/_parameter_validation.py b/pterasoftware/_parameter_validation.py
index d394f45c0..b624d8461 100644
--- a/pterasoftware/_parameter_validation.py
+++ b/pterasoftware/_parameter_validation.py
@@ -383,9 +383,9 @@ def threeD_number_vectorLike_return_float_unit_vector(
def threeD_spacing_vectorLike_return_tuple(value: Any, name: str) -> tuple[
- str | Callable[[np.ndarray], np.ndarray],
- str | Callable[[np.ndarray], np.ndarray],
- str | Callable[[np.ndarray], np.ndarray],
+ str | Callable[[float], float],
+ str | Callable[[float], float],
+ str | Callable[[float], float],
]:
"""Validates a value is a 3D vector-like object (array-like object with shape (3,))
of spacing specifications, and then returns it as a tuple of 3 spacing
@@ -430,9 +430,9 @@ def threeD_spacing_vectorLike_return_tuple(value: Any, name: str) -> tuple[
validated_value = tuple(validated_list)
return cast(
tuple[
- str | Callable[[np.ndarray], np.ndarray],
- str | Callable[[np.ndarray], np.ndarray],
- str | Callable[[np.ndarray], np.ndarray],
+ str | Callable[[float], float],
+ str | Callable[[float], float],
+ str | Callable[[float], float],
],
validated_value,
)
diff --git a/pterasoftware/_serialization.py b/pterasoftware/_serialization.py
index b54474097..cfef06c8d 100644
--- a/pterasoftware/_serialization.py
+++ b/pterasoftware/_serialization.py
@@ -14,6 +14,7 @@
import numpy as np
from . import _logging
+from ._oscillation import oscillating_lin_at_time, oscillating_sin_at_time
# This module is inherently coupled to the internals of every class in the package
# (it reads __slots__, knows class structure, and imports all classes into its
@@ -31,9 +32,6 @@
from .geometry.airplane import Airplane
from .geometry.wing import Wing
from .geometry.wing_cross_section import WingCrossSection
-
-# noinspection PyProtectedMember
-from .movements._functions import oscillating_linspaces, oscillating_sinspaces
from .movements.airplane_movement import AirplaneMovement
from .movements.movement import Movement
from .movements.operating_point_movement import OperatingPointMovement
@@ -53,14 +51,32 @@
# Maps serializable callable names to their function objects and vice versa.
_CALLABLE_NAME_TO_FUNC = {
- "sine": oscillating_sinspaces,
- "uniform": oscillating_linspaces,
+ "sine": oscillating_sin_at_time,
+ "uniform": oscillating_lin_at_time,
}
_CALLABLE_FUNC_TO_NAME = {func: name for name, func in _CALLABLE_NAME_TO_FUNC.items()}
# Increments only when the serialization structure changes (slots added/removed/
# renamed, class registry changed, encoding strategy changed).
-_FORMAT_VERSION = 1
+_FORMAT_VERSION = 2
+
+
+def _all_slots(cls: type) -> list[str]:
+ """Collects all __slots__ from a class and its parents via the MRO.
+
+ Walks the method resolution order so that inherited slots (e.g., those on
+ CoreMovement) are included alongside the class's own slots.
+
+ :param cls: The class to inspect.
+ :return: A list of slot names in MRO order (parent slots first).
+ """
+ slots: list[str] = []
+ for klass in reversed(cls.__mro__):
+ for slot in getattr(klass, "__slots__", ()):
+ if slot not in slots:
+ slots.append(slot)
+ return slots
+
# Default maximum decompressed size in bytes when reading gzip files. Prevents gzip
# bombs from exhausting memory. Users can override this via the max_size parameter on
@@ -359,7 +375,7 @@ def _object_to_dict(
result: dict[str, Any] = {"_type": class_name}
is_unsteady_problem = isinstance(obj, UnsteadyProblem)
- for slot_name in getattr(cls, "__slots__"):
+ for slot_name in _all_slots(cls):
if slot_name in _skip_slots:
result[slot_name] = None
elif is_unsteady_problem and slot_name == "_movement":
@@ -393,7 +409,7 @@ def _object_from_dict(data: dict[str, Any]) -> object:
_logger.debug("Deserializing %s.", type_tag)
obj: object = object.__new__(cls)
- for slot_name in getattr(cls, "__slots__"):
+ for slot_name in _all_slots(cls):
object.__setattr__(obj, slot_name, _deserialize_value(data[slot_name]))
_reconstruct_shared_references(obj)
return obj
diff --git a/pterasoftware/movements/_functions.py b/pterasoftware/movements/_functions.py
deleted file mode 100644
index 807fd7be6..000000000
--- a/pterasoftware/movements/_functions.py
+++ /dev/null
@@ -1,410 +0,0 @@
-"""Contains useful functions for the movement classes."""
-
-from __future__ import annotations
-
-from collections.abc import Callable
-
-import numpy as np
-import scipy.signal as sp_sig
-
-
-def oscillating_sinspaces(
- amps: float | np.ndarray,
- periods: float | np.ndarray,
- phases: float | np.ndarray,
- bases: float | np.ndarray,
- num_steps: int,
- delta_time: float,
-) -> np.ndarray:
- """Returns a (...,num_steps) ndarray of floats calculated by inputting a vector of
- linearly spaced time steps into a sine function defined with the parameters given by
- the floats or ndarrays amp, period, phase, and base.
-
- :param amps: The amplitude(s) of the fluctuation(s). It must be a non negative float
- or a ndarray of non negative floats. If any of its elements are 0.0, then the
- corresponding periods element must also be 0.0, and the corresponding results
- will have no fluctuations. Its units can be anything so long as they correspond
- with the units of base.
- :param periods: The period(s) of the fluctuation(s). It must be a non negative float
- or a ndarray of non negative floats. If any of its elements are 0.0, then the
- corresponding amps element must also be 0.0, and the corresponding results will
- have no fluctuations. If a ndarray, its shape must match that of amps. Its units
- are in seconds.
- :param phases: The phase offset(s) of the fluctuation(s). It must be a float or a
- ndarray of floats in the range (-180.0, 180.0]. Positive values correspond to
- phase lead. If a given result has no fluctuations (corresponding elements in
- amps and periods are 0.0), the corresponding element in phases must be 0.0. If a
- ndarray, its shape must match that of amps. Its units are in degrees.
- :param bases: The mean value(s) about which the fluctuation(s) occurs. It must be a
- float or a ndarray of floats. If a ndarray, its shape must match that of amps.
- Its units can be anything so long as they correspond with the units of amps.
- :param num_steps: The number of time steps to iterate through. It must be a positive
- int.
- :param delta_time: The change in time between each time step. It must be a positive
- float. Its units are in seconds.
- :return: The resulting ndarray of sinusoidally varying values. It will be a ndarray
- of floats with shape (num_steps,) (for scalar parameters) or (S,num_steps) (for
- array-like parameters of shape S). Its units will match those of amp and base.
- """
- amps, periods, phases, bases, num_steps, delta_time, mask_static = (
- _validate_oscillating_function_parameters(
- amps, periods, phases, bases, num_steps, delta_time
- )
- )
-
- total_time = num_steps * delta_time
-
- # Get the time at each time step.
- times = np.linspace(0, total_time, num_steps, endpoint=False)
-
- # Convert the function characteristic ndarrays into ndarrays of classic wave
- # function constants. This also adds a trailing dimension of length 1 to each
- # ndarray, so that broadcasting can occur when calculating the results.
- a = amps[..., None]
-
- b = np.zeros_like(periods, dtype=float)
- b[~mask_static] = 2 * np.pi / periods[~mask_static]
- b = b[..., None]
-
- h = np.deg2rad(phases)[..., None]
- k = bases[..., None]
-
- return np.asarray(a * np.sin(b * times + h) + k)
-
-
-def oscillating_linspaces(
- amps: float | np.ndarray,
- periods: float | np.ndarray,
- phases: float | np.ndarray,
- bases: float | np.ndarray,
- num_steps: int,
- delta_time: float,
-) -> np.ndarray:
- """Returns a (...,num_steps) ndarray of floats calculated by inputting a vector of
- linearly spaced time steps into a triangular wave function defined with the
- parameters given by the floats or ndarrays amp, period, phase, and base.
-
- :param amps: The amplitude(s) of the fluctuation(s). It must be a non negative float
- or a ndarray of non negative floats. If any of its elements are 0.0, then the
- corresponding periods element must also be 0.0, and the corresponding results
- will have no fluctuations. Its units can be anything so long as they correspond
- with the units of base.
- :param periods: The period(s) of the fluctuation(s). It must be a non negative float
- or a ndarray of non negative floats. If any of its elements are 0.0, then the
- corresponding amps element must also be 0.0, and the corresponding results will
- have no fluctuations. If a ndarray, its shape must match that of amps. Its units
- are in seconds.
- :param phases: The phase offset(s) of the fluctuation(s). It must be a float or a
- ndarray of floats in the range (-180.0, 180.0]. Positive values correspond to
- phase lead. If a given result has no fluctuations (corresponding elements in
- amps and periods are 0.0), the corresponding element in phases must be 0.0. If a
- ndarray, its shape must match that of amps. Its units are in degrees.
- :param bases: The mean value(s) about which the fluctuation(s) occurs. It must be a
- float or a ndarray of floats. If a ndarray, its shape must match that of amps.
- Its units can be anything so long as they correspond with the units of amps.
- :param num_steps: The number of time steps to iterate through. It must be a positive
- int.
- :param delta_time: The change in time between each time step. It must be a positive
- float. Its units are in seconds.
- :return: The resulting ndarray of varying values. It will be a ndarray of floats
- with shape (num_steps,) (for scalar parameters) or (S,num_steps) (for ndarray
- parameters of shape S). Its units will match those of amp and base.
- """
- amps, periods, phases, bases, num_steps, delta_time, mask_static = (
- _validate_oscillating_function_parameters(
- amps, periods, phases, bases, num_steps, delta_time
- )
- )
-
- total_time = num_steps * delta_time
-
- # Get the time at each time step.
- times = np.linspace(0, total_time, num_steps, endpoint=False)
-
- # Convert the function characteristic ndarrays into ndarrays of classic wave
- # function constants. This also adds a trailing dimension of length 1 to each
- # ndarray, so that broadcasting can occur when calculating the results.
- a = amps[..., None]
-
- b = np.zeros_like(periods, dtype=float)
- b[~mask_static] = 2 * np.pi / periods[~mask_static]
- b = b[..., None]
-
- h = (np.pi / 2) + np.deg2rad(phases)[..., None]
- k = bases[..., None]
-
- # Calculate and return the values.
- return np.asarray(a * sp_sig.sawtooth((b * times + h), 0.5) + k)
-
-
-def oscillating_customspaces(
- amps: float | np.ndarray,
- periods: float | np.ndarray,
- phases: float | np.ndarray,
- bases: float | np.ndarray,
- num_steps: int,
- delta_time: float,
- custom_function: Callable[[np.ndarray], np.ndarray],
-) -> np.ndarray:
- """Returns a (...,num_steps) ndarray of floats calculated by inputting a vector of
- linearly spaced time steps into a custom oscillating function defined with the
- parameters given by the floats or ndarrays amp, period, phase, and base.
-
- This function is intended for advanced users. The custom function is validated to
- ensure it meets requirements, but users should thoroughly test their functions
- before use in simulations.
-
- **Custom Function Requirements:**
-
- Must start at 0 with f(0) = 0
-
- Must return to 0 after one period with f(2*pi) = 0
-
- Must have amplitude of 1, meaning (max - min) / 2 = 1.0
-
- Must be periodic with period 2*pi such that f(x) = f(x + 2*pi)
-
- Must return finite values only with no NaN or Inf
-
- Must accept a ndarray as input and return a ndarray of the same shape
-
- Functions with non zero mean are allowed but will shift the effective center of
- oscillation away from the base value. This can be useful for creating asymmetric
- motion (e.g., faster upstroke than downstroke in flapping).
-
- **Parameter Interaction:**
-
- The custom function is transformed by the amps, periods, phases, and bases
- parameters. The output is calculated as amps * custom_function(2*pi * time / periods
- + deg2rad(phases)) + bases. The amps parameter scales the vertical amplitude of the
- custom function. The periods parameter scales the horizontal period of the custom
- function. The phases parameter shifts the function horizontally in degrees. The
- bases parameter shifts the function vertically.
-
- :param amps: The amplitude(s) of the fluctuation(s). It must be a non negative float
- or a ndarray of non negative floats. If any of its elements are 0.0, then the
- corresponding periods element must also be 0.0, and the corresponding results
- will have no fluctuations. Its units can be anything so long as they correspond
- with the units of base.
- :param periods: The period(s) of the fluctuation(s). It must be a non negative float
- or a ndarray of non negative floats. If any of its elements are 0.0, then the
- corresponding amps element must also be 0.0, and the corresponding results will
- have no fluctuations. If a ndarray, its shape must match that of amps. Its units
- are in seconds.
- :param phases: The phase offset(s) of the fluctuation(s). It must be a float or a
- ndarray of floats in the range (-180.0, 180.0]. Positive values correspond to
- phase lead. If a given result has no fluctuations (corresponding elements in
- amps and periods are 0.0), the corresponding element in phases must be 0.0. If a
- ndarray, its shape must match that of amps. Its units are in degrees.
- :param bases: The mean value(s) about which the fluctuation(s) occurs. It must be a
- float or a ndarray of floats. If a ndarray, its shape must match that of amps.
- Its units can be anything so long as they correspond with the units of amps.
- :param num_steps: The number of time steps to iterate through. It must be a positive
- int.
- :param delta_time: The change in time between each time step. It must be a positive
- float. Its units are in seconds.
- :param custom_function: A custom oscillating function that defines the waveform
- shape. The function must meet all requirements listed above. It must accept a
- ndarray as input and return a ndarray of the same shape. The function will be
- scaled and shifted by the amps, periods, phases, and bases parameters. Example
- valid functions, assuming numpy is imported as np, include np.sin for a standard
- sine wave, lambda x: 2 * np.sin(x) - np.sin(2 * x) for a custom harmonic, or
- lambda x: np.where(x < np.pi, x / np.pi, 2 - x / np.pi) for a triangle wave.
- Custom functions are validated before use, and if validation fails, a detailed
- error message will indicate which requirement was not met.
- :return: The resulting ndarray of varying values. It will be a ndarray of floats
- with shape (num_steps,) (for scalar parameters) or (S,num_steps) (for ndarray
- parameters of shape S). Its units will match those of amp and base.
- """
- amps, periods, phases, bases, num_steps, delta_time, mask_static = (
- _validate_oscillating_function_parameters(
- amps, periods, phases, bases, num_steps, delta_time
- )
- )
-
- # Validate the custom function before using it.
- _validate_custom_spacing_function(custom_function)
-
- total_time = num_steps * delta_time
-
- # Get the time at each time step.
- times = np.linspace(0, total_time, num_steps, endpoint=False)
-
- # Convert the function characteristic ndarrays into ndarrays of classic wave
- # function constants. This also adds a trailing dimension of length 1 to each
- # ndarray, so that broadcasting can occur when calculating the results.
- a = amps[..., None]
-
- b = np.zeros_like(periods, dtype=float)
- b[~mask_static] = 2 * np.pi / periods[~mask_static]
- b = b[..., None]
-
- h = np.deg2rad(phases)[..., None]
- k = bases[..., None]
-
- # Calculate the output or raise an exception if custom_functions throws.
- try:
- output = np.asarray(a * custom_function(b * times + h) + k)
- except Exception as e: # pragma: no cover
- raise ValueError(
- f"Calling your custom_function on the inputs resulted in the following "
- f"exception:\n{e}"
- )
-
- output_shape = output.shape
- expected_shape = amps.shape + (num_steps,)
-
- if output_shape != expected_shape: # pragma: no cover
- raise ValueError(
- f"Calling custom_function on your inputs resulted in an ndarray of shape "
- f"{output_shape}, but the expected shape is {expected_shape}."
- )
- return output
-
-
-def _validate_oscillating_function_parameters(
- amps: float | np.ndarray,
- periods: float | np.ndarray,
- phases: float | np.ndarray,
- bases: float | np.ndarray,
- num_steps: int,
- delta_time: float,
-) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, int, float, np.ndarray]:
- """Validates and returns the conditioned parameters for the oscillating_* functions.
-
- See their docstrings for details on the requirements for the parameters. It also
- returns the array mask for identifying static cases.
-
- :param amps: The amps parameter to validate.
- :param periods: The periods parameter to validate.
- :param phases: The phases parameter to validate.
- :param bases: The bases parameter to validate.
- :param num_steps: The num_steps parameter to validate.
- :param delta_time: The delta_time parameter to validate.
- :return: A tuple with seven elements. The first six are, in order, the conditioned
- amps, periods, phases, bases, num_steps, and delta_time parameters. The last is
- a ndarray of numpy bools identifying if any of the fluctuations described by the
- previous parameters are static (have zero amplitude). It will have the same
- shape as amps, periods, phases, and bases.
- """
- amps = np.asarray(amps, dtype=float)
- periods = np.asarray(periods, dtype=float)
- phases = np.asarray(phases, dtype=float)
- bases = np.asarray(bases, dtype=float)
-
- expected_shape = amps.shape
- values_to_check = (periods, phases, bases)
- value_names = ("periods", "phases", "bases")
- for value_id in range(len(values_to_check)):
- value = values_to_check[value_id]
- value_name = value_names[value_id]
-
- value_shape = value.shape
- if value_shape != expected_shape:
- raise ValueError(
- f"After conversion to a ndarray, {value_name} must have the same "
- f"shape as amps, which is {expected_shape}, but its shape is "
- f"{value_shape}."
- )
-
- mask_invalid = (amps == 0.0) ^ (periods == 0.0)
- if np.any(mask_invalid):
- raise ValueError(
- "If an element in amps is 0.0, the corresponding element in periods must "
- "also be 0.0."
- )
- mask_static = amps == 0.0
- if np.any(mask_static & (phases != 0.0)):
- raise ValueError(
- "If the elements at a given location in amps and periods are 0.0, "
- "the corresponding element in phases must also be 0.0."
- )
-
- return amps, periods, phases, bases, num_steps, delta_time, mask_static
-
-
-def _validate_custom_spacing_function(
- custom_function: Callable[[np.ndarray], np.ndarray],
-) -> None:
- """Validates that a custom spacing function meets requirements for use in
- oscillating_customspaces.
-
- See the oscillating_customspaces docstring for the exact requirements for a custom
- function.
-
- :param custom_function: The custom spacing function to validate.
- :return: None
- """
- # Test the function over two full periods. Us an odd number of points so that one
- # lies exactly on 2*pi.
- test_input = np.linspace(0, 4 * np.pi, 201)
-
- try:
- test_output = custom_function(test_input)
- except Exception as e:
- raise ValueError(
- f"Custom spacing function failed when called with test input: {e}"
- )
-
- # Convert to ndarray and check shape.
- test_output = np.asarray(test_output)
- if test_output.shape != test_input.shape:
- raise ValueError(
- f"Custom spacing function must return a ndarray of the same shape as its "
- f"input. Input shape: {test_input.shape}, output shape: "
- f"{test_output.shape}."
- )
-
- # Check for finite values.
- if not np.isfinite(test_output).all():
- raise ValueError(
- "Custom spacing function must return finite values only (no NaN or Inf)."
- )
-
- # Extract one period of data for validation (first period).
- first_period_indices = test_input < 2 * np.pi
- first_period_output = test_output[first_period_indices]
-
- tolerance = 0.05
-
- # Check that function starts at 0.
- start_value = test_output[0]
- if not np.isclose(start_value, 0.0, atol=tolerance):
- raise ValueError(
- f"Custom spacing function must start at 0. f(0) = {start_value:.4f}, "
- f"but should be within {tolerance} of 0."
- )
-
- # Check that function returns to 0 after one period.
- # Find the index closest to 2*pi.
- end_period_idx = np.argmin(np.abs(test_input - 2 * np.pi))
- end_value = test_output[end_period_idx]
- if not np.isclose(end_value, 0.0, atol=tolerance):
- raise ValueError(
- f"Custom spacing function must return to 0 after one period. "
- f"f(2*pi) = {end_value:.4f}, but should be within {tolerance} of 0."
- )
-
- # Check amplitude = 1.
- max_value = float(np.max(first_period_output))
- min_value = float(np.min(first_period_output))
- amplitude = (max_value - min_value) / 2.0
- if not np.isclose(amplitude, 1.0, atol=tolerance):
- raise ValueError(
- f"Custom spacing function must have amplitude of 1. "
- f"Amplitude = {amplitude:.4f}, but should be within {tolerance} of 1."
- )
-
- # Check periodicity by comparing first and second periods.
- second_period_indices = (test_input >= 2 * np.pi) & (test_input < 4 * np.pi)
- second_period_output = test_output[second_period_indices]
-
- # They should have the same length if properly sampled.
- if len(first_period_output) == len(second_period_output):
- if not np.allclose(first_period_output, second_period_output, atol=tolerance):
- max_diff = np.max(np.abs(first_period_output - second_period_output))
- raise ValueError(
- f"Custom spacing function must be periodic with period 2*pi. "
- f"Maximum difference between first and second period: {max_diff:.4f}, "
- f"but should be within {tolerance}."
- )
diff --git a/pterasoftware/movements/aeroelastic_airplane_movement.py b/pterasoftware/movements/aeroelastic_airplane_movement.py
new file mode 100644
index 000000000..54f9f799b
--- /dev/null
+++ b/pterasoftware/movements/aeroelastic_airplane_movement.py
@@ -0,0 +1,228 @@
+"""Contains the AeroelasticAirplaneMovement class.
+
+**Contains the following classes:**
+
+AeroelasticAirplaneMovement: A class used to contain an Airplane's movement in an
+aeroelastic simulation.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+from typing import cast
+
+import numpy as np
+
+from .. import _core, _oscillation, geometry
+from . import aeroelastic_wing_movement as aeroelastic_wing_movement_mod
+
+
+class AeroelasticAirplaneMovement(_core.CoreAirplaneMovement):
+ """A class used to contain an Airplane's movement in an aeroelastic simulation.
+
+ In aeroelastic simulations, airplane geometry is prescribed via oscillation
+ parameters (the same oscillation based generation as AirplaneMovement), but the
+ solver adds structural deformation at each time step. This class overrides
+ generate_airplane_at_time_step to accept per Wing deformation that is threaded down
+ to its AeroelasticWingMovement children.
+
+ **Contains the following methods:**
+
+ __deepcopy__: Creates a deep copy of this AeroelasticAirplaneMovement.
+
+ all_periods: All unique non zero periods from this AeroelasticAirplaneMovement, its
+ AeroelasticWingMovement(s), and their AeroelasticWingCrossSectionMovements.
+
+ max_period: The longest period of AeroelasticAirplaneMovement's own motion, the
+ motion(s) of its sub movement object(s), and the motions of its sub sub movement
+ objects.
+
+ generate_airplane_at_time_step: Creates the Airplane at a single time step,
+ optionally applying structural deformation to each Wing.
+
+ generate_airplanes: Creates the Airplane at each time step, and returns them in a
+ list.
+ """
+
+ __slots__ = ()
+
+ def __init__(
+ self,
+ base_airplane: geometry.airplane.Airplane,
+ wing_movements: list[aeroelastic_wing_movement_mod.AeroelasticWingMovement],
+ ampCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1: np.ndarray | Sequence[str | Callable[[float], float]] = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ ) -> None:
+ """The initialization method.
+
+ :param base_airplane: The base Airplane from which the Airplane at each time
+ step will be created.
+ :param wing_movements: A list of the AeroelasticWingMovements associated with
+ each of the base Airplane's Wings. It must have the same length as the base
+ Airplane's list of Wings.
+ :param ampCg_GP1_CgP1: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the amplitudes of the
+ AeroelasticAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1
+ parameters. Can be a tuple, list, or ndarray. Values are converted to floats
+ internally. Each amplitude must be low enough that it doesn't drive its base
+ value out of the range of valid values. Otherwise, this
+ AeroelasticAirplaneMovement will try to create Airplanes with invalid
+ parameter values. Because the first Airplane's Cg_GP1_CgP1 parameter must be
+ all zeros, this means that the first Airplane's ampCg_GP1_CgP1 parameter
+ must also be all zeros. The units are in meters. The default is (0.0, 0.0,
+ 0.0).
+ :param periodCg_GP1_CgP1: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the periods of the
+ AeroelasticAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1
+ parameters. Can be a tuple, list, or ndarray. Values are converted to floats
+ internally. Each element must be 0.0 if the corresponding element in
+ ampCg_GP1_CgP1 is 0.0 and non zero if not. The units are in seconds. The
+ default is (0.0, 0.0, 0.0).
+ :param spacingCg_GP1_CgP1: An array-like object of strs or callables with shape
+ (3,) representing the spacing of the AeroelasticAirplaneMovement's changes
+ in its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or ndarray.
+ Each element can be the str "sine", the str "uniform", or a callable custom
+ spacing function. Custom spacing functions are for advanced users and must
+ start at 0.0, return to 0.0 after one period of 2.0 * pi radians, have
+ amplitude of 1.0, be periodic, return finite values only, and accept a float
+ as input and return a float. Custom functions are scaled by ampCg_GP1_CgP1,
+ shifted horizontally and vertically by phaseCg_GP1_CgP1 and the base value,
+ and have a period set by periodCg_GP1_CgP1. The default is ("sine", "sine",
+ "sine").
+ :param phaseCg_GP1_CgP1: An array-like object of numbers (int or float) with
+ shape (3,) representing the phase offsets of the elements in the first time
+ step's Airplane's Cg_GP1_CgP1 parameter relative to the base Airplane's
+ Cg_GP1_CgP1 parameter. Can be a tuple, list, or ndarray. Elements must lie
+ in the range (-180.0, 180.0]. Each element must be 0.0 if the corresponding
+ element in ampCg_GP1_CgP1 is 0.0 and non zero if not. Values are converted
+ to floats internally. The units are in degrees. The default is (0.0, 0.0,
+ 0.0).
+ :return: None
+ """
+ # Validate that every element is an AeroelasticWingMovement, not just a
+ # CoreWingMovement. CoreAirplaneMovement.__init__() validates at the Core
+ # level, but AeroelasticAirplaneMovement enforces the stricter type.
+ for wing_movement in wing_movements:
+ if not isinstance(
+ wing_movement,
+ aeroelastic_wing_movement_mod.AeroelasticWingMovement,
+ ):
+ raise TypeError(
+ "Every element in wing_movements must be an "
+ "AeroelasticWingMovement."
+ )
+
+ super().__init__(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=ampCg_GP1_CgP1,
+ periodCg_GP1_CgP1=periodCg_GP1_CgP1,
+ spacingCg_GP1_CgP1=spacingCg_GP1_CgP1,
+ phaseCg_GP1_CgP1=phaseCg_GP1_CgP1,
+ )
+
+ # --- Immutable: read only properties ---
+ @property
+ def wing_movements(
+ self,
+ ) -> tuple[aeroelastic_wing_movement_mod.AeroelasticWingMovement, ...]:
+ return cast(
+ tuple[aeroelastic_wing_movement_mod.AeroelasticWingMovement, ...],
+ self._wing_movements,
+ )
+
+ def generate_airplane_at_time_step(
+ self,
+ step: int,
+ delta_time: float | int,
+ wing_deformation_angles_ixyz: list[np.ndarray] | None = None,
+ ) -> geometry.airplane.Airplane:
+ """Creates the Airplane at a single time step, optionally applying structural
+ deformation to each Wing.
+
+ Computes the prescribed Airplane using the inherited oscillation logic, then
+ threads per Wing deformation down to each AeroelasticWingMovement child.
+
+ :param step: The time step index. Must be a non negative int.
+ :param delta_time: The time between each time step in seconds. Must be a
+ positive number (int or float).
+ :param wing_deformation_angles_ixyz: A list of (N_wcs, 3) ndarrays of floats,
+ one per Wing, where N_wcs is the number of WingCrossSections in that Wing.
+ Each row is a (3,) deformation angle vector using an intrinsic xy'z"
+ sequence. The units are in degrees. When None, no deformation is applied.
+ The default is None.
+ :return: The Airplane at this time step, with structural deformation applied to
+ each Wing if provided.
+ """
+ time = step * delta_time
+
+ # Evaluate the oscillating value for each dimension of Cg_GP1_CgP1.
+ thisCg_GP1_CgP1 = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingCg_GP1_CgP1[dim]
+ this_amp = self._ampCg_GP1_CgP1[dim]
+ this_period = self._periodCg_GP1_CgP1[dim]
+ this_phase = self._phaseCg_GP1_CgP1[dim]
+ this_base = self._base_airplane.Cg_GP1_CgP1[dim]
+
+ if this_spacing == "sine":
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ thisCg_GP1_CgP1[dim] = _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ # Generate the Wings for this time step, threading deformation to
+ # each AeroelasticWingMovement child.
+ these_wings = []
+ for i, wing_movement in enumerate(self.wing_movements):
+ # Extract this Wing's deformation, or None.
+ this_deformation = None
+ if wing_deformation_angles_ixyz is not None:
+ this_deformation = wing_deformation_angles_ixyz[i]
+
+ these_wings.append(
+ wing_movement.generate_wing_at_time_step(
+ step,
+ delta_time,
+ deformation_angles_ixyz=this_deformation,
+ )
+ )
+
+ return geometry.airplane.Airplane(
+ wings=these_wings,
+ name=self._base_airplane.name,
+ Cg_GP1_CgP1=thisCg_GP1_CgP1,
+ weight=self._base_airplane.weight,
+ )
diff --git a/pterasoftware/movements/aeroelastic_movement.py b/pterasoftware/movements/aeroelastic_movement.py
new file mode 100644
index 000000000..2a0db51b8
--- /dev/null
+++ b/pterasoftware/movements/aeroelastic_movement.py
@@ -0,0 +1,187 @@
+"""Contains the AeroelasticMovement class.
+
+**Contains the following classes:**
+
+AeroelasticMovement: A class used to contain an AeroelasticUnsteadyProblem's movement.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from typing import cast
+
+import numpy as np
+
+from .. import _core, geometry
+from .. import operating_point as operating_point_mod
+from . import aeroelastic_airplane_movement as aeroelastic_airplane_movement_mod
+from . import (
+ aeroelastic_operating_point_movement as aeroelastic_operating_point_movement_mod,
+)
+
+
+class AeroelasticMovement(_core.CoreMovement):
+ """A class used to contain an AeroelasticUnsteadyProblem's movement.
+
+ In aeroelastic simulations, wing geometry is prescribed via oscillation parameters
+ (flapping, CG oscillation, etc.) but the solver adds structural deformation at each
+ time step based on aerodynamic loads. OperatingPoints are prescribed via the same
+ oscillation parameters as OperatingPointMovement.
+
+ AeroelasticMovement pre generates all OperatingPoints upfront (since they are
+ prescribed) but does not pre generate Airplanes, because the deformed wing geometry
+ at each time step depends on the solver's structural response calculation.
+
+ **Contains the following methods:**
+
+ lcm_period: The least common multiple of all motion periods, ensuring all motions
+ complete an integer number of cycles when cycle averaging forces and moments.
+
+ max_period: The longest period of motion of AeroelasticMovement's sub movement
+ objects, the motion(s) of its sub sub movement object(s), and the motions of its sub
+ sub sub movement objects.
+
+ min_period: The shortest non zero period of motion of AeroelasticMovement's sub
+ movement objects, the motion(s) of its sub sub movement object(s), and the motions
+ of its sub sub sub movement objects.
+
+ static: Flags if AeroelasticMovement's sub movement objects, its sub sub movement
+ object(s), and its sub sub sub movement objects all represent no motion.
+
+ generate_airplane_at_time_step: Creates the Airplane at a single time step, applying
+ deformation from the solver's structural response.
+ """
+
+ __slots__ = ("_operating_points",)
+
+ def __init__(
+ self,
+ airplane_movements: list[
+ aeroelastic_airplane_movement_mod.AeroelasticAirplaneMovement
+ ],
+ operating_point_movement: aeroelastic_operating_point_movement_mod.AeroelasticOperatingPointMovement,
+ delta_time: float | int,
+ num_steps: int,
+ max_wake_rows: int | None = None,
+ ) -> None:
+ """The initialization method.
+
+ This method checks that all Wings maintain their symmetry type across all time
+ steps (using the undeformed prescribed geometry). See the WingMovement class
+ documentation for more details on this requirement. See the Wing class
+ documentation for more information on symmetry types.
+
+ :param airplane_movements: A list of the AeroelasticAirplaneMovements associated
+ with each of the AeroelasticUnsteadyProblem's Airplanes.
+ :param operating_point_movement: An AeroelasticOperatingPointMovement holding
+ the oscillation parameters for prescribing OperatingPoints at each time
+ step.
+ :param delta_time: The time, in seconds, between each time step. It must be a
+ positive number (int or float). It will be converted internally to a float.
+ :param num_steps: The number of time steps to simulate. It must be a positive
+ int.
+ :param max_wake_rows: The maximum number of chordwise wake RingVortex rows per
+ Wing. Must be a positive int if set. The default is None (no truncation).
+ :return: None
+ """
+ # Validate that every element is an AeroelasticAirplaneMovement, not
+ # just a CoreAirplaneMovement. CoreMovement.__init__() validates at
+ # the Core level, but AeroelasticMovement enforces the stricter type.
+ for airplane_movement in airplane_movements:
+ if not isinstance(
+ airplane_movement,
+ aeroelastic_airplane_movement_mod.AeroelasticAirplaneMovement,
+ ):
+ raise TypeError(
+ "Every element in airplane_movements must be an "
+ "AeroelasticAirplaneMovement."
+ )
+
+ # Validate that operating_point_movement is an
+ # AeroelasticOperatingPointMovement.
+ if not isinstance(
+ operating_point_movement,
+ aeroelastic_operating_point_movement_mod.AeroelasticOperatingPointMovement,
+ ):
+ raise TypeError(
+ "operating_point_movement must be an "
+ "AeroelasticOperatingPointMovement."
+ )
+
+ # --- Initialize CoreMovement ---
+ super().__init__(
+ airplane_movements=airplane_movements,
+ operating_point_movement=operating_point_movement,
+ delta_time=delta_time,
+ num_steps=num_steps,
+ max_wake_rows=max_wake_rows,
+ )
+
+ # --- Batch generate OperatingPoints ---
+ # OperatingPoints are prescribed in aeroelastic simulations, so
+ # generate them all upfront.
+ operating_points_list = operating_point_movement.generate_operating_points(
+ num_steps=self._num_steps, delta_time=self._delta_time
+ )
+ self._operating_points: tuple[operating_point_mod.OperatingPoint, ...] = tuple(
+ operating_points_list
+ )
+
+ # --- Immutable: read only properties ---
+ @property
+ def operating_point_movement(
+ self,
+ ) -> aeroelastic_operating_point_movement_mod.AeroelasticOperatingPointMovement:
+ assert isinstance(
+ self._operating_point_movement,
+ aeroelastic_operating_point_movement_mod.AeroelasticOperatingPointMovement,
+ )
+ return self._operating_point_movement
+
+ @property
+ def airplane_movements(
+ self,
+ ) -> tuple[aeroelastic_airplane_movement_mod.AeroelasticAirplaneMovement, ...]:
+ return cast(
+ tuple[aeroelastic_airplane_movement_mod.AeroelasticAirplaneMovement, ...],
+ self._airplane_movements,
+ )
+
+ @property
+ def operating_points(self) -> tuple[operating_point_mod.OperatingPoint, ...]:
+ return self._operating_points
+
+ def generate_airplane_at_time_step(
+ self,
+ airplane_movement_index: int,
+ step: int,
+ wing_deformation_angles_ixyz: list[np.ndarray] | None = None,
+ ) -> geometry.airplane.Airplane:
+ """Creates the Airplane at a single time step for a given
+ AeroelasticAirplaneMovement, applying deformation from the solver's structural
+ response.
+
+ This is the method the aeroelastic solver calls at each time step to get the
+ deformed Airplane geometry.
+
+ :param airplane_movement_index: The index of the AeroelasticAirplaneMovement in
+ this AeroelasticMovement's airplane_movements tuple.
+ :param step: The time step index. Must be a non negative int.
+ :param wing_deformation_angles_ixyz: A list of (N_wcs, 3) ndarrays of floats,
+ one per Wing, where N_wcs is the number of WingCrossSections in that Wing.
+ Each row is a (3,) deformation angle vector using an intrinsic xy'z"
+ sequence. The units are in degrees. When None, no deformation is applied.
+ The default is None.
+ :return: The Airplane at this time step, with structural deformation applied if
+ provided.
+ """
+ return self.airplane_movements[
+ airplane_movement_index
+ ].generate_airplane_at_time_step(
+ step,
+ self._delta_time,
+ wing_deformation_angles_ixyz=wing_deformation_angles_ixyz,
+ )
diff --git a/pterasoftware/movements/aeroelastic_operating_point_movement.py b/pterasoftware/movements/aeroelastic_operating_point_movement.py
new file mode 100644
index 000000000..0394491c0
--- /dev/null
+++ b/pterasoftware/movements/aeroelastic_operating_point_movement.py
@@ -0,0 +1,90 @@
+"""Contains the AeroelasticOperatingPointMovement class.
+
+**Contains the following classes:**
+
+AeroelasticOperatingPointMovement: A class used to contain an OperatingPoint's movements
+in an aeroelastic simulation.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+from .. import _core
+from .. import operating_point as operating_point_mod
+
+
+class AeroelasticOperatingPointMovement(_core.CoreOperatingPointMovement):
+ """A class used to contain an OperatingPoint's movements in an aeroelastic
+ simulation.
+
+ In aeroelastic simulations, OperatingPoints are prescribed via oscillation
+ parameters (the same oscillation based generation as OperatingPointMovement). This
+ class exists so that an AeroelasticMovement always accepts
+ AeroelasticOperatingPointMovements, keeping the aeroelastic movement hierarchy
+ consistently named.
+
+ **Contains the following methods:**
+
+ max_period: AeroelasticOperatingPointMovement's longest period of motion.
+
+ generate_operating_point_at_time_step: Creates the OperatingPoint at a single time
+ step.
+
+ generate_operating_points: Creates the OperatingPoint at each time step, and returns
+ them in a list.
+ """
+
+ __slots__ = ()
+
+ def __init__(
+ self,
+ base_operating_point: operating_point_mod.OperatingPoint,
+ ampVCg__E: float | int = 0.0,
+ periodVCg__E: float | int = 0.0,
+ spacingVCg__E: str | Callable[[float], float] = "sine",
+ phaseVCg__E: float | int = 0.0,
+ ) -> None:
+ """The initialization method.
+
+ :param base_operating_point: The base OperatingPoint from which the
+ OperatingPoint at each time step will be created.
+ :param ampVCg__E: The amplitude of the AeroelasticOperatingPointMovement's
+ change in its OperatingPoints' vCg__E parameters. It must be a non negative
+ number (int or float). Values are converted to floats internally. The
+ amplitude must be low enough that it doesn't drive its base value out of the
+ range of valid values. Otherwise, this AeroelasticOperatingPointMovement
+ will try to create OperatingPoints with invalid parameter values. The units
+ are in meters per second. The default is 0.0.
+ :param periodVCg__E: The period of the AeroelasticOperatingPointMovement's
+ change in its OperatingPoints' vCg__E parameters. It must be a non negative
+ number (int or float). Values are converted to floats internally. It must be
+ 0.0 if ampVCg__E is 0.0, and non zero if not. The units are in seconds. The
+ default is 0.0.
+ :param spacingVCg__E: The spacing type of the
+ AeroelasticOperatingPointMovement's change in its OperatingPoints' vCg__E
+ parameters. It can be the str "sine", the str "uniform", or a callable
+ custom spacing function. Custom spacing functions are for advanced users and
+ must start at 0.0, return to 0.0 after one period of 2.0 * pi radians, have
+ amplitude of 1.0, be periodic, return finite values only, and accept a float
+ as input and return a float. Custom functions are scaled by ampVCg__E,
+ shifted horizontally and vertically by phaseVCg__E and the base value, and
+ have a period set by periodVCg__E. The default is "sine".
+ :param phaseVCg__E: The phase offset of the first time step's OperatingPoint's
+ vCg__E parameter relative to the base OperatingPoint's vCg__E parameter. It
+ must be a number (int or float) in the range (-180.0, 180.0]. It must be 0.0
+ if ampVCg__E is 0.0 and non zero if not. Values are converted to floats
+ internally. The units are in degrees. The default is 0.0.
+ :return: None
+ """
+ super().__init__(
+ base_operating_point=base_operating_point,
+ ampVCg__E=ampVCg__E,
+ periodVCg__E=periodVCg__E,
+ spacingVCg__E=spacingVCg__E,
+ phaseVCg__E=phaseVCg__E,
+ )
diff --git a/pterasoftware/movements/aeroelastic_wing_cross_section_movement.py b/pterasoftware/movements/aeroelastic_wing_cross_section_movement.py
new file mode 100644
index 000000000..74cd305d0
--- /dev/null
+++ b/pterasoftware/movements/aeroelastic_wing_cross_section_movement.py
@@ -0,0 +1,306 @@
+"""Contains the AeroelasticWingCrossSectionMovement class.
+
+**Contains the following classes:**
+
+AeroelasticWingCrossSectionMovement: A class used to contain a WingCrossSection's
+movement in an aeroelastic simulation.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+
+import numpy as np
+
+from .. import _core, _oscillation, geometry
+
+
+class AeroelasticWingCrossSectionMovement(_core.CoreWingCrossSectionMovement):
+ """A class used to contain a WingCrossSection's movement in an aeroelastic
+ simulation.
+
+ In aeroelastic simulations, wing cross section geometry is prescribed via
+ oscillation parameters (the same oscillation based generation as
+ WingCrossSectionMovement), but the solver adds structural deformation angles at each
+ time step. This class overrides generate_wing_cross_section_at_time_step to accept
+ an optional deformation that is added to the prescribed angles_Wcsp_to_Wcs_ixyz.
+
+ **Contains the following methods:**
+
+ __deepcopy__: Creates a deep copy of this AeroelasticWingCrossSectionMovement.
+
+ all_periods: All unique non zero periods from this
+ AeroelasticWingCrossSectionMovement.
+
+ max_period: AeroelasticWingCrossSectionMovement's longest period of motion.
+
+ generate_wing_cross_section_at_time_step: Creates the WingCrossSection at a single
+ time step, optionally applying structural deformation.
+
+ generate_wing_cross_sections: Creates the WingCrossSection at each time step, and
+ returns them in a list.
+ """
+
+ __slots__ = ()
+
+ def __init__(
+ self,
+ base_wing_cross_section: geometry.wing_cross_section.WingCrossSection,
+ ampLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp: np.ndarray | Sequence[str | Callable[[float], float]] = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ periodAngles_Wcsp_to_Wcs_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ spacingAngles_Wcsp_to_Wcs_ixyz: (
+ np.ndarray | Sequence[str | Callable[[float], float]]
+ ) = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseAngles_Wcsp_to_Wcs_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ ) -> None:
+ """The initialization method.
+
+ :param base_wing_cross_section: The base WingCrossSection from which the
+ WingCrossSection at each time step will be created.
+ :param ampLp_Wcsp_Lpp: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the amplitudes of the
+ AeroelasticWingCrossSectionMovement's changes in its WingCrossSections'
+ Lp_Wcsp_Lpp parameters. Can be a tuple, list, or ndarray. Values are
+ converted to floats internally. Each amplitude must be low enough that it
+ doesn't drive its base value out of the range of valid values. Otherwise,
+ this AeroelasticWingCrossSectionMovement will try to create
+ WingCrossSections with invalid parameter values. The units are in meters.
+ The default is (0.0, 0.0, 0.0).
+ :param periodLp_Wcsp_Lpp: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the periods of the
+ AeroelasticWingCrossSectionMovement's changes in its WingCrossSections'
+ Lp_Wcsp_Lpp parameters. Can be a tuple, list, or ndarray. Values are
+ converted to floats internally. Each element must be 0.0 if the
+ corresponding element in ampLp_Wcsp_Lpp is 0.0 and non zero if not. The
+ units are in seconds. The default is (0.0, 0.0, 0.0).
+ :param spacingLp_Wcsp_Lpp: An array-like object of strs or callables with shape
+ (3,) representing the spacing of the AeroelasticWingCrossSectionMovement's
+ changes in its WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple,
+ list, or ndarray. Each element can be the str "sine", the str "uniform", or
+ a callable custom spacing function. Custom spacing functions are for
+ advanced users and must start at 0.0, return to 0.0 after one period of 2.0
+ * pi radians, have amplitude of 1.0, be periodic, return finite values only,
+ and accept a float as input and return a float. Custom functions are scaled
+ by ampLp_Wcsp_Lpp, shifted horizontally and vertically by phaseLp_Wcsp_Lpp
+ and the base value, and have a period set by periodLp_Wcsp_Lpp. The default
+ is ("sine", "sine", "sine").
+ :param phaseLp_Wcsp_Lpp: An array-like object of numbers (int or float) with
+ shape (3,) representing the phase offsets of the elements in the first time
+ step's WingCrossSection's Lp_Wcsp_Lpp parameter relative to the base
+ WingCrossSection's Lp_Wcsp_Lpp parameter. Can be a tuple, list, or ndarray.
+ Elements must lie in the range (-180.0, 180.0]. Each element must be 0.0 if
+ the corresponding element in ampLp_Wcsp_Lpp is 0.0 and non zero if not.
+ Values are converted to floats internally. The units are in degrees. The
+ default is (0.0, 0.0, 0.0).
+ :param ampAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative numbers
+ (int or float) with shape (3,) representing the amplitudes of the
+ AeroelasticWingCrossSectionMovement's changes in its WingCrossSections'
+ angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Values
+ are converted to floats internally. Each amplitude must be low enough that
+ it doesn't drive its base value out of the range of valid values. Otherwise,
+ this AeroelasticWingCrossSectionMovement will try to create
+ WingCrossSections with invalid parameter values. The units are in degrees.
+ The default is (0.0, 0.0, 0.0).
+ :param periodAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative
+ numbers (int or float) with shape (3,) representing the periods of the
+ AeroelasticWingCrossSectionMovement's changes in its WingCrossSections'
+ angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Values
+ are converted to floats internally. Each element must be 0.0 if the
+ corresponding element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non zero if
+ not. The units are in seconds. The default is (0.0, 0.0, 0.0).
+ :param spacingAngles_Wcsp_to_Wcs_ixyz: An array-like object of strs or callables
+ with shape (3,) representing the spacing of the
+ AeroelasticWingCrossSectionMovement's changes in its WingCrossSections'
+ angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Each
+ element can be the str "sine", the str "uniform", or a callable custom
+ spacing function. Custom spacing functions are for advanced users and must
+ start at 0.0, return to 0.0 after one period of 2.0 * pi radians, have
+ amplitude of 1.0, be periodic, return finite values only, and accept a float
+ as input and return a float. Custom functions are scaled by
+ ampAngles_Wcsp_to_Wcs_ixyz, shifted horizontally and vertically by
+ phaseAngles_Wcsp_to_Wcs_ixyz and the base value, and have a period set by
+ periodAngles_Wcsp_to_Wcs_ixyz. The default is ("sine", "sine", "sine").
+ :param phaseAngles_Wcsp_to_Wcs_ixyz: An array-like object of numbers (int or
+ float) with shape (3,) representing the phase offsets of the elements in the
+ first time step's WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter
+ relative to the base WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter.
+ Can be a tuple, list, or ndarray. Elements must lie in the range (-180.0,
+ 180.0]. Each element must be 0.0 if the corresponding element in
+ ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non zero if not. Values are converted
+ to floats internally. The units are in degrees. The default is (0.0, 0.0,
+ 0.0).
+ :return: None
+ """
+ super().__init__(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=ampLp_Wcsp_Lpp,
+ periodLp_Wcsp_Lpp=periodLp_Wcsp_Lpp,
+ spacingLp_Wcsp_Lpp=spacingLp_Wcsp_Lpp,
+ phaseLp_Wcsp_Lpp=phaseLp_Wcsp_Lpp,
+ ampAngles_Wcsp_to_Wcs_ixyz=ampAngles_Wcsp_to_Wcs_ixyz,
+ periodAngles_Wcsp_to_Wcs_ixyz=periodAngles_Wcsp_to_Wcs_ixyz,
+ spacingAngles_Wcsp_to_Wcs_ixyz=spacingAngles_Wcsp_to_Wcs_ixyz,
+ phaseAngles_Wcsp_to_Wcs_ixyz=phaseAngles_Wcsp_to_Wcs_ixyz,
+ )
+
+ def generate_wing_cross_section_at_time_step(
+ self,
+ step: int,
+ delta_time: float | int,
+ deformation_angles_ixyz: np.ndarray | None = None,
+ ) -> geometry.wing_cross_section.WingCrossSection:
+ """Creates the WingCrossSection at a single time step, optionally applying
+ structural deformation.
+
+ Computes the prescribed WingCrossSection using the inherited oscillation logic,
+ then adds the deformation angles to the prescribed angles_Wcsp_to_Wcs_ixyz. This
+ is the integration point where the aeroelastic solver's structural response
+ modifies the wing cross section geometry.
+
+ :param step: The time step index. Must be a non negative int.
+ :param delta_time: The time between each time step in seconds. Must be a
+ positive number (int or float).
+ :param deformation_angles_ixyz: A (3,) ndarray of floats representing the
+ structural deformation angles to add to the prescribed
+ angles_Wcsp_to_Wcs_ixyz, using an intrinsic xy'z" sequence. The units are in
+ degrees. When None, no deformation is applied and the result is identical to
+ the prescribed WingCrossSection. The default is None.
+ :return: The WingCrossSection at this time step, with structural deformation
+ applied if provided.
+ """
+ time = step * delta_time
+
+ # Evaluate the oscillating value for each dimension of Lp_Wcsp_Lpp.
+ thisLp_Wcsp_Lpp = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingLp_Wcsp_Lpp[dim]
+ this_amp = self._ampLp_Wcsp_Lpp[dim]
+ this_period = self._periodLp_Wcsp_Lpp[dim]
+ this_phase = self._phaseLp_Wcsp_Lpp[dim]
+ this_base = self._base_wing_cross_section.Lp_Wcsp_Lpp[dim]
+
+ if this_spacing == "sine":
+ thisLp_Wcsp_Lpp[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ thisLp_Wcsp_Lpp[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ thisLp_Wcsp_Lpp[dim] = _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ # Evaluate the oscillating value for each dimension of
+ # angles_Wcsp_to_Wcs_ixyz.
+ theseAngles_Wcsp_to_Wcs_ixyz = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingAngles_Wcsp_to_Wcs_ixyz[dim]
+ this_amp = self._ampAngles_Wcsp_to_Wcs_ixyz[dim]
+ this_period = self._periodAngles_Wcsp_to_Wcs_ixyz[dim]
+ this_phase = self._phaseAngles_Wcsp_to_Wcs_ixyz[dim]
+ this_base = self._base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim]
+
+ if this_spacing == "sine":
+ theseAngles_Wcsp_to_Wcs_ixyz[dim] = (
+ _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ )
+ elif this_spacing == "uniform":
+ theseAngles_Wcsp_to_Wcs_ixyz[dim] = (
+ _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ )
+ elif callable(this_spacing):
+ theseAngles_Wcsp_to_Wcs_ixyz[dim] = (
+ _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ # Apply structural deformation if provided. The deformation angles
+ # are added to the prescribed angles, representing the structural
+ # response computed by the aeroelastic solver.
+ if deformation_angles_ixyz is not None:
+ theseAngles_Wcsp_to_Wcs_ixyz = (
+ theseAngles_Wcsp_to_Wcs_ixyz + deformation_angles_ixyz
+ )
+
+ return geometry.wing_cross_section.WingCrossSection(
+ airfoil=self._base_wing_cross_section.airfoil,
+ num_spanwise_panels=self._base_wing_cross_section.num_spanwise_panels,
+ chord=self._base_wing_cross_section.chord,
+ Lp_Wcsp_Lpp=thisLp_Wcsp_Lpp,
+ angles_Wcsp_to_Wcs_ixyz=theseAngles_Wcsp_to_Wcs_ixyz,
+ control_surface_symmetry_type=(
+ self._base_wing_cross_section.control_surface_symmetry_type
+ ),
+ control_surface_hinge_point=(
+ self._base_wing_cross_section.control_surface_hinge_point
+ ),
+ control_surface_deflection=(
+ self._base_wing_cross_section.control_surface_deflection
+ ),
+ spanwise_spacing=self._base_wing_cross_section.spanwise_spacing,
+ )
diff --git a/pterasoftware/movements/aeroelastic_wing_movement.py b/pterasoftware/movements/aeroelastic_wing_movement.py
new file mode 100644
index 000000000..396e40164
--- /dev/null
+++ b/pterasoftware/movements/aeroelastic_wing_movement.py
@@ -0,0 +1,368 @@
+"""Contains the AeroelasticWingMovement class.
+
+**Contains the following classes:**
+
+AeroelasticWingMovement: A class used to contain a Wing's movement in an aeroelastic
+simulation.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+
+import numpy as np
+
+from .. import _core, _oscillation, _transformations, geometry
+from . import (
+ aeroelastic_wing_cross_section_movement as aeroelastic_wing_cross_section_movement_mod,
+)
+
+
+class AeroelasticWingMovement(_core.CoreWingMovement):
+ """A class used to contain a Wing's movement in an aeroelastic simulation.
+
+ In aeroelastic simulations, wing geometry is prescribed via oscillation parameters
+ (the same oscillation based generation as WingMovement), but the solver adds
+ structural deformation at each time step. This class overrides
+ generate_wing_at_time_step to accept per WingCrossSection deformation angles that
+ are threaded down to its AeroelasticWingCrossSectionMovement children.
+
+ **Contains the following methods:**
+
+ __deepcopy__: Creates a deep copy of this AeroelasticWingMovement.
+
+ all_periods: All unique non zero periods from this AeroelasticWingMovement and its
+ AeroelasticWingCrossSectionMovements.
+
+ max_period: The longest period of AeroelasticWingMovement's own motion and that of
+ its sub movement objects.
+
+ generate_wing_at_time_step: Creates the Wing at a single time step, optionally
+ applying structural deformation to each WingCrossSection.
+
+ generate_wings: Creates the Wing at each time step, and returns them in a list.
+
+ **Notes:**
+
+ Wings cannot undergo motion that causes them to switch symmetry types. A transition
+ between types could change the number of Wings and the Panel structure, which is
+ incompatible with the unsteady solver. This happens when an AeroelasticWingMovement
+ defines motion that causes its base Wing's wing axes' yz plane and its symmetry
+ plane to transition from coincident to non coincident, or vice versa. This is
+ checked by this AeroelasticWingMovement's parent AeroelasticAirplaneMovement's
+ parent AeroelasticMovement.
+ """
+
+ __slots__ = ()
+
+ def __init__(
+ self,
+ base_wing: geometry.wing.Wing,
+ wing_cross_section_movements: list[
+ aeroelastic_wing_cross_section_movement_mod.AeroelasticWingCrossSectionMovement
+ ],
+ ampLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs: np.ndarray | Sequence[str | Callable[[float], float]] = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ spacingAngles_Gs_to_Wn_ixyz: (
+ np.ndarray | Sequence[str | Callable[[float], float]]
+ ) = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseAngles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ rotationPointOffset_Gs_Ler: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ ) -> None:
+ """The initialization method.
+
+ :param base_wing: The base Wing from which the Wing at each time step will be
+ created.
+ :param wing_cross_section_movements: A list of
+ AeroelasticWingCrossSectionMovements associated with each of the base Wing's
+ WingCrossSections. It must have the same length as the base Wing's list of
+ WingCrossSections.
+ :param ampLer_Gs_Cgs: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the amplitudes of the
+ AeroelasticWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can
+ be a tuple, list, or ndarray. Values are converted to floats internally.
+ Each amplitude must be low enough that it doesn't drive its base value out
+ of the range of valid values. Otherwise, this AeroelasticWingMovement will
+ try to create Wings with invalid parameters values. The units are in meters.
+ The default is (0.0, 0.0, 0.0).
+ :param periodLer_Gs_Cgs: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the periods of the
+ AeroelasticWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can
+ be a tuple, list, or ndarray. Values are converted to floats internally.
+ Each element must be 0.0 if the corresponding element in ampLer_Gs_Cgs is
+ 0.0 and non zero if not. The units are in seconds. The default is (0.0, 0.0,
+ 0.0).
+ :param spacingLer_Gs_Cgs: An array-like object of strs or callables with shape
+ (3,) representing the spacing of the AeroelasticWingMovement's change in its
+ Wings' Ler_Gs_Cgs parameters. Can be a tuple, list, or ndarray. Each element
+ can be the string "sine", the string "uniform", or a callable custom spacing
+ function. Custom spacing functions are for advanced users and must start at
+ 0.0, return to 0.0 after one period of 2.0 * pi radians, have amplitude of
+ 1.0, be periodic, return finite values only, and accept a float as input and
+ return a float. The custom function is scaled by ampLer_Gs_Cgs, shifted
+ horizontally and vertically by phaseLer_Gs_Cgs and the base value, and have
+ a period set by periodLer_Gs_Cgs. The default is ("sine", "sine", "sine").
+ :param phaseLer_Gs_Cgs: An array-like object of numbers (int or float) with
+ shape (3,) representing the phase offsets of the elements in the first time
+ step's Wing's Ler_Gs_Cgs parameter relative to the base Wing's Ler_Gs_Cgs
+ parameter. Can be a tuple, list, or ndarray. Values must lie in the range
+ (-180.0, 180.0] and will be converted to floats internally. Each element
+ must be 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and non
+ zero if not. The units are in degrees. The default is (0.0, 0.0, 0.0).
+ :param ampAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float)
+ with shape (3,) representing the amplitudes of the AeroelasticWingMovement's
+ changes in its Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list,
+ or ndarray. Values must lie in the range [0.0, 180.0] and will be converted
+ to floats internally. Each amplitude must be low enough that it doesn't
+ drive its base value out of the range of valid values. Otherwise, this
+ AeroelasticWingMovement will try to create Wings with invalid parameters
+ values. The units are in degrees. The default is (0.0, 0.0, 0.0).
+ :param periodAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or
+ float) with shape (3,) representing the periods of the
+ AeroelasticWingMovement's changes in its Wings' angles_Gs_to_Wn_ixyz
+ parameters. Can be a tuple, list, or ndarray. Values are converted to floats
+ internally. Each element must be 0.0 if the corresponding element in
+ ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in
+ seconds. The default is (0.0, 0.0, 0.0).
+ :param spacingAngles_Gs_to_Wn_ixyz: An array-like object of strs or callables
+ with shape (3,) representing the spacing of the AeroelasticWingMovement's
+ change in its Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list,
+ or ndarray. Each element can be the string "sine", the string "uniform", or
+ a callable custom spacing function. Custom spacing functions are for
+ advanced users and must start at 0.0, return to 0.0 after one period of 2.0
+ * pi radians, have amplitude of 1.0, be periodic, return finite values only,
+ and accept a float as input and return a float. The custom function is
+ scaled by ampAngles_Gs_to_Wn_ixyz, shifted horizontally and vertically by
+ phaseAngles_Gs_to_Wn_ixyz and the base value, with the period set by
+ periodAngles_Gs_to_Wn_ixyz. The default is ("sine", "sine", "sine").
+ :param phaseAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float)
+ with shape (3,) representing the phase offsets of the elements in the first
+ time step's Wing's angles_Gs_to_Wn_ixyz parameter relative to the base
+ Wing's angles_Gs_to_Wn_ixyz parameter. Can be a tuple, list, or ndarray.
+ Values must lie in the range (-180.0, 180.0] and will be converted to floats
+ internally. Each element must be 0.0 if the corresponding element in
+ ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in
+ degrees. The default is (0.0, 0.0, 0.0).
+ :param rotationPointOffset_Gs_Ler: An array-like object of 3 numbers (int or
+ float) representing the position of the rotation point for the Wing's
+ angular motion (in geometry axes after accounting for symmetry, relative to
+ the leading edge root point). Can be a tuple, list, or ndarray. Values are
+ converted to floats internally. This offset defines where the Wing rotates
+ about when angles_Gs_to_Wn_ixyz oscillates. When set to (0, 0, 0), rotation
+ occurs about the leading edge root point (default behavior). The units are
+ in meters. The default is (0.0, 0.0, 0.0).
+ :return: None
+ """
+ # Validate that every element is an AeroelasticWingCrossSectionMovement,
+ # not just a CoreWingCrossSectionMovement. CoreWingMovement.__init__()
+ # validates at the Core level, but AeroelasticWingMovement enforces the
+ # stricter type.
+ for wing_cross_section_movement in wing_cross_section_movements:
+ if not isinstance(
+ wing_cross_section_movement,
+ aeroelastic_wing_cross_section_movement_mod.AeroelasticWingCrossSectionMovement,
+ ):
+ raise TypeError(
+ "Every element in wing_cross_section_movements must "
+ "be an AeroelasticWingCrossSectionMovement."
+ )
+
+ super().__init__(
+ base_wing=base_wing,
+ wing_cross_section_movements=wing_cross_section_movements,
+ ampLer_Gs_Cgs=ampLer_Gs_Cgs,
+ periodLer_Gs_Cgs=periodLer_Gs_Cgs,
+ spacingLer_Gs_Cgs=spacingLer_Gs_Cgs,
+ phaseLer_Gs_Cgs=phaseLer_Gs_Cgs,
+ ampAngles_Gs_to_Wn_ixyz=ampAngles_Gs_to_Wn_ixyz,
+ periodAngles_Gs_to_Wn_ixyz=periodAngles_Gs_to_Wn_ixyz,
+ spacingAngles_Gs_to_Wn_ixyz=spacingAngles_Gs_to_Wn_ixyz,
+ phaseAngles_Gs_to_Wn_ixyz=phaseAngles_Gs_to_Wn_ixyz,
+ rotationPointOffset_Gs_Ler=rotationPointOffset_Gs_Ler,
+ )
+
+ def generate_wing_at_time_step(
+ self,
+ step: int,
+ delta_time: float | int,
+ deformation_angles_ixyz: np.ndarray | None = None,
+ ) -> geometry.wing.Wing:
+ """Creates the Wing at a single time step, optionally applying structural
+ deformation to each WingCrossSection.
+
+ Computes the prescribed Wing using the inherited oscillation logic, then threads
+ per WingCrossSection deformation angles down to each
+ AeroelasticWingCrossSectionMovement child.
+
+ :param step: The time step index. Must be a non negative int.
+ :param delta_time: The time between each time step in seconds. Must be a
+ positive number (int or float).
+ :param deformation_angles_ixyz: A (N, 3) ndarray of floats where N is the number
+ of WingCrossSections in this Wing. Each row is a (3,) deformation angle
+ vector using an intrinsic xy'z" sequence that is added to the corresponding
+ WingCrossSection's prescribed angles_Wcsp_to_Wcs_ixyz. The units are in
+ degrees. When None, no deformation is applied. The default is None.
+ :return: The Wing at this time step, with structural deformation applied to each
+ WingCrossSection if provided.
+ """
+ time = step * delta_time
+
+ # Evaluate the oscillating value for each dimension of Ler_Gs_Cgs.
+ thisLer_Gs_Cgs = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingLer_Gs_Cgs[dim]
+ this_amp = self._ampLer_Gs_Cgs[dim]
+ this_period = self._periodLer_Gs_Cgs[dim]
+ this_phase = self._phaseLer_Gs_Cgs[dim]
+ this_base = self._base_wing.Ler_Gs_Cgs[dim]
+
+ if this_spacing == "sine":
+ thisLer_Gs_Cgs[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ thisLer_Gs_Cgs[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ thisLer_Gs_Cgs[dim] = _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ # Evaluate the oscillating value for each dimension of
+ # angles_Gs_to_Wn_ixyz.
+ theseAngles_Gs_to_Wn_ixyz = np.zeros(3, dtype=float)
+ for dim in range(3):
+ this_spacing = self._spacingAngles_Gs_to_Wn_ixyz[dim]
+ this_amp = self._ampAngles_Gs_to_Wn_ixyz[dim]
+ this_period = self._periodAngles_Gs_to_Wn_ixyz[dim]
+ this_phase = self._phaseAngles_Gs_to_Wn_ixyz[dim]
+ this_base = self._base_wing.angles_Gs_to_Wn_ixyz[dim]
+
+ if this_spacing == "sine":
+ theseAngles_Gs_to_Wn_ixyz[dim] = _oscillation.oscillating_sin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif this_spacing == "uniform":
+ theseAngles_Gs_to_Wn_ixyz[dim] = _oscillation.oscillating_lin_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ )
+ elif callable(this_spacing):
+ theseAngles_Gs_to_Wn_ixyz[dim] = (
+ _oscillation.oscillating_custom_at_time(
+ amp=this_amp,
+ period=this_period,
+ phase=this_phase,
+ base=this_base,
+ time=time,
+ custom_function=this_spacing,
+ )
+ )
+ else:
+ raise ValueError(f"Invalid spacing value: {this_spacing}")
+
+ # Generate the WingCrossSections for this time step, threading
+ # deformation to each AeroelasticWingCrossSectionMovement child.
+ these_wing_cross_sections = []
+ for i, wing_cross_section_movement in enumerate(
+ self._wing_cross_section_movements
+ ):
+ assert isinstance(
+ wing_cross_section_movement,
+ aeroelastic_wing_cross_section_movement_mod.AeroelasticWingCrossSectionMovement,
+ )
+
+ # Extract this WingCrossSection's deformation row, or None.
+ this_deformation = None
+ if deformation_angles_ixyz is not None:
+ this_deformation = deformation_angles_ixyz[i]
+
+ these_wing_cross_sections.append(
+ wing_cross_section_movement.generate_wing_cross_section_at_time_step(
+ step,
+ delta_time,
+ deformation_angles_ixyz=this_deformation,
+ )
+ )
+
+ # If there is a non zero rotation point offset, adjust the position
+ # to account for rotation about the offset point instead of the
+ # leading edge root.
+ if not np.allclose(self._rotationPointOffset_Gs_Ler, np.zeros(3, dtype=float)):
+ rot_T_act = _transformations.generate_rot_T(
+ theseAngles_Gs_to_Wn_ixyz,
+ passive=False,
+ intrinsic=True,
+ order="xyz",
+ )
+ rot_R_act = rot_T_act[:3, :3]
+
+ offsetRotationPointAdjustment_Gs = (
+ np.eye(3, dtype=float) - rot_R_act
+ ) @ self._rotationPointOffset_Gs_Ler
+
+ thisLer_Gs_Cgs = thisLer_Gs_Cgs + offsetRotationPointAdjustment_Gs
+
+ return geometry.wing.Wing(
+ wing_cross_sections=these_wing_cross_sections,
+ name=self._base_wing.name,
+ Ler_Gs_Cgs=thisLer_Gs_Cgs,
+ angles_Gs_to_Wn_ixyz=theseAngles_Gs_to_Wn_ixyz,
+ symmetric=self._base_wing.symmetric,
+ mirror_only=self._base_wing.mirror_only,
+ symmetryNormal_G=self._base_wing.symmetryNormal_G,
+ symmetryPoint_G_Cg=self._base_wing.symmetryPoint_G_Cg,
+ num_chordwise_panels=self._base_wing.num_chordwise_panels,
+ chordwise_spacing=self._base_wing.chordwise_spacing,
+ )
diff --git a/pterasoftware/movements/airplane_movement.py b/pterasoftware/movements/airplane_movement.py
index f385348f6..306b8735a 100644
--- a/pterasoftware/movements/airplane_movement.py
+++ b/pterasoftware/movements/airplane_movement.py
@@ -11,18 +11,16 @@
from __future__ import annotations
-import copy
-import math
from collections.abc import Callable, Sequence
+from typing import cast
import numpy as np
-from .. import _parameter_validation, geometry
-from . import _functions
+from .. import _core, geometry
from . import wing_movement as wing_movement_mod
-class AirplaneMovement:
+class AirplaneMovement(_core.CoreAirplaneMovement):
"""A class used to contain an Airplane's movement.
**Contains the following methods:**
@@ -35,20 +33,13 @@ class AirplaneMovement:
max_period: The longest period of AirplaneMovement's own motion, the motion(s) of
its sub movement object(s), and the motions of its sub sub movement objects.
+ generate_airplane_at_time_step: Creates the Airplane at a single time step.
+
generate_airplanes: Creates the Airplane at each time step, and returns them in a
list.
"""
- __slots__ = (
- "_base_airplane",
- "_wing_movements",
- "_ampCg_GP1_CgP1",
- "_periodCg_GP1_CgP1",
- "_spacingCg_GP1_CgP1",
- "_phaseCg_GP1_CgP1",
- "_all_periods",
- "_max_period",
- )
+ __slots__ = ()
def __init__(
self,
@@ -56,9 +47,7 @@ def __init__(
wing_movements: list[wing_movement_mod.WingMovement],
ampCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
periodCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
- spacingCg_GP1_CgP1: (
- np.ndarray | Sequence[str | Callable[[np.ndarray], np.ndarray]]
- ) = (
+ spacingCg_GP1_CgP1: np.ndarray | Sequence[str | Callable[[float], float]] = (
"sine",
"sine",
"sine",
@@ -93,12 +82,12 @@ def __init__(
Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or ndarray. Each
element can be the str "sine", the str "uniform", or a callable custom
spacing function. Custom spacing functions are for advanced users and must
- start at 0.0, return to 0.0 after one period of 2*pi radians, have amplitude
- of 1.0, be periodic, return finite values only, and accept a ndarray as
- input and return a ndarray of the same shape. Custom functions are scaled by
- ampCg_GP1_CgP1, shifted horizontally and vertically by phaseCg_GP1_CgP1 and
- the base value, and have a period set by periodCg_GP1_CgP1. The default is
- ("sine", "sine", "sine").
+ start at 0.0, return to 0.0 after one period of 2.0 * pi radians, have
+ amplitude of 1.0, be periodic, return finite values only, and accept a float
+ as input and return a float. Custom functions are scaled by ampCg_GP1_CgP1,
+ shifted horizontally and vertically by phaseCg_GP1_CgP1 and the base value,
+ and have a period set by periodCg_GP1_CgP1. The default is ("sine", "sine",
+ "sine").
:param phaseCg_GP1_CgP1: An array-like object of numbers (int or float) with
shape (3,) representing the phase offsets of the elements in the first time
step's Airplane's Cg_GP1_CgP1 parameter relative to the base Airplane's
@@ -109,581 +98,28 @@ def __init__(
0.0).
:return: None
"""
- # Validate and store immutable attributes. Set those that are numpy arrays to
- # be read only.
- if not isinstance(base_airplane, geometry.airplane.Airplane):
- raise TypeError("base_airplane must be an Airplane.")
- self._base_airplane = base_airplane
-
- if not isinstance(wing_movements, list):
- raise TypeError("wing_movements must be a list.")
- if len(wing_movements) != len(self._base_airplane.wings):
- raise ValueError(
- "wing_movements must have the same length as base_airplane.wings."
- )
+ # Validate that every element is a WingMovement, not just a
+ # CoreWingMovement. CoreAirplaneMovement.__init__() validates at the Core
+ # level, but AirplaneMovement enforces the stricter type.
for wing_movement in wing_movements:
if not isinstance(wing_movement, wing_movement_mod.WingMovement):
raise TypeError(
- "Every element in wing_movements must be a WingMovement."
- )
- # Store as tuple to prevent external mutation.
- self._wing_movements = tuple(wing_movements)
-
- ampCg_GP1_CgP1 = _parameter_validation.threeD_number_vectorLike_return_float(
- ampCg_GP1_CgP1, "ampCg_GP1_CgP1"
- )
- if not np.all(ampCg_GP1_CgP1 >= 0.0):
- raise ValueError("All elements in ampCg_GP1_CgP1 must be non negative.")
- self._ampCg_GP1_CgP1 = ampCg_GP1_CgP1
- self._ampCg_GP1_CgP1.flags.writeable = False
-
- periodCg_GP1_CgP1 = _parameter_validation.threeD_number_vectorLike_return_float(
- periodCg_GP1_CgP1, "periodCg_GP1_CgP1"
- )
- if not np.all(periodCg_GP1_CgP1 >= 0.0):
- raise ValueError("All elements in periodCg_GP1_CgP1 must be non negative.")
- for period_index, period in enumerate(periodCg_GP1_CgP1):
- amp = self._ampCg_GP1_CgP1[period_index]
- if amp == 0 and period != 0:
- raise ValueError(
- "If an element in ampCg_GP1_CgP1 is 0.0, the corresponding element "
- "in periodCg_GP1_CgP1 must be also be 0.0."
+ "Every element in wing_movements must be a " "WingMovement."
)
- self._periodCg_GP1_CgP1 = periodCg_GP1_CgP1
- self._periodCg_GP1_CgP1.flags.writeable = False
-
- # Store as tuple to prevent external mutation.
- self._spacingCg_GP1_CgP1 = (
- _parameter_validation.threeD_spacing_vectorLike_return_tuple(
- spacingCg_GP1_CgP1, "spacingCg_GP1_CgP1"
- )
- )
- phaseCg_GP1_CgP1 = _parameter_validation.threeD_number_vectorLike_return_float(
- phaseCg_GP1_CgP1, "phaseCg_GP1_CgP1"
+ super().__init__(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=ampCg_GP1_CgP1,
+ periodCg_GP1_CgP1=periodCg_GP1_CgP1,
+ spacingCg_GP1_CgP1=spacingCg_GP1_CgP1,
+ phaseCg_GP1_CgP1=phaseCg_GP1_CgP1,
)
- if not (
- np.all(phaseCg_GP1_CgP1 > -180.0) and np.all(phaseCg_GP1_CgP1 <= 180.0)
- ):
- raise ValueError(
- "All elements in phaseCg_GP1_CgP1 must be in the range (-180.0, 180.0]."
- )
- for phase_index, phase in enumerate(phaseCg_GP1_CgP1):
- amp = self._ampCg_GP1_CgP1[phase_index]
- if amp == 0 and phase != 0:
- raise ValueError(
- "If an element in ampCg_GP1_CgP1 is 0.0, the corresponding element "
- "in phaseCg_GP1_CgP1 must be also be 0.0."
- )
- self._phaseCg_GP1_CgP1 = phaseCg_GP1_CgP1
- self._phaseCg_GP1_CgP1.flags.writeable = False
-
- # Initialize the caches for the properties derived from the immutable
- # attributes.
- self._all_periods: tuple[float, ...] | None = None
- self._max_period: float | None = None
-
- # --- Deep copy method ---
- def __deepcopy__(self, memo: dict) -> AirplaneMovement:
- """Creates a deep copy of this AirplaneMovement.
-
- All attributes are copied. The base Airplane and WingMovements are deep copied
- to ensure independence. NumPy arrays are copied and set to read only to preserve
- immutability. Cache variables are reset to None.
-
- :param memo: A dict used by the copy module to track already copied objects and
- avoid infinite recursion.
- :return: A new AirplaneMovement with copied attributes.
- """
- # Create a new AirplaneMovement instance without calling __init__ to avoid
- # redundant validation.
- new_movement = object.__new__(AirplaneMovement)
-
- # Store this AirplaneMovement in memo to handle potential circular references.
- memo[id(self)] = new_movement
-
- # Deep copy the base Airplane to ensure independence (immutable).
- new_movement._base_airplane = copy.deepcopy(self._base_airplane, memo)
-
- # Deep copy WingMovements and store as tuple.
- new_movement._wing_movements = tuple(
- copy.deepcopy(wing_movement, memo) for wing_movement in self._wing_movements
- )
-
- # Copy numpy arrays and make them read only.
- new_movement._ampCg_GP1_CgP1 = self._ampCg_GP1_CgP1.copy()
- new_movement._ampCg_GP1_CgP1.flags.writeable = False
-
- new_movement._periodCg_GP1_CgP1 = self._periodCg_GP1_CgP1.copy()
- new_movement._periodCg_GP1_CgP1.flags.writeable = False
-
- new_movement._phaseCg_GP1_CgP1 = self._phaseCg_GP1_CgP1.copy()
- new_movement._phaseCg_GP1_CgP1.flags.writeable = False
-
- # Copy tuple directly (it is immutable).
- new_movement._spacingCg_GP1_CgP1 = self._spacingCg_GP1_CgP1
-
- # Initialize cache variables to None (caches will be recomputed on access).
- new_movement._all_periods = None
- new_movement._max_period = None
-
- return new_movement
# --- Immutable: read only properties ---
- @property
- def base_airplane(self) -> geometry.airplane.Airplane:
- return self._base_airplane
-
@property
def wing_movements(self) -> tuple[wing_movement_mod.WingMovement, ...]:
- return self._wing_movements
-
- @property
- def ampCg_GP1_CgP1(self) -> np.ndarray:
- return self._ampCg_GP1_CgP1
-
- @property
- def periodCg_GP1_CgP1(self) -> np.ndarray:
- return self._periodCg_GP1_CgP1
-
- @property
- def spacingCg_GP1_CgP1(
- self,
- ) -> tuple[str | Callable[[np.ndarray], np.ndarray], ...]:
- return self._spacingCg_GP1_CgP1
-
- @property
- def phaseCg_GP1_CgP1(self) -> np.ndarray:
- return self._phaseCg_GP1_CgP1
-
- # --- Immutable derived: manual lazy caching ---
- @property
- def all_periods(self) -> tuple[float, ...]:
- """All unique non zero periods from this AirplaneMovement, its WingMovement(s),
- and their WingCrossSectionMovements.
-
- :return: A tuple of all unique non zero periods in seconds. If all motion is
- static, this will be an empty tuple.
- """
- if self._all_periods is None:
- periods: list[float] = []
-
- # Collect all periods from WingMovement(s).
- for wing_movement in self._wing_movements:
- periods.extend(wing_movement.all_periods)
-
- # Collect all periods from AirplaneMovement's own motion.
- for period in self._periodCg_GP1_CgP1:
- if period > 0.0:
- periods.append(float(period))
-
- self._all_periods = tuple(periods)
- return self._all_periods
-
- @property
- def max_period(self) -> float:
- """The longest period of AirplaneMovement's own motion, the motion(s) of its sub
- movement object(s), and the motions of its sub sub movement objects.
-
- :return: The longest period in seconds. If all the motion is static, this will
- be 0.0.
- """
- if self._max_period is None:
- wing_movement_max_periods = []
- for wing_movement in self._wing_movements:
- wing_movement_max_periods.append(wing_movement.max_period)
- max_wing_movement_period = max(wing_movement_max_periods)
-
- self._max_period = float(
- max(
- max_wing_movement_period,
- np.max(self._periodCg_GP1_CgP1),
- )
- )
- return self._max_period
-
- # --- Other methods ---
- def generate_airplanes(
- self, num_steps: int, delta_time: float | int
- ) -> list[geometry.airplane.Airplane]:
- """Creates the Airplane at each time step, and returns them in a list.
-
- For static geometry (no periodic motion), this method optimizes performance by
- creating the first Airplane with full mesh generation, then using deepcopy for
- subsequent time steps. This avoids redundant mesh generation when the geometry
- is identical across all steps.
-
- :param num_steps: The number of time steps in this movement. It must be a
- positive int.
- :param delta_time: The time between each time step. It must be a positive number
- (float or int), and will be converted internally to a float. The units are
- in seconds.
- :return: The list of Airplanes associated with this AirplaneMovement.
- """
- num_steps = _parameter_validation.int_in_range_return_int(
- num_steps,
- "num_steps",
- min_val=1,
- min_inclusive=True,
- )
- delta_time = _parameter_validation.number_in_range_return_float(
- delta_time, "delta_time", min_val=0.0, min_inclusive=False
+ return cast(
+ tuple[wing_movement_mod.WingMovement, ...],
+ self._wing_movements,
)
-
- # Generate oscillating values for each dimension of Cg_GP1_CgP1.
- listCg_GP1_CgP1 = np.zeros((3, num_steps), dtype=float)
- for dim in range(3):
- spacing = self._spacingCg_GP1_CgP1[dim]
- if spacing == "sine":
- listCg_GP1_CgP1[dim, :] = _functions.oscillating_sinspaces(
- amps=self._ampCg_GP1_CgP1[dim],
- periods=self._periodCg_GP1_CgP1[dim],
- phases=self._phaseCg_GP1_CgP1[dim],
- bases=self._base_airplane.Cg_GP1_CgP1[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif spacing == "uniform":
- listCg_GP1_CgP1[dim, :] = _functions.oscillating_linspaces(
- amps=self._ampCg_GP1_CgP1[dim],
- periods=self._periodCg_GP1_CgP1[dim],
- phases=self._phaseCg_GP1_CgP1[dim],
- bases=self._base_airplane.Cg_GP1_CgP1[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif callable(spacing):
- listCg_GP1_CgP1[dim, :] = _functions.oscillating_customspaces(
- amps=self._ampCg_GP1_CgP1[dim],
- periods=self._periodCg_GP1_CgP1[dim],
- phases=self._phaseCg_GP1_CgP1[dim],
- bases=self._base_airplane.Cg_GP1_CgP1[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- custom_function=spacing,
- )
- else:
- raise ValueError(f"Invalid spacing value: {spacing}")
-
- # Check if geometry is static (no periodic motion).
- is_static_geometry = self.max_period == 0.0
-
- if is_static_geometry:
- # Optimization for static geometry: create first Airplane with full mesh
- # generation, then deepcopy for subsequent steps.
- return self._generate_airplanes_static(num_steps, listCg_GP1_CgP1)
- else:
- # For variable geometry, use the standard approach.
- return self._generate_airplanes_variable(
- num_steps, delta_time, listCg_GP1_CgP1
- )
-
- def _generate_airplanes_static(
- self, num_steps: int, listCg_GP1_CgP1: np.ndarray
- ) -> list[geometry.airplane.Airplane]:
- """Generates Airplanes for static geometry using deepcopy optimization.
-
- Creates the first Airplane with full mesh generation, then uses deepcopy for
- subsequent time steps to avoid redundant mesh generation.
-
- :param num_steps: The number of time steps.
- :param listCg_GP1_CgP1: A (3, num_steps) ndarray of Cg positions for each step.
- :return: The list of Airplanes.
- """
- # Generate Wings only for step 0 (all steps have identical geometry).
- first_step_wings = []
- for wing_movement in self._wing_movements:
- step_0_wings = wing_movement.generate_wings(num_steps=1, delta_time=1.0)
- first_step_wings.append(step_0_wings[0])
-
- # Create the first Airplane (triggers full mesh generation).
- first_airplane = geometry.airplane.Airplane(
- wings=first_step_wings,
- name=self._base_airplane.name,
- Cg_GP1_CgP1=listCg_GP1_CgP1[:, 0],
- weight=self._base_airplane.weight,
- )
-
- # Create list with first Airplane.
- airplanes = [first_airplane]
-
- # Create copies for remaining steps with different Cg_GP1_CgP1 positions.
- for step in range(1, num_steps):
- copied_airplane = first_airplane.deep_copy_with_Cg_GP1_CgP1(
- listCg_GP1_CgP1[:, step]
- )
- airplanes.append(copied_airplane)
-
- return airplanes
-
- def _generate_airplanes_variable(
- self, num_steps: int, delta_time: float, listCg_GP1_CgP1: np.ndarray
- ) -> list[geometry.airplane.Airplane]:
- """Generates Airplanes for variable (periodic) geometry.
-
- Uses a conservative optimization approach that validates periodicity before
- applying deepcopy optimization. If validation fails, falls back to standard
- generation.
-
- :param num_steps: The number of time steps.
- :param delta_time: The time between each time step in seconds.
- :param listCg_GP1_CgP1: A (3, num_steps) ndarray of Cg positions for each step.
- :return: The list of Airplanes.
- """
- # Step 1: Calculate geometry LCM period.
- geometry_lcm = self._geometry_lcm_period()
-
- # Step 2: Pre-validation checks.
- # Check if period aligns cleanly with delta_time.
- steps_per_period_float = geometry_lcm / delta_time
- steps_per_period = int(round(steps_per_period_float))
-
- alignment_error = abs(steps_per_period_float - steps_per_period)
- if alignment_error > 1e-6:
- # Period doesn't align with time steps. Fall back to standard generation.
- return self._generate_airplanes_variable_standard(
- num_steps, delta_time, listCg_GP1_CgP1
- )
-
- # Check if there's meaningful benefit.
- if num_steps <= steps_per_period:
- # No repetition occurs. No benefit from optimization.
- return self._generate_airplanes_variable_standard(
- num_steps, delta_time, listCg_GP1_CgP1
- )
-
- # Step 3: Generate first period + one validation step.
- validation_num_steps = steps_per_period + 1
-
- # Create an empty 2D ndarray to hold Wings for validation steps.
- wings = np.empty(
- (len(self._wing_movements), validation_num_steps), dtype=object
- )
-
- # Iterate through the WingMovements.
- for wing_movement_id, wing_movement in enumerate(self._wing_movements):
- this_wings_list_of_wings = np.array(
- wing_movement.generate_wings(
- num_steps=validation_num_steps, delta_time=delta_time
- )
- )
- wings[wing_movement_id, :] = this_wings_list_of_wings
-
- # Step 4: Validate periodicity.
- # Compare step 0 geometry to step steps_per_period.
- if not self._geometry_matches(
- wings_step_a=wings[:, 0],
- wings_step_b=wings[:, steps_per_period],
- tolerance=1e-9,
- ):
- # Geometry doesn't repeat as expected. Fall back to standard generation.
- return self._generate_airplanes_variable_standard(
- num_steps, delta_time, listCg_GP1_CgP1
- )
-
- # Step 5: Create Airplanes for first period.
- this_name = self._base_airplane.name
- this_weight = self._base_airplane.weight
-
- first_period_airplanes = []
- for step in range(steps_per_period):
- thisCg_GP1_CgP1 = listCg_GP1_CgP1[:, step]
- these_wings = list(wings[:, step])
-
- this_airplane = geometry.airplane.Airplane(
- wings=these_wings,
- name=this_name,
- Cg_GP1_CgP1=thisCg_GP1_CgP1,
- weight=this_weight,
- )
- first_period_airplanes.append(this_airplane)
-
- # Step 6: Create copies for remaining steps with different Cg_GP1_CgP1 positions.
- airplanes = list(first_period_airplanes)
- for step in range(steps_per_period, num_steps):
- source_step = step % steps_per_period
- copied_airplane = first_period_airplanes[
- source_step
- ].deep_copy_with_Cg_GP1_CgP1(listCg_GP1_CgP1[:, step])
- airplanes.append(copied_airplane)
-
- return airplanes
-
- def _generate_airplanes_variable_standard(
- self, num_steps: int, delta_time: float, listCg_GP1_CgP1: np.ndarray
- ) -> list[geometry.airplane.Airplane]:
- """Generates Airplanes for variable geometry using standard approach.
-
- Creates new Airplanes for each time step without optimization.
-
- :param num_steps: The number of time steps.
- :param delta_time: The time between each time step in seconds.
- :param listCg_GP1_CgP1: A (3, num_steps) ndarray of Cg positions for each step.
- :return: The list of Airplanes.
- """
- # Create an empty 2D ndarray that will hold each of the Airplane's Wing's vector
- # of Wings representing its changing state at each time step. The first index
- # denotes a particular base Wing, and the second index denotes the time step.
- wings = np.empty((len(self._wing_movements), num_steps), dtype=object)
-
- # Iterate through the WingMovements.
- for wing_movement_id, wing_movement in enumerate(self._wing_movements):
- # Generate this Wing's vector of Wings representing its changing state at
- # each time step.
- this_wings_list_of_wings = np.array(
- wing_movement.generate_wings(num_steps=num_steps, delta_time=delta_time)
- )
-
- # Add this vector the Airplane's 2D ndarray of Wings' Wings.
- wings[wing_movement_id, :] = this_wings_list_of_wings
-
- # Create an empty list to hold each time step's Airplane.
- airplanes = []
-
- # Get the non changing Airplane attributes.
- this_name = self._base_airplane.name
- this_weight = self._base_airplane.weight
-
- # Iterate through the time steps.
- for step in range(num_steps):
- thisCg_GP1_CgP1 = listCg_GP1_CgP1[:, step]
- these_wings = list(wings[:, step])
-
- # Make a new Airplane for this time step.
- this_airplane = geometry.airplane.Airplane(
- wings=these_wings,
- name=this_name,
- Cg_GP1_CgP1=thisCg_GP1_CgP1,
- weight=this_weight,
- )
-
- # Add this new Airplane to the list of Airplanes.
- airplanes.append(this_airplane)
-
- return airplanes
-
- @staticmethod
- def _lcm(a: float, b: float) -> float:
- """Calculates the least common multiple of two numbers.
-
- :param a: First number (period in seconds).
- :param b: Second number (period in seconds).
- :return: LCM of a and b. Returns 0.0 if either input is 0.0.
- """
- if a == 0.0 or b == 0.0:
- return 0.0
- # Convert to integers (periods are typically whole multiples of delta_time).
- # Use sufficiently large multiplier to preserve precision.
- multiplier = 1000000
- a_int = int(round(a * multiplier))
- b_int = int(round(b * multiplier))
- lcm_int = abs(a_int * b_int) // math.gcd(a_int, b_int)
- return lcm_int / multiplier
-
- @staticmethod
- def _lcm_multiple(periods: list[float] | tuple[float, ...]) -> float:
- """Calculates the least common multiple of multiple periods.
-
- :param periods: A list or tuple of periods in seconds.
- :return: LCM of all periods. Returns 0.0 if all periods are 0.0.
- """
- if not periods or all(p == 0.0 for p in periods):
- return 0.0
-
- # Filter out zero periods and calculate LCM.
- non_zero_periods = [p for p in periods if p != 0.0]
-
- result = non_zero_periods[0]
- for period in non_zero_periods[1:]:
- result = AirplaneMovement._lcm(result, period)
- return result
-
- def _geometry_lcm_period(self) -> float:
- """Calculates the LCM of all geometry related periods.
-
- Excludes OperatingPoint periods since those don't affect geometry. Uses the
- all_periods property which already collects only geometry related periods.
-
- :return: The LCM of all geometry related periods in seconds. Returns 0.0 if all
- geometry is static.
- """
- return self._lcm_multiple(self.all_periods)
-
- @staticmethod
- def _geometry_matches(
- wings_step_a: np.ndarray,
- wings_step_b: np.ndarray,
- tolerance: float = 1e-9,
- ) -> bool:
- """Compares two sets of Wings to verify their geometry matches within tolerance.
-
- Checks Wing position (Ler_Gs_Cgs), Wing angles (angles_Gs_to_Wn_ixyz), and Panel
- corner positions (Frpp_G_Cg, Flpp_G_Cg, Blpp_G_Cg, Brpp_G_Cg).
-
- :param wings_step_a: A (num_wings,) ndarray of Wings from the first time step.
- :param wings_step_b: A (num_wings,) ndarray of Wings from the second time step.
- :param tolerance: The tolerance for floating point comparison. The default is
- 1e-9.
- :return: True if all geometry attributes match within tolerance, False
- otherwise.
- """
- if len(wings_step_a) != len(wings_step_b):
- return False
-
- for wing_a, wing_b in zip(wings_step_a, wings_step_b):
- # Check Wing position.
- if not np.allclose(
- wing_a.Ler_Gs_Cgs, wing_b.Ler_Gs_Cgs, atol=tolerance, rtol=0.0
- ):
- return False
-
- # Check Wing angles.
- if not np.allclose(
- wing_a.angles_Gs_to_Wn_ixyz,
- wing_b.angles_Gs_to_Wn_ixyz,
- atol=tolerance,
- rtol=0.0,
- ):
- return False
-
- # Check Panel corner positions if Wings are meshed.
- if wing_a.panels is not None and wing_b.panels is not None:
- if wing_a.panels.shape != wing_b.panels.shape:
- return False
-
- for i in range(wing_a.panels.shape[0]):
- for j in range(wing_a.panels.shape[1]):
- panel_a = wing_a.panels[i, j]
- panel_b = wing_b.panels[i, j]
-
- # Check all four corner positions.
- if not np.allclose(
- panel_a.Frpp_G_Cg,
- panel_b.Frpp_G_Cg,
- atol=tolerance,
- rtol=0.0,
- ):
- return False
- if not np.allclose(
- panel_a.Flpp_G_Cg,
- panel_b.Flpp_G_Cg,
- atol=tolerance,
- rtol=0.0,
- ):
- return False
- if not np.allclose(
- panel_a.Blpp_G_Cg,
- panel_b.Blpp_G_Cg,
- atol=tolerance,
- rtol=0.0,
- ):
- return False
- if not np.allclose(
- panel_a.Brpp_G_Cg,
- panel_b.Brpp_G_Cg,
- atol=tolerance,
- rtol=0.0,
- ):
- return False
-
- return True
diff --git a/pterasoftware/movements/free_flight_airplane_movement.py b/pterasoftware/movements/free_flight_airplane_movement.py
new file mode 100644
index 000000000..0043d1956
--- /dev/null
+++ b/pterasoftware/movements/free_flight_airplane_movement.py
@@ -0,0 +1,140 @@
+"""Contains the FreeFlightAirplaneMovement class.
+
+**Contains the following classes:**
+
+FreeFlightAirplaneMovement: A class used to contain an Airplane's movement in a free
+flight simulation.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+from typing import cast
+
+import numpy as np
+
+from .. import _core, geometry
+from . import free_flight_wing_movement as free_flight_wing_movement_mod
+
+
+class FreeFlightAirplaneMovement(_core.CoreAirplaneMovement):
+ """A class used to contain an Airplane's movement in a free flight simulation.
+
+ In free flight, airplane geometry is prescribed (the same oscillation based
+ generation as AirplaneMovement). This class exists so that a FreeFlightMovement
+ always accepts FreeFlightAirplaneMovements, keeping the free flight movement
+ hierarchy consistently named.
+
+ **Contains the following methods:**
+
+ __deepcopy__: Creates a deep copy of this FreeFlightAirplaneMovement.
+
+ all_periods: All unique non zero periods from this FreeFlightAirplaneMovement, its
+ FreeFlightWingMovement(s), and their FreeFlightWingCrossSectionMovements.
+
+ max_period: The longest period of FreeFlightAirplaneMovement's own motion, the
+ motion(s) of its sub movement object(s), and the motions of its sub sub movement
+ objects.
+
+ generate_airplane_at_time_step: Creates the Airplane at a single time step.
+
+ generate_airplanes: Creates the Airplane at each time step, and returns them in a
+ list.
+ """
+
+ __slots__ = ()
+
+ def __init__(
+ self,
+ base_airplane: geometry.airplane.Airplane,
+ wing_movements: list[free_flight_wing_movement_mod.FreeFlightWingMovement],
+ ampCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1: np.ndarray | Sequence[str | Callable[[float], float]] = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ ) -> None:
+ """The initialization method.
+
+ :param base_airplane: The base Airplane from which the Airplane at each time
+ step will be created.
+ :param wing_movements: A list of the FreeFlightWingMovements associated with
+ each of the base Airplane's Wings. It must have the same length as the base
+ Airplane's list of Wings.
+ :param ampCg_GP1_CgP1: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the amplitudes of the
+ FreeFlightAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1
+ parameters. Can be a tuple, list, or ndarray. Values are converted to floats
+ internally. Each amplitude must be low enough that it doesn't drive its base
+ value out of the range of valid values. Otherwise, this
+ FreeFlightAirplaneMovement will try to create Airplanes with invalid
+ parameter values. Because the first Airplane's Cg_GP1_CgP1 parameter must be
+ all zeros, this means that the first Airplane's ampCg_GP1_CgP1 parameter
+ must also be all zeros. The units are in meters. The default is (0.0, 0.0,
+ 0.0).
+ :param periodCg_GP1_CgP1: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the periods of the
+ FreeFlightAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1
+ parameters. Can be a tuple, list, or ndarray. Values are converted to floats
+ internally. Each element must be 0.0 if the corresponding element in
+ ampCg_GP1_CgP1 is 0.0 and non zero if not. The units are in seconds. The
+ default is (0.0, 0.0, 0.0).
+ :param spacingCg_GP1_CgP1: An array-like object of strs or callables with shape
+ (3,) representing the spacing of the FreeFlightAirplaneMovement's changes in
+ its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or ndarray.
+ Each element can be the str "sine", the str "uniform", or a callable custom
+ spacing function. Custom spacing functions are for advanced users and must
+ start at 0.0, return to 0.0 after one period of 2.0 * pi radians, have
+ amplitude of 1.0, be periodic, return finite values only, and accept a float
+ as input and return a float. Custom functions are scaled by ampCg_GP1_CgP1,
+ shifted horizontally and vertically by phaseCg_GP1_CgP1 and the base value,
+ and have a period set by periodCg_GP1_CgP1. The default is ("sine", "sine",
+ "sine").
+ :param phaseCg_GP1_CgP1: An array-like object of numbers (int or float) with
+ shape (3,) representing the phase offsets of the elements in the first time
+ step's Airplane's Cg_GP1_CgP1 parameter relative to the base Airplane's
+ Cg_GP1_CgP1 parameter. Can be a tuple, list, or ndarray. Elements must lie
+ in the range (-180.0, 180.0]. Each element must be 0.0 if the corresponding
+ element in ampCg_GP1_CgP1 is 0.0 and non zero if not. Values are converted
+ to floats internally. The units are in degrees. The default is (0.0, 0.0,
+ 0.0).
+ :return: None
+ """
+ # Validate that every element is a FreeFlightWingMovement, not just a
+ # CoreWingMovement. CoreAirplaneMovement.__init__() validates at the Core
+ # level, but FreeFlightAirplaneMovement enforces the stricter type.
+ for wing_movement in wing_movements:
+ if not isinstance(
+ wing_movement,
+ free_flight_wing_movement_mod.FreeFlightWingMovement,
+ ):
+ raise TypeError(
+ "Every element in wing_movements must be a "
+ "FreeFlightWingMovement."
+ )
+
+ super().__init__(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=ampCg_GP1_CgP1,
+ periodCg_GP1_CgP1=periodCg_GP1_CgP1,
+ spacingCg_GP1_CgP1=spacingCg_GP1_CgP1,
+ phaseCg_GP1_CgP1=phaseCg_GP1_CgP1,
+ )
+
+ # --- Immutable: read only properties ---
+ @property
+ def wing_movements(
+ self,
+ ) -> tuple[free_flight_wing_movement_mod.FreeFlightWingMovement, ...]:
+ return cast(
+ tuple[free_flight_wing_movement_mod.FreeFlightWingMovement, ...],
+ self._wing_movements,
+ )
diff --git a/pterasoftware/movements/free_flight_movement.py b/pterasoftware/movements/free_flight_movement.py
new file mode 100644
index 000000000..969fb0a39
--- /dev/null
+++ b/pterasoftware/movements/free_flight_movement.py
@@ -0,0 +1,222 @@
+"""Contains the FreeFlightMovement class.
+
+**Contains the following classes:**
+
+FreeFlightMovement: A class used to contain a FreeFlightUnsteadyProblem's movement.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from typing import cast
+
+from .. import _core, _parameter_validation, geometry
+from . import free_flight_airplane_movement as free_flight_airplane_movement_mod
+from . import (
+ free_flight_operating_point_movement as free_flight_operating_point_movement_mod,
+)
+
+
+class FreeFlightMovement(_core.CoreMovement):
+ """A class used to contain a FreeFlightUnsteadyProblem's movement.
+
+ In free flight, airplane geometry is prescribed (flapping, CG oscillation, etc.) but
+ OperatingPoints are dynamically determined by the solver as it integrates rigid body
+ dynamics at each time step. FreeFlightMovement pre generates all Airplanes upfront
+ and provides a FreeFlightOperatingPointMovement whose mutable operating_points list
+ the solver populates during simulation.
+
+ The simulation is divided into two phases. During the prescribed phase, the solver
+ uses the operating conditions from the initial OperatingPoint. During the free
+ flight phase, the solver integrates rigid body dynamics using MuJoCo and creates new
+ OperatingPoints from the resulting state at each time step.
+
+ **Contains the following methods:**
+
+ lcm_period: The least common multiple of all motion periods, ensuring all motions
+ complete an integer number of cycles when cycle averaging forces and moments.
+
+ max_period: The longest period of motion of FreeFlightMovement's sub movement
+ objects, the motion(s) of its sub sub movement object(s), and the motions of its sub
+ sub sub movement objects.
+
+ min_period: The shortest non zero period of motion of FreeFlightMovement's sub
+ movement objects, the motion(s) of its sub sub movement object(s), and the motions
+ of its sub sub sub movement objects.
+
+ static: Flags if FreeFlightMovement's sub movement objects, its sub sub movement
+ object(s), and its sub sub sub movement objects all represent no motion.
+ """
+
+ __slots__ = (
+ "_prescribed_num_steps",
+ "_free_num_steps",
+ "_airplanes",
+ )
+
+ def __init__(
+ self,
+ airplane_movements: list[
+ free_flight_airplane_movement_mod.FreeFlightAirplaneMovement
+ ],
+ operating_point_movement: free_flight_operating_point_movement_mod.FreeFlightOperatingPointMovement,
+ delta_time: float | int,
+ prescribed_num_steps: int,
+ free_num_steps: int,
+ max_wake_rows: int | None = None,
+ ) -> None:
+ """The initialization method.
+
+ This method checks that all Wings maintain their symmetry type across all time
+ steps. See the WingMovement class documentation for more details on this
+ requirement. See the Wing class documentation for more information on symmetry
+ types.
+
+ :param airplane_movements: A list of the FreeFlightAirplaneMovements associated
+ with each of the FreeFlightUnsteadyProblem's Airplanes.
+ :param operating_point_movement: A FreeFlightOperatingPointMovement holding the
+ initial OperatingPoint. The solver populates its mutable operating_points
+ list during simulation.
+ :param delta_time: The time, in seconds, between each time step. It must be a
+ positive number (int or float). It will be converted internally to a float.
+ :param prescribed_num_steps: The number of prescribed flight time steps to
+ simulate before the free flight time steps. It must be a positive int.
+ :param free_num_steps: The number of free flight time steps to simulate after
+ the prescribed time steps. It must be a positive int.
+ :param max_wake_rows: The maximum number of chordwise wake RingVortex rows per
+ Wing. Must be a positive int if set. The default is None (no truncation).
+ :return: None
+ """
+ # Validate that every element is a FreeFlightAirplaneMovement, not just
+ # a CoreAirplaneMovement. CoreMovement.__init__() validates at the Core
+ # level, but FreeFlightMovement enforces the stricter type.
+ for airplane_movement in airplane_movements:
+ if not isinstance(
+ airplane_movement,
+ free_flight_airplane_movement_mod.FreeFlightAirplaneMovement,
+ ):
+ raise TypeError(
+ "Every element in airplane_movements must be a "
+ "FreeFlightAirplaneMovement."
+ )
+
+ # Validate that operating_point_movement is a
+ # FreeFlightOperatingPointMovement.
+ if not isinstance(
+ operating_point_movement,
+ free_flight_operating_point_movement_mod.FreeFlightOperatingPointMovement,
+ ):
+ raise TypeError(
+ "operating_point_movement must be a "
+ "FreeFlightOperatingPointMovement."
+ )
+
+ # Validate and store the phase step counts.
+ prescribed_num_steps = _parameter_validation.int_in_range_return_int(
+ prescribed_num_steps,
+ "prescribed_num_steps",
+ min_val=1,
+ min_inclusive=True,
+ )
+ free_num_steps = _parameter_validation.int_in_range_return_int(
+ free_num_steps,
+ "free_num_steps",
+ min_val=1,
+ min_inclusive=True,
+ )
+ num_steps = prescribed_num_steps + free_num_steps
+
+ # --- Initialize CoreMovement ---
+ super().__init__(
+ airplane_movements=airplane_movements,
+ operating_point_movement=operating_point_movement,
+ delta_time=delta_time,
+ num_steps=num_steps,
+ max_wake_rows=max_wake_rows,
+ )
+
+ # --- Store FreeFlightMovement only attributes ---
+ self._prescribed_num_steps = prescribed_num_steps
+ self._free_num_steps = free_num_steps
+
+ # --- Batch generate Airplanes ---
+ # Generate a list of lists of Airplanes that are the steps through each
+ # FreeFlightAirplaneMovement. The first index identifies the
+ # FreeFlightAirplaneMovement, and the second index identifies the time
+ # step.
+ airplanes_temp: list[list[geometry.airplane.Airplane]] = []
+ for airplane_movement in self.airplane_movements:
+ airplanes_temp.append(
+ airplane_movement.generate_airplanes(
+ num_steps=self._num_steps, delta_time=self._delta_time
+ )
+ )
+
+ # Validate that all Wings maintain their symmetry type across all time
+ # steps.
+ for airplane_movement_id, airplane_list in enumerate(airplanes_temp):
+ # Get the base Airplane (first time step).
+ base_airplane = airplane_list[0]
+
+ # Store the symmetry types of the base Wings.
+ base_wing_symmetry_types = []
+ for wing in base_airplane.wings:
+ base_wing_symmetry_types.append(wing.symmetry_type)
+
+ # Validate all subsequent time steps.
+ for step_id, airplane in enumerate(airplane_list):
+ # Check that Wings maintain their symmetry types.
+ for wing_id, wing in enumerate(airplane.wings):
+ base_symmetry_type = base_wing_symmetry_types[wing_id]
+ if wing.symmetry_type != base_symmetry_type:
+ raise ValueError(
+ f"Wing {wing_id} in FreeFlightAirplaneMovement "
+ f"{airplane_movement_id} changed from type "
+ f"{base_symmetry_type} symmetry at time step 0 "
+ f"to type {wing.symmetry_type} symmetry at time "
+ f"step {step_id}. Wings cannot undergo motion "
+ f"that changes their symmetry type. This happens "
+ f"when a symmetric Wing moves such that its "
+ f"symmetry plane is no longer coincident with "
+ f"the wing axes' yz plane or vice versa."
+ )
+
+ # Store as tuple of tuples to prevent external mutation.
+ self._airplanes: tuple[tuple[geometry.airplane.Airplane, ...], ...] = tuple(
+ tuple(airplane_list) for airplane_list in airplanes_temp
+ )
+
+ # --- Immutable: read only properties ---
+ @property
+ def operating_point_movement(
+ self,
+ ) -> free_flight_operating_point_movement_mod.FreeFlightOperatingPointMovement:
+ assert isinstance(
+ self._operating_point_movement,
+ free_flight_operating_point_movement_mod.FreeFlightOperatingPointMovement,
+ )
+ return self._operating_point_movement
+
+ @property
+ def airplane_movements(
+ self,
+ ) -> tuple[free_flight_airplane_movement_mod.FreeFlightAirplaneMovement, ...]:
+ return cast(
+ tuple[free_flight_airplane_movement_mod.FreeFlightAirplaneMovement, ...],
+ self._airplane_movements,
+ )
+
+ @property
+ def prescribed_num_steps(self) -> int:
+ return self._prescribed_num_steps
+
+ @property
+ def free_num_steps(self) -> int:
+ return self._free_num_steps
+
+ @property
+ def airplanes(self) -> tuple[tuple[geometry.airplane.Airplane, ...], ...]:
+ return self._airplanes
diff --git a/pterasoftware/movements/free_flight_operating_point_movement.py b/pterasoftware/movements/free_flight_operating_point_movement.py
new file mode 100644
index 000000000..31cfe7f8c
--- /dev/null
+++ b/pterasoftware/movements/free_flight_operating_point_movement.py
@@ -0,0 +1,59 @@
+"""Contains the FreeFlightOperatingPointMovement class.
+
+**Contains the following classes:**
+
+FreeFlightOperatingPointMovement: A class used to contain an OperatingPoint's movements
+in a free flight simulation.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from .. import _core
+from .. import operating_point as operating_point_mod
+
+
+class FreeFlightOperatingPointMovement(_core.CoreOperatingPointMovement):
+ """A class used to contain an OperatingPoint's movements in a free flight
+ simulation.
+
+ In free flight, OperatingPoints are not prescribed via oscillation parameters. They
+ are dynamically determined by the solver as it integrates rigid body dynamics at
+ each time step. FreeFlightOperatingPointMovement holds the initial OperatingPoint
+ and provides a mutable list that the solver populates as dynamics integration
+ produces new states.
+
+ **Contains the following methods:**
+
+ max_period: FreeFlightOperatingPointMovement's longest period of motion.
+
+ generate_operating_point_at_time_step: Creates the OperatingPoint at a single time
+ step.
+
+ generate_operating_points: Creates the OperatingPoint at each time step, and returns
+ them in a list.
+ """
+
+ __slots__ = ("operating_points",)
+
+ def __init__(
+ self,
+ base_operating_point: operating_point_mod.OperatingPoint,
+ ) -> None:
+ """The initialization method.
+
+ :param base_operating_point: The initial OperatingPoint representing the
+ operating conditions at the start of the simulation.
+ :return: None
+ """
+ super().__init__(base_operating_point=base_operating_point)
+
+ # Mutable list of OperatingPoints. The solver appends new
+ # OperatingPoints from dynamics integration at each time step. Starts
+ # with the base OperatingPoint at step 0.
+ self.operating_points: list[operating_point_mod.OperatingPoint] = [
+ base_operating_point
+ ]
diff --git a/pterasoftware/movements/free_flight_wing_cross_section_movement.py b/pterasoftware/movements/free_flight_wing_cross_section_movement.py
new file mode 100644
index 000000000..9967928f7
--- /dev/null
+++ b/pterasoftware/movements/free_flight_wing_cross_section_movement.py
@@ -0,0 +1,171 @@
+"""Contains the FreeFlightWingCrossSectionMovement class.
+
+**Contains the following classes:**
+
+FreeFlightWingCrossSectionMovement: A class used to contain a WingCrossSection's
+movement in a free flight simulation.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+
+import numpy as np
+
+from .. import _core, geometry
+
+
+class FreeFlightWingCrossSectionMovement(_core.CoreWingCrossSectionMovement):
+ """A class used to contain a WingCrossSection's movement in a free flight
+ simulation.
+
+ In free flight, wing cross section geometry is prescribed (the same oscillation
+ based generation as WingCrossSectionMovement). This class exists so that a
+ FreeFlightWingMovement always accepts FreeFlightWingCrossSectionMovements, keeping
+ the free flight movement hierarchy consistently named.
+
+ **Contains the following methods:**
+
+ __deepcopy__: Creates a deep copy of this FreeFlightWingCrossSectionMovement.
+
+ all_periods: All unique non zero periods from this
+ FreeFlightWingCrossSectionMovement.
+
+ max_period: FreeFlightWingCrossSectionMovement's longest period of motion.
+
+ generate_wing_cross_section_at_time_step: Creates the WingCrossSection at a single
+ time step.
+
+ generate_wing_cross_sections: Creates the WingCrossSection at each time step, and
+ returns them in a list.
+ """
+
+ __slots__ = ()
+
+ def __init__(
+ self,
+ base_wing_cross_section: geometry.wing_cross_section.WingCrossSection,
+ ampLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp: np.ndarray | Sequence[str | Callable[[float], float]] = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ periodAngles_Wcsp_to_Wcs_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ spacingAngles_Wcsp_to_Wcs_ixyz: (
+ np.ndarray | Sequence[str | Callable[[float], float]]
+ ) = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseAngles_Wcsp_to_Wcs_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ ) -> None:
+ """The initialization method.
+
+ :param base_wing_cross_section: The base WingCrossSection from which the
+ WingCrossSection at each time step will be created.
+ :param ampLp_Wcsp_Lpp: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the amplitudes of the
+ FreeFlightWingCrossSectionMovement's changes in its WingCrossSections'
+ Lp_Wcsp_Lpp parameters. Can be a tuple, list, or ndarray. Values are
+ converted to floats internally. Each amplitude must be low enough that it
+ doesn't drive its base value out of the range of valid values. Otherwise,
+ this FreeFlightWingCrossSectionMovement will try to create WingCrossSections
+ with invalid parameter values. The units are in meters. The default is (0.0,
+ 0.0, 0.0).
+ :param periodLp_Wcsp_Lpp: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the periods of the
+ FreeFlightWingCrossSectionMovement's changes in its WingCrossSections'
+ Lp_Wcsp_Lpp parameters. Can be a tuple, list, or ndarray. Values are
+ converted to floats internally. Each element must be 0.0 if the
+ corresponding element in ampLp_Wcsp_Lpp is 0.0 and non zero if not. The
+ units are in seconds. The default is (0.0, 0.0, 0.0).
+ :param spacingLp_Wcsp_Lpp: An array-like object of strs or callables with shape
+ (3,) representing the spacing of the FreeFlightWingCrossSectionMovement's
+ changes in its WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple,
+ list, or ndarray. Each element can be the str "sine", the str "uniform", or
+ a callable custom spacing function. Custom spacing functions are for
+ advanced users and must start at 0.0, return to 0.0 after one period of 2.0
+ * pi radians, have amplitude of 1.0, be periodic, return finite values only,
+ and accept a float as input and return a float. Custom functions are scaled
+ by ampLp_Wcsp_Lpp, shifted horizontally and vertically by phaseLp_Wcsp_Lpp
+ and the base value, and have a period set by periodLp_Wcsp_Lpp. The default
+ is ("sine", "sine", "sine").
+ :param phaseLp_Wcsp_Lpp: An array-like object of numbers (int or float) with
+ shape (3,) representing the phase offsets of the elements in the first time
+ step's WingCrossSection's Lp_Wcsp_Lpp parameter relative to the base
+ WingCrossSection's Lp_Wcsp_Lpp parameter. Can be a tuple, list, or ndarray.
+ Elements must lie in the range (-180.0, 180.0]. Each element must be 0.0 if
+ the corresponding element in ampLp_Wcsp_Lpp is 0.0 and non zero if not.
+ Values are converted to floats internally. The units are in degrees. The
+ default is (0.0, 0.0, 0.0).
+ :param ampAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative numbers
+ (int or float) with shape (3,) representing the amplitudes of the
+ FreeFlightWingCrossSectionMovement's changes in its WingCrossSections'
+ angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Values
+ are converted to floats internally. Each amplitude must be low enough that
+ it doesn't drive its base value out of the range of valid values. Otherwise,
+ this FreeFlightWingCrossSectionMovement will try to create WingCrossSections
+ with invalid parameter values. The units are in degrees. The default is
+ (0.0, 0.0, 0.0).
+ :param periodAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative
+ numbers (int or float) with shape (3,) representing the periods of the
+ FreeFlightWingCrossSectionMovement's changes in its WingCrossSections'
+ angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Values
+ are converted to floats internally. Each element must be 0.0 if the
+ corresponding element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non zero if
+ not. The units are in seconds. The default is (0.0, 0.0, 0.0).
+ :param spacingAngles_Wcsp_to_Wcs_ixyz: An array-like object of strs or callables
+ with shape (3,) representing the spacing of the
+ FreeFlightWingCrossSectionMovement's changes in its WingCrossSections'
+ angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Each
+ element can be the str "sine", the str "uniform", or a callable custom
+ spacing function. Custom spacing functions are for advanced users and must
+ start at 0.0, return to 0.0 after one period of 2.0 * pi radians, have
+ amplitude of 1.0, be periodic, return finite values only, and accept a float
+ as input and return a float. Custom functions are scaled by
+ ampAngles_Wcsp_to_Wcs_ixyz, shifted horizontally and vertically by
+ phaseAngles_Wcsp_to_Wcs_ixyz and the base value, and have a period set by
+ periodAngles_Wcsp_to_Wcs_ixyz. The default is ("sine", "sine", "sine").
+ :param phaseAngles_Wcsp_to_Wcs_ixyz: An array-like object of numbers (int or
+ float) with shape (3,) representing the phase offsets of the elements in the
+ first time step's WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter
+ relative to the base WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter.
+ Can be a tuple, list, or ndarray. Elements must lie in the range (-180.0,
+ 180.0]. Each element must be 0.0 if the corresponding element in
+ ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non zero if not. Values are converted
+ to floats internally. The units are in degrees. The default is (0.0, 0.0,
+ 0.0).
+ :return: None
+ """
+ super().__init__(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=ampLp_Wcsp_Lpp,
+ periodLp_Wcsp_Lpp=periodLp_Wcsp_Lpp,
+ spacingLp_Wcsp_Lpp=spacingLp_Wcsp_Lpp,
+ phaseLp_Wcsp_Lpp=phaseLp_Wcsp_Lpp,
+ ampAngles_Wcsp_to_Wcs_ixyz=ampAngles_Wcsp_to_Wcs_ixyz,
+ periodAngles_Wcsp_to_Wcs_ixyz=periodAngles_Wcsp_to_Wcs_ixyz,
+ spacingAngles_Wcsp_to_Wcs_ixyz=spacingAngles_Wcsp_to_Wcs_ixyz,
+ phaseAngles_Wcsp_to_Wcs_ixyz=phaseAngles_Wcsp_to_Wcs_ixyz,
+ )
diff --git a/pterasoftware/movements/free_flight_wing_movement.py b/pterasoftware/movements/free_flight_wing_movement.py
new file mode 100644
index 000000000..42c5966bf
--- /dev/null
+++ b/pterasoftware/movements/free_flight_wing_movement.py
@@ -0,0 +1,206 @@
+"""Contains the FreeFlightWingMovement class.
+
+**Contains the following classes:**
+
+FreeFlightWingMovement: A class used to contain a Wing's movement in a free flight
+simulation.
+
+**Contains the following functions:**
+
+None
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+
+import numpy as np
+
+from .. import _core, geometry
+from . import (
+ free_flight_wing_cross_section_movement as free_flight_wing_cross_section_movement_mod,
+)
+
+
+class FreeFlightWingMovement(_core.CoreWingMovement):
+ """A class used to contain a Wing's movement in a free flight simulation.
+
+ In free flight, wing geometry is prescribed (the same oscillation based generation
+ as WingMovement). This class exists so that a FreeFlightAirplaneMovement always
+ accepts FreeFlightWingMovements, keeping the free flight movement hierarchy
+ consistently named.
+
+ **Contains the following methods:**
+
+ __deepcopy__: Creates a deep copy of this FreeFlightWingMovement.
+
+ all_periods: All unique non zero periods from this FreeFlightWingMovement and its
+ FreeFlightWingCrossSectionMovements.
+
+ max_period: The longest period of FreeFlightWingMovement's own motion and that of
+ its sub movement objects.
+
+ generate_wing_at_time_step: Creates the Wing at a single time step.
+
+ generate_wings: Creates the Wing at each time step, and returns them in a list.
+
+ **Notes:**
+
+ Wings cannot undergo motion that causes them to switch symmetry types. A transition
+ between types could change the number of Wings and the Panel structure, which is
+ incompatible with the unsteady solver. This happens when a FreeFlightWingMovement
+ defines motion that causes its base Wing's wing axes' yz plane and its symmetry
+ plane to transition from coincident to non coincident, or vice versa. This is
+ checked by this FreeFlightWingMovement's parent FreeFlightAirplaneMovement's parent
+ FreeFlightMovement.
+ """
+
+ __slots__ = ()
+
+ def __init__(
+ self,
+ base_wing: geometry.wing.Wing,
+ wing_cross_section_movements: list[
+ free_flight_wing_cross_section_movement_mod.FreeFlightWingCrossSectionMovement
+ ],
+ ampLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs: np.ndarray | Sequence[str | Callable[[float], float]] = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ spacingAngles_Gs_to_Wn_ixyz: (
+ np.ndarray | Sequence[str | Callable[[float], float]]
+ ) = (
+ "sine",
+ "sine",
+ "sine",
+ ),
+ phaseAngles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ rotationPointOffset_Gs_Ler: np.ndarray | Sequence[float | int] = (
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ ) -> None:
+ """The initialization method.
+
+ :param base_wing: The base Wing from which the Wing at each time step will be
+ created.
+ :param wing_cross_section_movements: A list of
+ FreeFlightWingCrossSectionMovements associated with each of the base Wing's
+ WingCrossSections. It must have the same length as the base Wing's list of
+ WingCrossSections.
+ :param ampLer_Gs_Cgs: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the amplitudes of the
+ FreeFlightWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can be
+ a tuple, list, or ndarray. Values are converted to floats internally. Each
+ amplitude must be low enough that it doesn't drive its base value out of the
+ range of valid values. Otherwise, this FreeFlightWingMovement will try to
+ create Wings with invalid parameters values. The units are in meters. The
+ default is (0.0, 0.0, 0.0).
+ :param periodLer_Gs_Cgs: An array-like object of non negative numbers (int or
+ float) with shape (3,) representing the periods of the
+ FreeFlightWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can be
+ a tuple, list, or ndarray. Values are converted to floats internally. Each
+ element must be 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and
+ non zero if not. The units are in seconds. The default is (0.0, 0.0, 0.0).
+ :param spacingLer_Gs_Cgs: An array-like object of strs or callables with shape
+ (3,) representing the spacing of the FreeFlightWingMovement's change in its
+ Wings' Ler_Gs_Cgs parameters. Can be a tuple, list, or ndarray. Each element
+ can be the string "sine", the string "uniform", or a callable custom spacing
+ function. Custom spacing functions are for advanced users and must start at
+ 0.0, return to 0.0 after one period of 2.0 * pi radians, have amplitude of
+ 1.0, be periodic, return finite values only, and accept a float as input and
+ return a float. The custom function is scaled by ampLer_Gs_Cgs, shifted
+ horizontally and vertically by phaseLer_Gs_Cgs and the base value, and have
+ a period set by periodLer_Gs_Cgs. The default is ("sine", "sine", "sine").
+ :param phaseLer_Gs_Cgs: An array-like object of numbers (int or float) with
+ shape (3,) representing the phase offsets of the elements in the first time
+ step's Wing's Ler_Gs_Cgs parameter relative to the base Wing's Ler_Gs_Cgs
+ parameter. Can be a tuple, list, or ndarray. Values must lie in the range
+ (-180.0, 180.0] and will be converted to floats internally. Each element
+ must be 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and non
+ zero if not. The units are in degrees. The default is (0.0, 0.0, 0.0).
+ :param ampAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float)
+ with shape (3,) representing the amplitudes of the FreeFlightWingMovement's
+ changes in its Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list,
+ or ndarray. Values must lie in the range [0.0, 180.0] and will be converted
+ to floats internally. Each amplitude must be low enough that it doesn't
+ drive its base value out of the range of valid values. Otherwise, this
+ FreeFlightWingMovement will try to create Wings with invalid parameters
+ values. The units are in degrees. The default is (0.0, 0.0, 0.0).
+ :param periodAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or
+ float) with shape (3,) representing the periods of the
+ FreeFlightWingMovement's changes in its Wings' angles_Gs_to_Wn_ixyz
+ parameters. Can be a tuple, list, or ndarray. Values are converted to floats
+ internally. Each element must be 0.0 if the corresponding element in
+ ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in
+ seconds. The default is (0.0, 0.0, 0.0).
+ :param spacingAngles_Gs_to_Wn_ixyz: An array-like object of strs or callables
+ with shape (3,) representing the spacing of the FreeFlightWingMovement's
+ change in its Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list,
+ or ndarray. Each element can be the string "sine", the string "uniform", or
+ a callable custom spacing function. Custom spacing functions are for
+ advanced users and must start at 0.0, return to 0.0 after one period of 2.0
+ * pi radians, have amplitude of 1.0, be periodic, return finite values only,
+ and accept a float as input and return a float. The custom function is
+ scaled by ampAngles_Gs_to_Wn_ixyz, shifted horizontally and vertically by
+ phaseAngles_Gs_to_Wn_ixyz and the base value, with the period set by
+ periodAngles_Gs_to_Wn_ixyz. The default is ("sine", "sine", "sine").
+ :param phaseAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float)
+ with shape (3,) representing the phase offsets of the elements in the first
+ time step's Wing's angles_Gs_to_Wn_ixyz parameter relative to the base
+ Wing's angles_Gs_to_Wn_ixyz parameter. Can be a tuple, list, or ndarray.
+ Values must lie in the range (-180.0, 180.0] and will be converted to floats
+ internally. Each element must be 0.0 if the corresponding element in
+ ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in
+ degrees. The default is (0.0, 0.0, 0.0).
+ :param rotationPointOffset_Gs_Ler: An array-like object of 3 numbers (int or
+ float) representing the position of the rotation point for the Wing's
+ angular motion (in geometry axes after accounting for symmetry, relative to
+ the leading edge root point). Can be a tuple, list, or ndarray. Values are
+ converted to floats internally. This offset defines where the Wing rotates
+ about when angles_Gs_to_Wn_ixyz oscillates. When set to (0, 0, 0), rotation
+ occurs about the leading edge root point (default behavior). The units are
+ in meters. The default is (0.0, 0.0, 0.0).
+ :return: None
+ """
+ # Validate that every element is a FreeFlightWingCrossSectionMovement, not
+ # just a CoreWingCrossSectionMovement. CoreWingMovement.__init__() validates
+ # at the Core level, but FreeFlightWingMovement enforces the stricter type.
+ for wing_cross_section_movement in wing_cross_section_movements:
+ if not isinstance(
+ wing_cross_section_movement,
+ free_flight_wing_cross_section_movement_mod.FreeFlightWingCrossSectionMovement,
+ ):
+ raise TypeError(
+ "Every element in wing_cross_section_movements must "
+ "be a FreeFlightWingCrossSectionMovement."
+ )
+
+ super().__init__(
+ base_wing=base_wing,
+ wing_cross_section_movements=wing_cross_section_movements,
+ ampLer_Gs_Cgs=ampLer_Gs_Cgs,
+ periodLer_Gs_Cgs=periodLer_Gs_Cgs,
+ spacingLer_Gs_Cgs=spacingLer_Gs_Cgs,
+ phaseLer_Gs_Cgs=phaseLer_Gs_Cgs,
+ ampAngles_Gs_to_Wn_ixyz=ampAngles_Gs_to_Wn_ixyz,
+ periodAngles_Gs_to_Wn_ixyz=periodAngles_Gs_to_Wn_ixyz,
+ spacingAngles_Gs_to_Wn_ixyz=spacingAngles_Gs_to_Wn_ixyz,
+ phaseAngles_Gs_to_Wn_ixyz=phaseAngles_Gs_to_Wn_ixyz,
+ rotationPointOffset_Gs_Ler=rotationPointOffset_Gs_Ler,
+ )
diff --git a/pterasoftware/movements/movement.py b/pterasoftware/movements/movement.py
index 6e0f5e44c..fb938b1f7 100644
--- a/pterasoftware/movements/movement.py
+++ b/pterasoftware/movements/movement.py
@@ -13,11 +13,12 @@
import copy
import math
+from typing import cast
import numpy as np
import scipy.optimize as sp_opt
-from .. import _logging, _parameter_validation, _vortices, geometry
+from .. import _core, _logging, _parameter_validation, _vortices, geometry
from .. import operating_point as operating_point_mod
from .. import problems
from . import airplane_movement as airplane_movement_mod
@@ -26,43 +27,7 @@
movement_logger = _logging.get_logger("movements.movement")
-def _lcm(a: float, b: float) -> float:
- """Calculates the least common multiple of two numbers.
-
- :param a: First number (period in seconds)
- :param b: Second number (period in seconds)
- :return: LCM of a and b. Returns 0.0 if either input is 0.0.
- """
- if a == 0.0 or b == 0.0:
- return 0.0
- # Convert to integers (periods are typically whole multiples of delta_time)
- # Use sufficiently large multiplier to preserve precision
- multiplier = 1000000
- a_int = int(round(a * multiplier))
- b_int = int(round(b * multiplier))
- lcm_int = abs(a_int * b_int) // math.gcd(a_int, b_int)
- return lcm_int / multiplier
-
-
-def _lcm_multiple(periods: list[float]) -> float:
- """Calculates the least common multiple of multiple periods.
-
- :param periods: List of periods in seconds
- :return: LCM of all periods. Returns 0.0 if all periods are 0.0.
- """
- if not periods or all(p == 0.0 for p in periods):
- return 0.0
- # Filter out zero periods and calculate LCM
- non_zero_periods = [p for p in periods if p != 0.0]
- if not non_zero_periods:
- return 0.0
- result = non_zero_periods[0]
- for period in non_zero_periods[1:]:
- result = _lcm(result, period)
- return result
-
-
-class Movement:
+class Movement(_core.CoreMovement):
"""A class used to contain an UnsteadyProblem's movement.
**Contains the following methods:**
@@ -83,21 +48,12 @@ class Movement:
"""
__slots__ = (
- "_airplane_movements",
- "_operating_point_movement",
- "_delta_time",
"_num_cycles",
"_num_chords",
- "_num_steps",
- "_max_wake_rows",
"_max_wake_chords",
"_max_wake_cycles",
"_airplanes",
"_operating_points",
- "_lcm_period",
- "_max_period",
- "_min_period",
- "_static",
)
def __init__(
@@ -167,7 +123,11 @@ def __init__(
num_cycles). Must be a positive int if set. The default is None.
:return: None
"""
- # Validate and store immutable attributes.
+ # --- Validate types early ---
+ # CoreMovement.__init__() validates these, but Movement needs to use
+ # the parameters before calling super().__init__() (for delta_time
+ # estimation and period computation). Validate here so that bad types
+ # produce clear TypeErrors rather than AttributeErrors.
if not isinstance(airplane_movements, list):
raise TypeError("airplane_movements must be a list.")
if len(airplane_movements) < 1:
@@ -177,13 +137,9 @@ def __init__(
airplane_movement, airplane_movement_mod.AirplaneMovement
):
raise TypeError(
- "Every element in airplane_movements must be an AirplaneMovement."
+ "Every element in airplane_movements must be an "
+ "AirplaneMovement."
)
- # Store as tuple to prevent external mutation.
- self._airplane_movements: tuple[airplane_movement_mod.AirplaneMovement, ...] = (
- tuple(airplane_movements)
- )
-
if not isinstance(
operating_point_movement,
operating_point_movement_mod.OperatingPointMovement,
@@ -191,15 +147,8 @@ def __init__(
raise TypeError(
"operating_point_movement must be an OperatingPointMovement."
)
- self._operating_point_movement = operating_point_movement
- # Initialize the caches for the properties derived from the immutable
- # attributes. These are initialized early because static is accessed below
- # during __init__ to determine num_steps calculation.
- self._lcm_period: float | None = None
- self._max_period: float | None = None
- self._min_period: float | None = None
- self._static: bool | None = None
+ # --- Resolve delta_time ---
# Track whether iterative optimization should run after analytical.
_should_iteratively_optimize_delta_time: bool = False
@@ -221,7 +170,7 @@ def __init__(
# velocity. This is used as a fallback for static movements and as
# a starting point for the analytical optimization.
delta_times = []
- for airplane_movement in self._airplane_movements:
+ for airplane_movement in airplane_movements:
# TODO: Consider making this also average across each Airplane's Wings.
c_ref = airplane_movement.base_airplane.c_ref
assert c_ref is not None
@@ -235,8 +184,8 @@ def __init__(
# Run analytical optimization to get a better delta_time that accounts
# for both freestream and geometry motion velocities.
delta_time = _analytically_optimize_delta_time(
- airplane_movements=list(self._airplane_movements),
- operating_point_movement=self._operating_point_movement,
+ airplane_movements=list(airplane_movements),
+ operating_point_movement=operating_point_movement,
initial_delta_time=fast_estimate,
)
@@ -244,14 +193,33 @@ def __init__(
# as the initial guess.
if _should_iteratively_optimize_delta_time:
delta_time = _optimize_delta_time(
- airplane_movements=list(self._airplane_movements),
- operating_point_movement=self._operating_point_movement,
+ airplane_movements=list(airplane_movements),
+ operating_point_movement=operating_point_movement,
initial_delta_time=delta_time,
)
- self._delta_time: float = delta_time
- _static = self.static
+ # --- Compute period properties locally ---
+ # These are needed for num_steps and max_wake calculations before
+ # super().__init__() is called. The same values will be lazily cached
+ # by CoreMovement when accessed via properties.
+ _airplane_movement_max_periods = []
+ for airplane_movement in airplane_movements:
+ _airplane_movement_max_periods.append(airplane_movement.max_period)
+ _max_period = max(
+ max(_airplane_movement_max_periods),
+ operating_point_movement.max_period,
+ )
+ _static = _max_period == 0
+
+ _lcm_period: float = 0.0
+ if not _static:
+ _all_periods: list[float] = []
+ for airplane_movement in airplane_movements:
+ _all_periods.extend(airplane_movement.all_periods)
+ _all_periods.append(operating_point_movement.max_period)
+ _lcm_period = _core.lcm_multiple(_all_periods)
+ # --- Resolve num_steps ---
if num_steps is None:
if _static:
if num_cycles is not None:
@@ -272,7 +240,6 @@ def __init__(
min_val=1,
min_inclusive=True,
)
- self._num_cycles = num_cycles
if num_steps is None:
if _static:
@@ -294,9 +261,8 @@ def __init__(
min_val=1,
min_inclusive=True,
)
- self._num_chords = num_chords
- if self._num_cycles is not None or self._num_chords is not None:
+ if num_cycles is not None or num_chords is not None:
if num_steps is not None:
raise ValueError(
"If either num_cycles or num_chords is not None, num_steps must "
@@ -314,7 +280,7 @@ def __init__(
# Find the value of the largest reference chord length of all the
# base Airplanes.
c_refs = []
- for airplane_movement in self._airplane_movements:
+ for airplane_movement in airplane_movements:
c_ref = airplane_movement.base_airplane.c_ref
assert c_ref is not None
c_refs.append(c_ref)
@@ -322,23 +288,20 @@ def __init__(
# Set the number of time steps such that the wake extends back by
# some number of reference chord lengths.
- assert self._num_chords is not None
- wake_length = self._num_chords * max_c_ref
+ assert num_chords is not None
+ wake_length = num_chords * max_c_ref
distance_per_time_step = (
- delta_time
- * self._operating_point_movement.base_operating_point.vCg__E
+ delta_time * operating_point_movement.base_operating_point.vCg__E
)
num_steps = math.ceil(wake_length / distance_per_time_step)
else:
- # Set the number of time steps such that the simulation runs for some
- # number of cycles of all motions. Use the LCM of all periods to ensure
- # each motion completes an integer number of cycles.
- assert self._num_cycles is not None
- num_steps = math.ceil(
- self._num_cycles * self.lcm_period / self._delta_time
- )
- self._num_steps: int = num_steps
+ # Set the number of time steps such that the simulation runs for
+ # some number of cycles of all motions. Use the LCM of all periods
+ # to ensure each motion completes an integer number of cycles.
+ assert num_cycles is not None
+ num_steps = math.ceil(num_cycles * _lcm_period / delta_time)
+ # --- Resolve max_wake_rows ---
# Validate max_wake_* parameters. At most one can be non None.
_num_max_wake_set = sum(
x is not None for x in (max_wake_rows, max_wake_chords, max_wake_cycles)
@@ -358,7 +321,6 @@ def __init__(
min_val=1,
min_inclusive=True,
)
- self._max_wake_chords = max_wake_chords
if max_wake_cycles is not None:
if _static:
@@ -371,48 +333,59 @@ def __init__(
min_val=1,
min_inclusive=True,
)
- self._max_wake_cycles = max_wake_cycles
-
- if max_wake_rows is not None:
- max_wake_rows = _parameter_validation.int_in_range_return_int(
- max_wake_rows,
- "max_wake_rows",
- min_val=1,
- min_inclusive=True,
- )
- # Convert max_wake_chords to max_wake_rows using the same formula as num_chords
- # to num_steps.
- if self._max_wake_chords is not None:
+ # Convert max_wake_chords to max_wake_rows using the same formula as
+ # num_chords to num_steps.
+ if max_wake_chords is not None:
c_refs = []
- for airplane_movement in self._airplane_movements:
+ for airplane_movement in airplane_movements:
c_ref = airplane_movement.base_airplane.c_ref
assert c_ref is not None
c_refs.append(c_ref)
max_c_ref = max(c_refs)
distance_per_time_step = (
- self._delta_time
- * self._operating_point_movement.base_operating_point.vCg__E
+ delta_time * operating_point_movement.base_operating_point.vCg__E
)
max_wake_rows = math.ceil(
- self._max_wake_chords * max_c_ref / distance_per_time_step
+ max_wake_chords * max_c_ref / distance_per_time_step
)
# Convert max_wake_cycles to max_wake_rows using the same formula as
# num_cycles to num_steps.
- if self._max_wake_cycles is not None:
- max_wake_rows = math.ceil(
- self._max_wake_cycles * self.lcm_period / self._delta_time
- )
+ if max_wake_cycles is not None:
+ max_wake_rows = math.ceil(max_wake_cycles * _lcm_period / delta_time)
+
+ # --- Initialize CoreMovement ---
+ super().__init__(
+ airplane_movements=airplane_movements,
+ operating_point_movement=operating_point_movement,
+ delta_time=delta_time,
+ num_steps=num_steps,
+ max_wake_rows=max_wake_rows,
+ )
+
+ # Pre-populate the lazy caches with values already computed above so
+ # that accessing the inherited properties does not redundantly
+ # recompute them. _max_period, _lcm_period, and _static are set here
+ # because they are always needed during __init__ (via the static
+ # check). _min_period remains lazy.
+ self._max_period = _max_period
+ self._lcm_period = _lcm_period
+ self._static = _static
- self._max_wake_rows = max_wake_rows
+ # --- Store Movement only attributes ---
+ self._num_cycles = num_cycles
+ self._num_chords = num_chords
+ self._max_wake_chords = max_wake_chords
+ self._max_wake_cycles = max_wake_cycles
+ # --- Batch generate ---
# Generate a list of lists of Airplanes that are the steps through each
# AirplaneMovement. The first index identifies the AirplaneMovement, and the
# second index identifies the time step.
airplanes_temp: list[list[geometry.airplane.Airplane]] = []
- for airplane_movement in self._airplane_movements:
+ for airplane_movement in self.airplane_movements:
airplanes_temp.append(
airplane_movement.generate_airplanes(
num_steps=self._num_steps, delta_time=self._delta_time
@@ -451,7 +424,7 @@ def __init__(
tuple(airplane_list) for airplane_list in airplanes_temp
)
- # Generate a lists of OperatingPoints that are the steps through the
+ # Generate a list of OperatingPoints that are the steps through the
# OperatingPointMovement.
operating_points_temp = operating_point_movement.generate_operating_points(
num_steps=self._num_steps, delta_time=self._delta_time
@@ -462,19 +435,24 @@ def __init__(
)
# --- Immutable: read only properties ---
- @property
- def airplane_movements(self) -> tuple[airplane_movement_mod.AirplaneMovement, ...]:
- return self._airplane_movements
-
@property
def operating_point_movement(
self,
) -> operating_point_movement_mod.OperatingPointMovement:
+ assert isinstance(
+ self._operating_point_movement,
+ operating_point_movement_mod.OperatingPointMovement,
+ )
return self._operating_point_movement
@property
- def delta_time(self) -> float:
- return self._delta_time
+ def airplane_movements(
+ self,
+ ) -> tuple[airplane_movement_mod.AirplaneMovement, ...]:
+ return cast(
+ tuple[airplane_movement_mod.AirplaneMovement, ...],
+ self._airplane_movements,
+ )
@property
def num_cycles(self) -> int | None:
@@ -484,14 +462,6 @@ def num_cycles(self) -> int | None:
def num_chords(self) -> int | None:
return self._num_chords
- @property
- def num_steps(self) -> int:
- return self._num_steps
-
- @property
- def max_wake_rows(self) -> int | None:
- return self._max_wake_rows
-
@property
def max_wake_chords(self) -> int | None:
return self._max_wake_chords
@@ -508,101 +478,6 @@ def airplanes(self) -> tuple[tuple[geometry.airplane.Airplane, ...], ...]:
def operating_points(self) -> tuple[operating_point_mod.OperatingPoint, ...]:
return self._operating_points
- # --- Immutable derived: manual lazy caching ---
- @property
- def lcm_period(self) -> float:
- """The least common multiple of all motion periods, ensuring all motions
- complete an integer number of cycles when cycle averaging forces and moments.
-
- Using the LCM ensures that when cycle averaging forces and moments, we capture a
- complete cycle of all motions, not just the longest one. For example, if one
- motion has a period of 2.0 s and another has a period of 3.0 s, the LCM is 6.0,
- which contains exactly 3 cycles of the first motion and 2 cycles of the second.
-
- :return: The LCM period in seconds. If all the motion is static, this will be
- 0.0.
- """
- if self._lcm_period is None:
- # Collect all periods from AirplaneMovements
- all_periods: list[float] = []
- for airplane_movement in self._airplane_movements:
- all_periods.extend(airplane_movement.all_periods)
-
- # Add the OperatingPointMovement period
- all_periods.append(self._operating_point_movement.max_period)
-
- self._lcm_period = _lcm_multiple(all_periods)
- return self._lcm_period
-
- @property
- def max_period(self) -> float:
- """The longest period of motion of Movement's sub movement objects, the
- motion(s) of its sub sub movement object(s), and the motions of its sub sub sub
- movement objects.
-
- Note: For cycle averaging calculations, lcm_period should be used instead of
- max_period to ensure all motions complete an integer number of cycles.
-
- :return: The longest period in seconds. If all the motion is static, this will
- be 0.0.
- """
- if self._max_period is None:
- # Iterate through the AirplaneMovements and find the one with the largest
- # max period.
- airplane_movement_max_periods = []
- for airplane_movement in self._airplane_movements:
- airplane_movement_max_periods.append(airplane_movement.max_period)
- max_airplane_period = max(airplane_movement_max_periods)
-
- # The global max period is the maximum of the max AirplaneMovement period
- # and the OperatingPointMovement max period.
- self._max_period = max(
- max_airplane_period,
- self._operating_point_movement.max_period,
- )
- return self._max_period
-
- @property
- def min_period(self) -> float:
- """The shortest non zero period of motion of Movement's sub movement objects,
- the motion(s) of its sub sub movement object(s), and the motions of its sub sub
- sub movement objects.
-
- :return: The shortest non zero period in seconds. If all the motion is static,
- this will be 0.0.
- """
- if self._min_period is None:
- # Collect all periods from AirplaneMovements.
- all_periods: list[float] = []
- for airplane_movement in self._airplane_movements:
- all_periods.extend(airplane_movement.all_periods)
-
- # Add the OperatingPointMovement period.
- op_period = self._operating_point_movement.max_period
- if op_period != 0.0:
- all_periods.append(op_period)
-
- # Filter out zero periods and find the minimum.
- non_zero_periods = [p for p in all_periods if p != 0.0]
- if not non_zero_periods:
- self._min_period = 0.0
- else:
- self._min_period = min(non_zero_periods)
- return self._min_period
-
- @property
- def static(self) -> bool:
- """Flags if the Movement's sub movement objects, its sub sub movement object(s),
- and its sub sub sub movement objects all represent no motion.
-
- :return: True if Movement's sub movement objects, its sub sub movement
- object(s), and its sub sub sub movement objects all represent no motion.
- False otherwise.
- """
- if self._static is None:
- self._static = self.max_period == 0
- return self._static
-
def _compute_wake_area_mismatch(
delta_time: float,
@@ -788,7 +663,7 @@ def _optimize_delta_time(
)
else:
# Non static case: brute force search over integer num_steps_per_lcm_cycle.
- lcm_period = _lcm_multiple(non_zero_periods)
+ lcm_period = _core.lcm_multiple(non_zero_periods)
return _optimize_delta_time_non_static(
airplane_movements=airplane_movements,
operating_point_movement=operating_point_movement,
@@ -1034,7 +909,7 @@ def _analytically_optimize_delta_time(
min_period = min(non_zero_periods)
- lcm_period = _lcm_multiple(non_zero_periods)
+ lcm_period = _core.lcm_multiple(non_zero_periods)
# Step 1: Compute a preliminary delta_time that divides the LCM period into roughly
# min_period / 100 sized steps. Cap at 1000 steps to prevent excessive computation
diff --git a/pterasoftware/movements/operating_point_movement.py b/pterasoftware/movements/operating_point_movement.py
index 741c3f6da..bdca8ef9f 100644
--- a/pterasoftware/movements/operating_point_movement.py
+++ b/pterasoftware/movements/operating_point_movement.py
@@ -13,39 +13,32 @@
from collections.abc import Callable
-import numpy as np
-
-from .. import _parameter_validation
+from .. import _core
from .. import operating_point as operating_point_mod
-from . import _functions
-class OperatingPointMovement:
+class OperatingPointMovement(_core.CoreOperatingPointMovement):
"""A class used to contain an OperatingPoint's movements.
**Contains the following methods:**
max_period: OperatingPointMovement's longest period of motion.
+ generate_operating_point_at_time_step: Creates the OperatingPoint at a single time
+ step.
+
generate_operating_points: Creates the OperatingPoint at each time step, and returns
them in a list.
"""
- __slots__ = (
- "_base_operating_point",
- "_ampVCg__E",
- "_periodVCg__E",
- "_spacingVCg__E",
- "_phaseVCg__E",
- "_max_period",
- )
+ __slots__ = ()
def __init__(
self,
base_operating_point: operating_point_mod.OperatingPoint,
ampVCg__E: float | int = 0.0,
periodVCg__E: float | int = 0.0,
- spacingVCg__E: str | Callable[[np.ndarray], np.ndarray] = "sine",
+ spacingVCg__E: str | Callable[[float], float] = "sine",
phaseVCg__E: float | int = 0.0,
) -> None:
"""The initialization method.
@@ -66,12 +59,12 @@ def __init__(
:param spacingVCg__E: Determines the spacing of the OperatingPointMovement's
change in its OperatingPoints' vCg__E parameters. Can be "sine", "uniform",
or a callable custom spacing function. Custom spacing functions are for
- advanced users and must start at 0.0, return to 0.0 after one period of 2*pi
- radians, have amplitude of 1.0, be periodic, return finite values only, and
- accept a ndarray as input and return a ndarray of the same shape. The custom
- function is scaled by ampVCg__E, shifted horizontally and vertically by
- phaseVCg__E and the base value, and have a period set by periodVCg__E. The
- default is "sine".
+ advanced users and must start at 0.0, return to 0.0 after one period of 2.0
+ * pi radians, have amplitude of 1.0, be periodic, return finite values only,
+ and accept a float as input and return a float. The custom function is
+ scaled by ampVCg__E, shifted horizontally and vertically by phaseVCg__E and
+ the base value, and have a period set by periodVCg__E. The default is
+ "sine".
:param phaseVCg__E: The phase offset of the first time step's OperatingPoint's
vCg__E parameter relative to the base OperatingPoint's vCg__E parameter.
Must be a number (int or float) in the range (-180.0, 180.0], and will be
@@ -79,168 +72,10 @@ def __init__(
zero if not. The units are in degrees. The default is 0.0.
:return: None
"""
- # Validate and store immutable attributes.
- if not isinstance(base_operating_point, operating_point_mod.OperatingPoint):
- raise TypeError("base_operating_point must be an OperatingPoint")
- self._base_operating_point = base_operating_point
-
- self._ampVCg__E = _parameter_validation.number_in_range_return_float(
- ampVCg__E, "ampVCg__E", min_val=0.0, min_inclusive=True
- )
-
- periodVCg__E = _parameter_validation.number_in_range_return_float(
- periodVCg__E, "periodVCg__E", min_val=0.0, min_inclusive=True
- )
- if self._ampVCg__E == 0 and periodVCg__E != 0:
- raise ValueError("If ampVCg__E is 0.0, then periodVCg__E must also be 0.0.")
- self._periodVCg__E = periodVCg__E
-
- if isinstance(spacingVCg__E, str):
- if spacingVCg__E not in ["sine", "uniform"]:
- raise ValueError(
- f"spacingVCg__E must be 'sine', 'uniform', or a callable, "
- f"got string '{spacingVCg__E}'."
- )
- elif not callable(spacingVCg__E):
- raise TypeError(
- f"spacingVCg__E must be 'sine', 'uniform', or a callable, got "
- f"{type(spacingVCg__E).__name__}."
- )
- self._spacingVCg__E = spacingVCg__E
-
- phaseVCg__E = _parameter_validation.number_in_range_return_float(
- phaseVCg__E, "phaseVCg__E", -180.0, False, 180.0, True
+ super().__init__(
+ base_operating_point=base_operating_point,
+ ampVCg__E=ampVCg__E,
+ periodVCg__E=periodVCg__E,
+ spacingVCg__E=spacingVCg__E,
+ phaseVCg__E=phaseVCg__E,
)
- if self._ampVCg__E == 0 and phaseVCg__E != 0:
- raise ValueError("If ampVCg__E is 0.0, then phaseVCg__E must also be 0.0.")
- self._phaseVCg__E = phaseVCg__E
-
- # Initialize the caches for the properties derived from the immutable
- # attributes.
- self._max_period: float | None = None
-
- # --- Immutable: read only properties ---
- @property
- def base_operating_point(self) -> operating_point_mod.OperatingPoint:
- return self._base_operating_point
-
- @property
- def ampVCg__E(self) -> float:
- return self._ampVCg__E
-
- @property
- def periodVCg__E(self) -> float:
- return self._periodVCg__E
-
- @property
- def spacingVCg__E(self) -> str | Callable[[np.ndarray], np.ndarray]:
- return self._spacingVCg__E
-
- @property
- def phaseVCg__E(self) -> float:
- return self._phaseVCg__E
-
- # --- Immutable derived: manual lazy caching ---
- @property
- def max_period(self) -> float:
- """OperatingPointMovement's longest period of motion.
-
- :return: The longest period in seconds. If the motion is static, this will be
- 0.0.
- """
- if self._max_period is None:
- self._max_period = self._periodVCg__E
- return self._max_period
-
- # --- Other methods ---
- def generate_operating_points(
- self, num_steps: int, delta_time: float | int
- ) -> list[operating_point_mod.OperatingPoint]:
- """Creates the OperatingPoint at each time step, and returns them in a list.
-
- :param num_steps: The number of time steps in this movement. It must be a
- positive int.
- :param delta_time: The time between each time step. It must be a positive number
- (int or float), and will be converted internally to a float. The units are
- in seconds.
- :return: The list of OperatingPoints associated with this
- OperatingPointMovement.
- """
- num_steps = _parameter_validation.int_in_range_return_int(
- num_steps,
- "num_steps",
- min_val=1,
- min_inclusive=True,
- )
- delta_time = _parameter_validation.number_in_range_return_float(
- delta_time, "delta_time", min_val=0.0, min_inclusive=False
- )
-
- # Generate oscillating values for VCg__E.
- if self._spacingVCg__E == "sine":
- listVCg__E = _functions.oscillating_sinspaces(
- amps=self._ampVCg__E,
- periods=self._periodVCg__E,
- phases=self._phaseVCg__E,
- bases=self._base_operating_point.vCg__E,
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif self._spacingVCg__E == "uniform":
- listVCg__E = _functions.oscillating_linspaces(
- amps=self._ampVCg__E,
- periods=self._periodVCg__E,
- phases=self._phaseVCg__E,
- bases=self._base_operating_point.vCg__E,
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif callable(self._spacingVCg__E):
- listVCg__E = _functions.oscillating_customspaces(
- amps=self._ampVCg__E,
- periods=self._periodVCg__E,
- phases=self._phaseVCg__E,
- bases=self._base_operating_point.vCg__E,
- num_steps=num_steps,
- delta_time=delta_time,
- custom_function=self._spacingVCg__E,
- )
- else:
- raise ValueError(f"Invalid spacing value: {self._spacingVCg__E}")
-
- # Create an empty list to hold each time step's OperatingPoint.
- operating_points = []
-
- # Get the non changing OperatingPoint attributes.
- this_rho = self._base_operating_point.rho
- this_alpha = self._base_operating_point.alpha
- this_beta = self._base_operating_point.beta
- thisExternalFX_W = self._base_operating_point.externalFX_W
- this_nu = self._base_operating_point.nu
- thisAngles_E_to_BP1_izyx = self._base_operating_point.angles_E_to_BP1_izyx
- thisCgP1_E_Eo = self._base_operating_point.CgP1_E_Eo
- thisSurfaceNormal_E = self._base_operating_point.surfaceNormal_E
- thisSurfacePoint_E_Eo = self._base_operating_point.surfacePoint_E_Eo
-
- # Iterate through the time steps.
- for step in range(num_steps):
- thisVCg__E = listVCg__E[step]
-
- # Make a new operating point object for this time step.
- this_operating_point = operating_point_mod.OperatingPoint(
- rho=this_rho,
- vCg__E=thisVCg__E,
- alpha=this_alpha,
- beta=this_beta,
- externalFX_W=thisExternalFX_W,
- nu=this_nu,
- angles_E_to_BP1_izyx=thisAngles_E_to_BP1_izyx,
- CgP1_E_Eo=thisCgP1_E_Eo,
- surfaceNormal_E=thisSurfaceNormal_E,
- surfacePoint_E_Eo=thisSurfacePoint_E_Eo,
- )
-
- # Add this new OperatingPoint to the list of OperatingPoints.
- operating_points.append(this_operating_point)
-
- return operating_points
diff --git a/pterasoftware/movements/wing_cross_section_movement.py b/pterasoftware/movements/wing_cross_section_movement.py
index 68f6269c6..535dc66b0 100644
--- a/pterasoftware/movements/wing_cross_section_movement.py
+++ b/pterasoftware/movements/wing_cross_section_movement.py
@@ -11,16 +11,14 @@
from __future__ import annotations
-import copy
from collections.abc import Callable, Sequence
import numpy as np
-from .. import _parameter_validation, geometry
-from . import _functions
+from .. import _core, geometry
-class WingCrossSectionMovement:
+class WingCrossSectionMovement(_core.CoreWingCrossSectionMovement):
"""A class used to contain a WingCrossSection's movement.
**Contains the following methods:**
@@ -31,32 +29,21 @@ class WingCrossSectionMovement:
max_period: WingCrossSectionMovement's longest period of motion.
+ generate_wing_cross_section_at_time_step: Creates the WingCrossSection at a single
+ time step.
+
generate_wing_cross_sections: Creates the WingCrossSection at each time step, and
returns them in a list.
"""
- __slots__ = (
- "_base_wing_cross_section",
- "_ampLp_Wcsp_Lpp",
- "_periodLp_Wcsp_Lpp",
- "_spacingLp_Wcsp_Lpp",
- "_phaseLp_Wcsp_Lpp",
- "_ampAngles_Wcsp_to_Wcs_ixyz",
- "_periodAngles_Wcsp_to_Wcs_ixyz",
- "_spacingAngles_Wcsp_to_Wcs_ixyz",
- "_phaseAngles_Wcsp_to_Wcs_ixyz",
- "_all_periods",
- "_max_period",
- )
+ __slots__ = ()
def __init__(
self,
base_wing_cross_section: geometry.wing_cross_section.WingCrossSection,
ampLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
periodLp_Wcsp_Lpp: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp: (
- np.ndarray | Sequence[str | Callable[[np.ndarray], np.ndarray]]
- ) = (
+ spacingLp_Wcsp_Lpp: np.ndarray | Sequence[str | Callable[[float], float]] = (
"sine",
"sine",
"sine",
@@ -73,7 +60,7 @@ def __init__(
0.0,
),
spacingAngles_Wcsp_to_Wcs_ixyz: (
- np.ndarray | Sequence[str | Callable[[np.ndarray], np.ndarray]]
+ np.ndarray | Sequence[str | Callable[[float], float]]
) = (
"sine",
"sine",
@@ -109,12 +96,12 @@ def __init__(
its WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, list, or
ndarray. Each element can be the str "sine", the str "uniform", or a
callable custom spacing function. Custom spacing functions are for advanced
- users and must start at 0.0, return to 0.0 after one period of 2*pi radians,
- have amplitude of 1.0, be periodic, return finite values only, and accept a
- ndarray as input and return a ndarray of the same shape. Custom functions
- are scaled by ampLp_Wcsp_Lpp, shifted horizontally and vertically by
- phaseLp_Wcsp_Lpp and the base value, and have a period set by
- periodLp_Wcsp_Lpp. The default is ("sine", "sine", "sine").
+ users and must start at 0.0, return to 0.0 after one period of 2.0 * pi
+ radians, have amplitude of 1.0, be periodic, return finite values only, and
+ accept a float as input and return a float. Custom functions are scaled by
+ ampLp_Wcsp_Lpp, shifted horizontally and vertically by phaseLp_Wcsp_Lpp and
+ the base value, and have a period set by periodLp_Wcsp_Lpp. The default is
+ ("sine", "sine", "sine").
:param phaseLp_Wcsp_Lpp: An array-like object of numbers (int or float) with
shape (3,) representing the phase offsets of the elements in the first time
step's WingCrossSection's Lp_Wcsp_Lpp parameter relative to the base
@@ -145,12 +132,12 @@ def __init__(
a tuple, list, or ndarray. Each element can be the str "sine", the str
"uniform", or a callable custom spacing function. Custom spacing functions
are for advanced users and must start at 0.0, return to 0.0 after one period
- of 2*pi radians, have amplitude of 1.0, be periodic, return finite values
- only, and accept a ndarray as input and return a ndarray of the same shape.
- Custom functions are scaled by ampAngles_Wcsp_to_Wcs_ixyz, shifted
- horizontally and vertically by phaseAngles_Wcsp_to_Wcs_ixyz and the base
- value, and have a period set by periodAngles_Wcsp_to_Wcs_ixyz. The default
- is ("sine", "sine", "sine").
+ of 2.0 * pi radians, have amplitude of 1.0, be periodic, return finite
+ values only, and accept a float as input and return a float. Custom
+ functions are scaled by ampAngles_Wcsp_to_Wcs_ixyz, shifted horizontally and
+ vertically by phaseAngles_Wcsp_to_Wcs_ixyz and the base value, and have a
+ period set by periodAngles_Wcsp_to_Wcs_ixyz. The default is ("sine", "sine",
+ "sine").
:param phaseAngles_Wcsp_to_Wcs_ixyz: An array-like object of numbers (int or
float) with shape (3,) representing the phase offsets of the elements in the
first time step's WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter
@@ -162,415 +149,14 @@ def __init__(
0.0).
:return: None
"""
- # Validate and store immutable attributes. Set those that are numpy arrays to
- # be read only.
- if not isinstance(
- base_wing_cross_section, geometry.wing_cross_section.WingCrossSection
- ):
- raise TypeError("base_wing_cross_section must be a WingCrossSection.")
- self._base_wing_cross_section = base_wing_cross_section
-
- ampLp_Wcsp_Lpp = _parameter_validation.threeD_number_vectorLike_return_float(
- ampLp_Wcsp_Lpp, "ampLp_Wcsp_Lpp"
- )
- if not np.all(ampLp_Wcsp_Lpp >= 0.0):
- raise ValueError("All elements in ampLp_Wcsp_Lpp must be non negative.")
- self._ampLp_Wcsp_Lpp = ampLp_Wcsp_Lpp
- self._ampLp_Wcsp_Lpp.flags.writeable = False
-
- periodLp_Wcsp_Lpp = _parameter_validation.threeD_number_vectorLike_return_float(
- periodLp_Wcsp_Lpp, "periodLp_Wcsp_Lpp"
- )
- if not np.all(periodLp_Wcsp_Lpp >= 0.0):
- raise ValueError("All elements in periodLp_Wcsp_Lpp must be non negative.")
- for period_index, period in enumerate(periodLp_Wcsp_Lpp):
- amp = self._ampLp_Wcsp_Lpp[period_index]
- if amp == 0 and period != 0:
- raise ValueError(
- "If an element in ampLp_Wcsp_Lpp is 0.0, the corresponding "
- "element in periodLp_Wcsp_Lpp must be also be 0.0."
- )
- self._periodLp_Wcsp_Lpp = periodLp_Wcsp_Lpp
- self._periodLp_Wcsp_Lpp.flags.writeable = False
-
- # Store as tuple to prevent external mutation.
- self._spacingLp_Wcsp_Lpp = (
- _parameter_validation.threeD_spacing_vectorLike_return_tuple(
- spacingLp_Wcsp_Lpp, "spacingLp_Wcsp_Lpp"
- )
- )
-
- phaseLp_Wcsp_Lpp = _parameter_validation.threeD_number_vectorLike_return_float(
- phaseLp_Wcsp_Lpp, "phaseLp_Wcsp_Lpp"
- )
- if not (
- np.all(phaseLp_Wcsp_Lpp > -180.0) and np.all(phaseLp_Wcsp_Lpp <= 180.0)
- ):
- raise ValueError(
- "All elements in phaseLp_Wcsp_Lpp must be in the range (-180.0, 180.0]."
- )
- for phase_index, phase in enumerate(phaseLp_Wcsp_Lpp):
- amp = self._ampLp_Wcsp_Lpp[phase_index]
- if amp == 0 and phase != 0:
- raise ValueError(
- "If an element in ampLp_Wcsp_Lpp is 0.0, the corresponding "
- "element in phaseLp_Wcsp_Lpp must be also be 0.0."
- )
- self._phaseLp_Wcsp_Lpp = phaseLp_Wcsp_Lpp
- self._phaseLp_Wcsp_Lpp.flags.writeable = False
-
- ampAngles_Wcsp_to_Wcs_ixyz = (
- _parameter_validation.threeD_number_vectorLike_return_float(
- ampAngles_Wcsp_to_Wcs_ixyz, "ampAngles_Wcsp_to_Wcs_ixyz"
- )
- )
- if not (
- np.all(ampAngles_Wcsp_to_Wcs_ixyz >= 0.0)
- and np.all(ampAngles_Wcsp_to_Wcs_ixyz <= 180.0)
- ):
- raise ValueError(
- "All elements in ampAngles_Wcsp_to_Wcs_ixyz must be in the range ["
- "0.0, 180.0]."
- )
- self._ampAngles_Wcsp_to_Wcs_ixyz = ampAngles_Wcsp_to_Wcs_ixyz
- self._ampAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
-
- periodAngles_Wcsp_to_Wcs_ixyz = (
- _parameter_validation.threeD_number_vectorLike_return_float(
- periodAngles_Wcsp_to_Wcs_ixyz, "periodAngles_Wcsp_to_Wcs_ixyz"
- )
- )
- if not np.all(periodAngles_Wcsp_to_Wcs_ixyz >= 0.0):
- raise ValueError(
- "All elements in periodAngles_Wcsp_to_Wcs_ixyz must be non negative."
- )
- for period_index, period in enumerate(periodAngles_Wcsp_to_Wcs_ixyz):
- amp = self._ampAngles_Wcsp_to_Wcs_ixyz[period_index]
- if amp == 0 and period != 0:
- raise ValueError(
- "If an element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0, "
- "the corresponding element in periodAngles_Wcsp_to_Wcs_ixyz must "
- "be also be 0.0."
- )
- self._periodAngles_Wcsp_to_Wcs_ixyz = periodAngles_Wcsp_to_Wcs_ixyz
- self._periodAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
-
- # Store as tuple to prevent external mutation.
- self._spacingAngles_Wcsp_to_Wcs_ixyz = (
- _parameter_validation.threeD_spacing_vectorLike_return_tuple(
- spacingAngles_Wcsp_to_Wcs_ixyz, "spacingAngles_Wcsp_to_Wcs_ixyz"
- )
- )
-
- phaseAngles_Wcsp_to_Wcs_ixyz = (
- _parameter_validation.threeD_number_vectorLike_return_float(
- phaseAngles_Wcsp_to_Wcs_ixyz, "phaseAngles_Wcsp_to_Wcs_ixyz"
- )
- )
- if not (
- np.all(phaseAngles_Wcsp_to_Wcs_ixyz > -180.0)
- and np.all(phaseAngles_Wcsp_to_Wcs_ixyz <= 180.0)
- ):
- raise ValueError(
- "All elements in phaseAngles_Wcsp_to_Wcs_ixyz must be in the range ("
- "-180.0, 180.0]."
- )
- for phase_index, phase in enumerate(phaseAngles_Wcsp_to_Wcs_ixyz):
- amp = self._ampAngles_Wcsp_to_Wcs_ixyz[phase_index]
- if amp == 0 and phase != 0:
- raise ValueError(
- "If an element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0, "
- "the corresponding element in phaseAngles_Wcsp_to_Wcs_ixyz must "
- "be also be 0.0."
- )
- self._phaseAngles_Wcsp_to_Wcs_ixyz = phaseAngles_Wcsp_to_Wcs_ixyz
- self._phaseAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
-
- # Initialize the caches for the properties derived from the immutable
- # attributes.
- self._all_periods: tuple[float, ...] | None = None
- self._max_period: float | None = None
-
- # --- Deep copy method ---
- def __deepcopy__(self, memo: dict) -> WingCrossSectionMovement:
- """Creates a deep copy of this WingCrossSectionMovement.
-
- All attributes are copied. The base WingCrossSection is deep copied to ensure
- independence. NumPy arrays are copied and set to read only to preserve
- immutability. Cache variables are reset to None.
-
- :param memo: A dict used by the copy module to track already copied objects and
- avoid infinite recursion.
- :return: A new WingCrossSectionMovement with copied attributes.
- """
- # Create a new WingCrossSectionMovement instance without calling __init__ to
- # avoid redundant validation.
- new_movement = object.__new__(WingCrossSectionMovement)
-
- # Store this WingCrossSectionMovement in memo to handle potential circular
- # references.
- memo[id(self)] = new_movement
-
- # Deep copy the base WingCrossSection to ensure independence (immutable).
- new_movement._base_wing_cross_section = copy.deepcopy(
- self._base_wing_cross_section, memo
+ super().__init__(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=ampLp_Wcsp_Lpp,
+ periodLp_Wcsp_Lpp=periodLp_Wcsp_Lpp,
+ spacingLp_Wcsp_Lpp=spacingLp_Wcsp_Lpp,
+ phaseLp_Wcsp_Lpp=phaseLp_Wcsp_Lpp,
+ ampAngles_Wcsp_to_Wcs_ixyz=ampAngles_Wcsp_to_Wcs_ixyz,
+ periodAngles_Wcsp_to_Wcs_ixyz=periodAngles_Wcsp_to_Wcs_ixyz,
+ spacingAngles_Wcsp_to_Wcs_ixyz=spacingAngles_Wcsp_to_Wcs_ixyz,
+ phaseAngles_Wcsp_to_Wcs_ixyz=phaseAngles_Wcsp_to_Wcs_ixyz,
)
-
- # Copy numpy arrays and make them read only.
- new_movement._ampLp_Wcsp_Lpp = self._ampLp_Wcsp_Lpp.copy()
- new_movement._ampLp_Wcsp_Lpp.flags.writeable = False
-
- new_movement._periodLp_Wcsp_Lpp = self._periodLp_Wcsp_Lpp.copy()
- new_movement._periodLp_Wcsp_Lpp.flags.writeable = False
-
- new_movement._phaseLp_Wcsp_Lpp = self._phaseLp_Wcsp_Lpp.copy()
- new_movement._phaseLp_Wcsp_Lpp.flags.writeable = False
-
- new_movement._ampAngles_Wcsp_to_Wcs_ixyz = (
- self._ampAngles_Wcsp_to_Wcs_ixyz.copy()
- )
- new_movement._ampAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
-
- new_movement._periodAngles_Wcsp_to_Wcs_ixyz = (
- self._periodAngles_Wcsp_to_Wcs_ixyz.copy()
- )
- new_movement._periodAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
-
- new_movement._phaseAngles_Wcsp_to_Wcs_ixyz = (
- self._phaseAngles_Wcsp_to_Wcs_ixyz.copy()
- )
- new_movement._phaseAngles_Wcsp_to_Wcs_ixyz.flags.writeable = False
-
- # Copy tuples directly (they are immutable).
- new_movement._spacingLp_Wcsp_Lpp = self._spacingLp_Wcsp_Lpp
- new_movement._spacingAngles_Wcsp_to_Wcs_ixyz = (
- self._spacingAngles_Wcsp_to_Wcs_ixyz
- )
-
- # Initialize cache variables to None (caches will be recomputed on access).
- new_movement._all_periods = None
- new_movement._max_period = None
-
- return new_movement
-
- # --- Immutable: read only properties ---
- @property
- def base_wing_cross_section(self) -> geometry.wing_cross_section.WingCrossSection:
- return self._base_wing_cross_section
-
- @property
- def ampLp_Wcsp_Lpp(self) -> np.ndarray:
- return self._ampLp_Wcsp_Lpp
-
- @property
- def periodLp_Wcsp_Lpp(self) -> np.ndarray:
- return self._periodLp_Wcsp_Lpp
-
- @property
- def spacingLp_Wcsp_Lpp(
- self,
- ) -> tuple[str | Callable[[np.ndarray], np.ndarray], ...]:
- return self._spacingLp_Wcsp_Lpp
-
- @property
- def phaseLp_Wcsp_Lpp(self) -> np.ndarray:
- return self._phaseLp_Wcsp_Lpp
-
- @property
- def ampAngles_Wcsp_to_Wcs_ixyz(self) -> np.ndarray:
- return self._ampAngles_Wcsp_to_Wcs_ixyz
-
- @property
- def periodAngles_Wcsp_to_Wcs_ixyz(self) -> np.ndarray:
- return self._periodAngles_Wcsp_to_Wcs_ixyz
-
- @property
- def spacingAngles_Wcsp_to_Wcs_ixyz(
- self,
- ) -> tuple[str | Callable[[np.ndarray], np.ndarray], ...]:
- return self._spacingAngles_Wcsp_to_Wcs_ixyz
-
- @property
- def phaseAngles_Wcsp_to_Wcs_ixyz(self) -> np.ndarray:
- return self._phaseAngles_Wcsp_to_Wcs_ixyz
-
- # --- Immutable derived: manual lazy caching ---
- @property
- def all_periods(self) -> tuple[float, ...]:
- """All unique non zero periods from this WingCrossSectionMovement.
-
- :return: A tuple of all unique non zero periods in seconds. If the motion is
- static, this will be an empty tuple.
- """
- if self._all_periods is None:
- periods = []
-
- # Collect all periods from positional motion.
- for period in self._periodLp_Wcsp_Lpp:
- if period > 0.0:
- periods.append(float(period))
-
- # Collect all periods from angular motion.
- for period in self._periodAngles_Wcsp_to_Wcs_ixyz:
- if period > 0.0:
- periods.append(float(period))
-
- self._all_periods = tuple(periods)
- return self._all_periods
-
- @property
- def max_period(self) -> float:
- """WingCrossSectionMovement's longest period of motion.
-
- :return: The longest period in seconds. If the motion is static, this will be
- 0.0.
- """
- if self._max_period is None:
- self._max_period = float(
- max(
- np.max(self._periodLp_Wcsp_Lpp),
- np.max(self._periodAngles_Wcsp_to_Wcs_ixyz),
- )
- )
- return self._max_period
-
- # --- Other methods ---
- def generate_wing_cross_sections(
- self,
- num_steps: int,
- delta_time: float | int,
- ) -> list[geometry.wing_cross_section.WingCrossSection]:
- """Creates the WingCrossSection at each time step, and returns them in a list.
-
- :param num_steps: The number of time steps in this movement. It must be a
- positive int.
- :param delta_time: The time between each time step. It must be a positive number
- (int or float), and will be converted internally to a float. The units are
- in seconds.
- :return: The list of WingCrossSections associated with this
- WingCrossSectionMovement.
- """
- num_steps = _parameter_validation.int_in_range_return_int(
- num_steps,
- "num_steps",
- min_val=1,
- min_inclusive=True,
- )
- delta_time = _parameter_validation.number_in_range_return_float(
- delta_time, "delta_time", min_val=0.0, min_inclusive=False
- )
-
- # Generate oscillating values for each dimension of Lp_Wcsp_Lpp.
- listLp_Wcsp_Lpp = np.zeros((3, num_steps), dtype=float)
- for dim in range(3):
- spacing = self._spacingLp_Wcsp_Lpp[dim]
- if spacing == "sine":
- listLp_Wcsp_Lpp[dim, :] = _functions.oscillating_sinspaces(
- amps=self._ampLp_Wcsp_Lpp[dim],
- periods=self._periodLp_Wcsp_Lpp[dim],
- phases=self._phaseLp_Wcsp_Lpp[dim],
- bases=self._base_wing_cross_section.Lp_Wcsp_Lpp[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif spacing == "uniform":
- listLp_Wcsp_Lpp[dim, :] = _functions.oscillating_linspaces(
- amps=self._ampLp_Wcsp_Lpp[dim],
- periods=self._periodLp_Wcsp_Lpp[dim],
- phases=self._phaseLp_Wcsp_Lpp[dim],
- bases=self._base_wing_cross_section.Lp_Wcsp_Lpp[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif callable(spacing):
- listLp_Wcsp_Lpp[dim, :] = _functions.oscillating_customspaces(
- amps=self._ampLp_Wcsp_Lpp[dim],
- periods=self._periodLp_Wcsp_Lpp[dim],
- phases=self._phaseLp_Wcsp_Lpp[dim],
- bases=self._base_wing_cross_section.Lp_Wcsp_Lpp[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- custom_function=spacing,
- )
- else:
- raise ValueError(f"Invalid spacing value: {spacing}")
-
- # Generate oscillating values for each dimension of angles_Wcsp_to_Wcs_ixyz.
- listAngles_Wcsp_to_Wcs_ixyz = np.zeros((3, num_steps), dtype=float)
- for dim in range(3):
- spacing = self._spacingAngles_Wcsp_to_Wcs_ixyz[dim]
- if spacing == "sine":
- listAngles_Wcsp_to_Wcs_ixyz[dim, :] = _functions.oscillating_sinspaces(
- amps=self._ampAngles_Wcsp_to_Wcs_ixyz[dim],
- periods=self._periodAngles_Wcsp_to_Wcs_ixyz[dim],
- phases=self._phaseAngles_Wcsp_to_Wcs_ixyz[dim],
- bases=self._base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif spacing == "uniform":
- listAngles_Wcsp_to_Wcs_ixyz[dim, :] = _functions.oscillating_linspaces(
- amps=self._ampAngles_Wcsp_to_Wcs_ixyz[dim],
- periods=self._periodAngles_Wcsp_to_Wcs_ixyz[dim],
- phases=self._phaseAngles_Wcsp_to_Wcs_ixyz[dim],
- bases=self._base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif callable(spacing):
- listAngles_Wcsp_to_Wcs_ixyz[dim, :] = (
- _functions.oscillating_customspaces(
- amps=self._ampAngles_Wcsp_to_Wcs_ixyz[dim],
- periods=self._periodAngles_Wcsp_to_Wcs_ixyz[dim],
- phases=self._phaseAngles_Wcsp_to_Wcs_ixyz[dim],
- bases=self._base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[
- dim
- ],
- num_steps=num_steps,
- delta_time=delta_time,
- custom_function=spacing,
- )
- )
- else:
- raise ValueError(f"Invalid spacing value: {spacing}")
-
- # Create an empty list to hold each time step's WingCrossSection.
- wing_cross_sections = []
-
- # Get the non changing WingCrossSectionAttributes.
- this_airfoil = self._base_wing_cross_section.airfoil
- this_num_spanwise_panels = self._base_wing_cross_section.num_spanwise_panels
- this_chord = self._base_wing_cross_section.chord
- this_control_surface_symmetry_type = (
- self._base_wing_cross_section.control_surface_symmetry_type
- )
- this_control_surface_hinge_point = (
- self._base_wing_cross_section.control_surface_hinge_point
- )
- this_control_surface_deflection = (
- self._base_wing_cross_section.control_surface_deflection
- )
- this_spanwise_spacing = self._base_wing_cross_section.spanwise_spacing
-
- # Iterate through the time steps.
- for step in range(num_steps):
- thisLp_Wcsp_Lpp = listLp_Wcsp_Lpp[:, step]
- theseAngles_Wcsp_to_Wcs_ixyz = listAngles_Wcsp_to_Wcs_ixyz[:, step]
-
- # Make a new WingCrossSection for this time step.
- this_wing_cross_section = geometry.wing_cross_section.WingCrossSection(
- airfoil=this_airfoil,
- num_spanwise_panels=this_num_spanwise_panels,
- chord=this_chord,
- Lp_Wcsp_Lpp=thisLp_Wcsp_Lpp,
- angles_Wcsp_to_Wcs_ixyz=theseAngles_Wcsp_to_Wcs_ixyz,
- control_surface_symmetry_type=this_control_surface_symmetry_type,
- control_surface_hinge_point=this_control_surface_hinge_point,
- control_surface_deflection=this_control_surface_deflection,
- spanwise_spacing=this_spanwise_spacing,
- )
-
- # Add this new WingCrossSection to the list of WingCrossSections.
- wing_cross_sections.append(this_wing_cross_section)
-
- return wing_cross_sections
diff --git a/pterasoftware/movements/wing_movement.py b/pterasoftware/movements/wing_movement.py
index ab9f7761b..88903e534 100644
--- a/pterasoftware/movements/wing_movement.py
+++ b/pterasoftware/movements/wing_movement.py
@@ -11,17 +11,15 @@
from __future__ import annotations
-import copy
from collections.abc import Callable, Sequence
import numpy as np
-from .. import _parameter_validation, _transformations, geometry
-from . import _functions
+from .. import _core, geometry
from . import wing_cross_section_movement as wing_cross_section_movement_mod
-class WingMovement:
+class WingMovement(_core.CoreWingMovement):
"""A class used to contain a Wing's movement.
**Contains the following methods:**
@@ -34,6 +32,8 @@ class WingMovement:
max_period: The longest period of WingMovement's own motion and that of its sub
movement objects.
+ generate_wing_at_time_step: Creates the Wing at a single time step.
+
generate_wings: Creates the Wing at each time step, and returns them in a list.
**Notes:**
@@ -46,21 +46,7 @@ class WingMovement:
WingMovement's parent AirplaneMovement's parent Movement.
"""
- __slots__ = (
- "_base_wing",
- "_wing_cross_section_movements",
- "_ampLer_Gs_Cgs",
- "_periodLer_Gs_Cgs",
- "_spacingLer_Gs_Cgs",
- "_phaseLer_Gs_Cgs",
- "_ampAngles_Gs_to_Wn_ixyz",
- "_periodAngles_Gs_to_Wn_ixyz",
- "_spacingAngles_Gs_to_Wn_ixyz",
- "_phaseAngles_Gs_to_Wn_ixyz",
- "_rotationPointOffset_Gs_Ler",
- "_all_periods",
- "_max_period",
- )
+ __slots__ = ()
def __init__(
self,
@@ -70,9 +56,7 @@ def __init__(
],
ampLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
periodLer_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs: (
- np.ndarray | Sequence[str | Callable[[np.ndarray], np.ndarray]]
- ) = (
+ spacingLer_Gs_Cgs: np.ndarray | Sequence[str | Callable[[float], float]] = (
"sine",
"sine",
"sine",
@@ -85,7 +69,7 @@ def __init__(
0.0,
),
spacingAngles_Gs_to_Wn_ixyz: (
- np.ndarray | Sequence[str | Callable[[np.ndarray], np.ndarray]]
+ np.ndarray | Sequence[str | Callable[[float], float]]
) = (
"sine",
"sine",
@@ -123,12 +107,11 @@ def __init__(
Ler_Gs_Cgs parameters. Can be a tuple, list, or ndarray. Each element can be
the string "sine", the string "uniform", or a callable custom spacing
function. Custom spacing functions are for advanced users and must start at
- 0.0, return to 0.0 after one period of 2*pi radians, have amplitude of 1.0,
- be periodic, return finite values only, and accept a ndarray as input and
- return a ndarray of the same shape. The custom function is scaled by
- ampLer_Gs_Cgs, shifted horizontally and vertically by phaseLer_Gs_Cgs and
- the base value, and have a period set by periodLer_Gs_Cgs. The default is
- ("sine", "sine", "sine").
+ 0.0, return to 0.0 after one period of 2.0 * pi radians, have amplitude of
+ 1.0, be periodic, return finite values only, and accept a float as input and
+ return a float. The custom function is scaled by ampLer_Gs_Cgs, shifted
+ horizontally and vertically by phaseLer_Gs_Cgs and the base value, and have
+ a period set by periodLer_Gs_Cgs. The default is ("sine", "sine", "sine").
:param phaseLer_Gs_Cgs: An array-like object of numbers (int or float) with
shape (3,) representing the phase offsets of the elements in the first time
step's Wing's Ler_Gs_Cgs parameter relative to the base Wing's Ler_Gs_Cgs
@@ -155,10 +138,10 @@ def __init__(
Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, or ndarray.
Each element can be the string "sine", the string "uniform", or a callable
custom spacing function. Custom spacing functions are for advanced users and
- must start at 0.0, return to 0.0 after one period of 2*pi radians, have
- amplitude of 1.0, be periodic, return finite values only, and accept a
- ndarray as input and return a ndarray of the same shape. The custom function
- is scaled by ampAngles_Gs_to_Wn_ixyz, shifted horizontally and vertically by
+ must start at 0.0, return to 0.0 after one period of 2.0 * pi radians, have
+ amplitude of 1.0, be periodic, return finite values only, and accept a float
+ as input and return a float. The custom function is scaled by
+ ampAngles_Gs_to_Wn_ixyz, shifted horizontally and vertically by
phaseAngles_Gs_to_Wn_ixyz and the base value, with the period set by
periodAngles_Gs_to_Wn_ixyz. The default is ("sine", "sine", "sine").
:param phaseAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float)
@@ -178,514 +161,29 @@ def __init__(
occurs about the leading edge root point (default behavior). The units are
in meters. The default is (0.0, 0.0, 0.0).
"""
- # Validate and store immutable attributes. Set those that are numpy arrays to
- # be read only.
- if not isinstance(base_wing, geometry.wing.Wing):
- raise TypeError("base_wing must be a Wing.")
- self._base_wing = base_wing
-
- if not isinstance(wing_cross_section_movements, list):
- raise TypeError("wing_cross_section_movements must be a list.")
- if len(wing_cross_section_movements) != len(
- self._base_wing.wing_cross_sections
- ):
- raise ValueError(
- "wing_cross_section_movements must have the same length as "
- "base_wing.wing_cross_sections."
- )
+ # Validate that every element is a WingCrossSectionMovement, not just a
+ # CoreWingCrossSectionMovement. CoreWingMovement.__init__() validates at the
+ # Core level, but WingMovement enforces the stricter type.
for wing_cross_section_movement in wing_cross_section_movements:
if not isinstance(
wing_cross_section_movement,
wing_cross_section_movement_mod.WingCrossSectionMovement,
):
raise TypeError(
- "Every element in wing_cross_section_movements must be a "
- "WingCrossSectionMovement."
- )
- # Store as tuple to prevent external mutation.
- self._wing_cross_section_movements = tuple(wing_cross_section_movements)
-
- ampLer_Gs_Cgs = _parameter_validation.threeD_number_vectorLike_return_float(
- ampLer_Gs_Cgs, "ampLer_Gs_Cgs"
- )
- if not np.all(ampLer_Gs_Cgs >= 0.0):
- raise ValueError("All elements in ampLer_Gs_Cgs must be non negative.")
- self._ampLer_Gs_Cgs = ampLer_Gs_Cgs
- self._ampLer_Gs_Cgs.flags.writeable = False
-
- periodLer_Gs_Cgs = _parameter_validation.threeD_number_vectorLike_return_float(
- periodLer_Gs_Cgs, "periodLer_Gs_Cgs"
- )
- if not np.all(periodLer_Gs_Cgs >= 0.0):
- raise ValueError("All elements in periodLer_Gs_Cgs must be non negative.")
- for period_index, period in enumerate(periodLer_Gs_Cgs):
- amp = self._ampLer_Gs_Cgs[period_index]
- if amp == 0 and period != 0:
- raise ValueError(
- "If an element in ampLer_Gs_Cgs is 0.0, the corresponding element "
- "in periodLer_Gs_Cgs must be also be 0.0."
- )
- self._periodLer_Gs_Cgs = periodLer_Gs_Cgs
- self._periodLer_Gs_Cgs.flags.writeable = False
-
- # Store as tuple to prevent external mutation.
- self._spacingLer_Gs_Cgs = (
- _parameter_validation.threeD_spacing_vectorLike_return_tuple(
- spacingLer_Gs_Cgs, "spacingLer_Gs_Cgs"
- )
- )
-
- phaseLer_Gs_Cgs = _parameter_validation.threeD_number_vectorLike_return_float(
- phaseLer_Gs_Cgs, "phaseLer_Gs_Cgs"
- )
- if not (np.all(phaseLer_Gs_Cgs > -180.0) and np.all(phaseLer_Gs_Cgs <= 180.0)):
- raise ValueError(
- "All elements in phaseLer_Gs_Cgs must be in the range (-180.0, 180.0]."
- )
- for phase_index, phase in enumerate(phaseLer_Gs_Cgs):
- amp = self._ampLer_Gs_Cgs[phase_index]
- if amp == 0 and phase != 0:
- raise ValueError(
- "If an element in ampLer_Gs_Cgs is 0.0, the corresponding element "
- "in phaseLer_Gs_Cgs must be also be 0.0."
- )
- self._phaseLer_Gs_Cgs = phaseLer_Gs_Cgs
- self._phaseLer_Gs_Cgs.flags.writeable = False
-
- ampAngles_Gs_to_Wn_ixyz = (
- _parameter_validation.threeD_number_vectorLike_return_float(
- ampAngles_Gs_to_Wn_ixyz, "ampAngles_Gs_to_Wn_ixyz"
- )
- )
- if not (
- np.all(ampAngles_Gs_to_Wn_ixyz >= 0.0)
- and np.all(ampAngles_Gs_to_Wn_ixyz <= 180.0)
- ):
- raise ValueError(
- "All elements in ampAngles_Gs_to_Wn_ixyz must be in the range [0.0, "
- "180.0]."
- )
- self._ampAngles_Gs_to_Wn_ixyz = ampAngles_Gs_to_Wn_ixyz
- self._ampAngles_Gs_to_Wn_ixyz.flags.writeable = False
-
- periodAngles_Gs_to_Wn_ixyz = (
- _parameter_validation.threeD_number_vectorLike_return_float(
- periodAngles_Gs_to_Wn_ixyz, "periodAngles_Gs_to_Wn_ixyz"
- )
- )
- if not np.all(periodAngles_Gs_to_Wn_ixyz >= 0.0):
- raise ValueError(
- "All elements in periodAngles_Gs_to_Wn_ixyz must be non negative."
- )
- for period_index, period in enumerate(periodAngles_Gs_to_Wn_ixyz):
- amp = self._ampAngles_Gs_to_Wn_ixyz[period_index]
- if amp == 0 and period != 0:
- raise ValueError(
- "If an element in ampAngles_Gs_to_Wn_ixyz is 0.0, "
- "the corresponding element in periodAngles_Gs_to_Wn_ixyz must be "
- "also be 0.0."
- )
- self._periodAngles_Gs_to_Wn_ixyz = periodAngles_Gs_to_Wn_ixyz
- self._periodAngles_Gs_to_Wn_ixyz.flags.writeable = False
-
- # Store as tuple to prevent external mutation.
- self._spacingAngles_Gs_to_Wn_ixyz = (
- _parameter_validation.threeD_spacing_vectorLike_return_tuple(
- spacingAngles_Gs_to_Wn_ixyz, "spacingAngles_Gs_to_Wn_ixyz"
- )
- )
-
- phaseAngles_Gs_to_Wn_ixyz = (
- _parameter_validation.threeD_number_vectorLike_return_float(
- phaseAngles_Gs_to_Wn_ixyz, "phaseAngles_Gs_to_Wn_ixyz"
- )
- )
- if not (
- np.all(phaseAngles_Gs_to_Wn_ixyz > -180.0)
- and np.all(phaseAngles_Gs_to_Wn_ixyz <= 180.0)
- ):
- raise ValueError(
- "All elements in phaseAngles_Gs_to_Wn_ixyz must be in the range ("
- "-180.0, 180.0]."
- )
- for phase_index, phase in enumerate(phaseAngles_Gs_to_Wn_ixyz):
- amp = self._ampAngles_Gs_to_Wn_ixyz[phase_index]
- if amp == 0 and phase != 0:
- raise ValueError(
- "If an element in ampAngles_Gs_to_Wn_ixyz is 0.0, "
- "the corresponding element in phaseAngles_Gs_to_Wn_ixyz must be "
- "also be 0.0."
- )
- self._phaseAngles_Gs_to_Wn_ixyz = phaseAngles_Gs_to_Wn_ixyz
- self._phaseAngles_Gs_to_Wn_ixyz.flags.writeable = False
-
- rotationPointOffset_Gs_Ler = (
- _parameter_validation.threeD_number_vectorLike_return_float(
- rotationPointOffset_Gs_Ler, "rotationPointOffset_Gs_Ler"
- )
- )
- self._rotationPointOffset_Gs_Ler = rotationPointOffset_Gs_Ler
- self._rotationPointOffset_Gs_Ler.flags.writeable = False
-
- # Initialize the caches for the properties derived from the immutable
- # attributes.
- self._all_periods: tuple[float, ...] | None = None
- self._max_period: float | None = None
-
- # --- Deep copy method ---
- def __deepcopy__(self, memo: dict) -> WingMovement:
- """Creates a deep copy of this WingMovement.
-
- All attributes are copied. The base Wing and WingCrossSectionMovements are deep
- copied to ensure independence. NumPy arrays are copied and set to read only to
- preserve immutability. Cache variables are reset to None.
-
- :param memo: A dict used by the copy module to track already copied objects and
- avoid infinite recursion.
- :return: A new WingMovement with copied attributes.
- """
- # Create a new WingMovement instance without calling __init__ to avoid
- # redundant validation.
- new_movement = object.__new__(WingMovement)
-
- # Store this WingMovement in memo to handle potential circular references.
- memo[id(self)] = new_movement
-
- # Deep copy the base Wing to ensure independence (immutable).
- new_movement._base_wing = copy.deepcopy(self._base_wing, memo)
-
- # Deep copy the WingCrossSectionMovements and store as tuple.
- new_movement._wing_cross_section_movements = tuple(
- copy.deepcopy(wing_cross_section_movement, memo)
- for wing_cross_section_movement in self._wing_cross_section_movements
- )
-
- # Copy numpy arrays and make them read only.
- new_movement._ampLer_Gs_Cgs = self._ampLer_Gs_Cgs.copy()
- new_movement._ampLer_Gs_Cgs.flags.writeable = False
-
- new_movement._periodLer_Gs_Cgs = self._periodLer_Gs_Cgs.copy()
- new_movement._periodLer_Gs_Cgs.flags.writeable = False
-
- new_movement._phaseLer_Gs_Cgs = self._phaseLer_Gs_Cgs.copy()
- new_movement._phaseLer_Gs_Cgs.flags.writeable = False
-
- new_movement._ampAngles_Gs_to_Wn_ixyz = self._ampAngles_Gs_to_Wn_ixyz.copy()
- new_movement._ampAngles_Gs_to_Wn_ixyz.flags.writeable = False
-
- new_movement._periodAngles_Gs_to_Wn_ixyz = (
- self._periodAngles_Gs_to_Wn_ixyz.copy()
- )
- new_movement._periodAngles_Gs_to_Wn_ixyz.flags.writeable = False
-
- new_movement._phaseAngles_Gs_to_Wn_ixyz = self._phaseAngles_Gs_to_Wn_ixyz.copy()
- new_movement._phaseAngles_Gs_to_Wn_ixyz.flags.writeable = False
-
- new_movement._rotationPointOffset_Gs_Ler = (
- self._rotationPointOffset_Gs_Ler.copy()
- )
- new_movement._rotationPointOffset_Gs_Ler.flags.writeable = False
-
- # Copy tuples directly (they are immutable).
- new_movement._spacingLer_Gs_Cgs = self._spacingLer_Gs_Cgs
- new_movement._spacingAngles_Gs_to_Wn_ixyz = self._spacingAngles_Gs_to_Wn_ixyz
-
- # Initialize cache variables to None (caches will be recomputed on access).
- new_movement._all_periods = None
- new_movement._max_period = None
-
- return new_movement
-
- # --- Immutable: read only properties ---
- @property
- def base_wing(self) -> geometry.wing.Wing:
- return self._base_wing
-
- @property
- def wing_cross_section_movements(
- self,
- ) -> tuple[wing_cross_section_movement_mod.WingCrossSectionMovement, ...]:
- return self._wing_cross_section_movements
-
- @property
- def ampLer_Gs_Cgs(self) -> np.ndarray:
- return self._ampLer_Gs_Cgs
-
- @property
- def periodLer_Gs_Cgs(self) -> np.ndarray:
- return self._periodLer_Gs_Cgs
-
- @property
- def spacingLer_Gs_Cgs(
- self,
- ) -> tuple[str | Callable[[np.ndarray], np.ndarray], ...]:
- return self._spacingLer_Gs_Cgs
-
- @property
- def phaseLer_Gs_Cgs(self) -> np.ndarray:
- return self._phaseLer_Gs_Cgs
-
- @property
- def ampAngles_Gs_to_Wn_ixyz(self) -> np.ndarray:
- return self._ampAngles_Gs_to_Wn_ixyz
-
- @property
- def periodAngles_Gs_to_Wn_ixyz(self) -> np.ndarray:
- return self._periodAngles_Gs_to_Wn_ixyz
-
- @property
- def spacingAngles_Gs_to_Wn_ixyz(
- self,
- ) -> tuple[str | Callable[[np.ndarray], np.ndarray], ...]:
- return self._spacingAngles_Gs_to_Wn_ixyz
-
- @property
- def phaseAngles_Gs_to_Wn_ixyz(self) -> np.ndarray:
- return self._phaseAngles_Gs_to_Wn_ixyz
-
- @property
- def rotationPointOffset_Gs_Ler(self) -> np.ndarray:
- return self._rotationPointOffset_Gs_Ler
-
- # --- Immutable derived: manual lazy caching ---
- @property
- def all_periods(self) -> tuple[float, ...]:
- """All unique non zero periods from this WingMovement and its
- WingCrossSectionMovements.
-
- :return: A tuple of all unique non zero periods in seconds. If all motion is
- static, this will be an empty tuple.
- """
- if self._all_periods is None:
- periods: list[float] = []
-
- # Collect all periods from WingCrossSectionMovements.
- for wing_cross_section_movement in self._wing_cross_section_movements:
- periods.extend(wing_cross_section_movement.all_periods)
-
- # Collect all periods from WingMovement's own motion.
- for period in self._periodLer_Gs_Cgs:
- if period > 0.0:
- periods.append(float(period))
- for period in self._periodAngles_Gs_to_Wn_ixyz:
- if period > 0.0:
- periods.append(float(period))
-
- self._all_periods = tuple(periods)
- return self._all_periods
-
- @property
- def max_period(self) -> float:
- """The longest period of WingMovement's own motion and that of its sub movement
- objects.
-
- :return: The longest period in seconds. If all the motion is static, this will
- be 0.0.
- """
- if self._max_period is None:
- wing_cross_section_movement_max_periods = []
- for wing_cross_section_movement in self._wing_cross_section_movements:
- wing_cross_section_movement_max_periods.append(
- wing_cross_section_movement.max_period
+ "Every element in wing_cross_section_movements must "
+ "be a WingCrossSectionMovement."
)
- max_wing_cross_section_movement_period = max(
- wing_cross_section_movement_max_periods
- )
- self._max_period = float(
- max(
- max_wing_cross_section_movement_period,
- np.max(self._periodLer_Gs_Cgs),
- np.max(self._periodAngles_Gs_to_Wn_ixyz),
- )
- )
- return self._max_period
-
- # --- Other methods ---
- def generate_wings(
- self, num_steps: int, delta_time: float | int
- ) -> list[geometry.wing.Wing]:
- """Creates the Wing at each time step, and returns them in a list.
-
- :param num_steps: The number of time steps in this movement. It must be a
- positive int.
- :param delta_time: The time between each time step. It must be a positive number
- (int or float), and will be converted internally to a float. The units are
- in seconds.
- :return: The list of Wings associated with this WingMovement.
- """
- num_steps = _parameter_validation.int_in_range_return_int(
- num_steps,
- "num_steps",
- min_val=1,
- min_inclusive=True,
+ super().__init__(
+ base_wing=base_wing,
+ wing_cross_section_movements=wing_cross_section_movements,
+ ampLer_Gs_Cgs=ampLer_Gs_Cgs,
+ periodLer_Gs_Cgs=periodLer_Gs_Cgs,
+ spacingLer_Gs_Cgs=spacingLer_Gs_Cgs,
+ phaseLer_Gs_Cgs=phaseLer_Gs_Cgs,
+ ampAngles_Gs_to_Wn_ixyz=ampAngles_Gs_to_Wn_ixyz,
+ periodAngles_Gs_to_Wn_ixyz=periodAngles_Gs_to_Wn_ixyz,
+ spacingAngles_Gs_to_Wn_ixyz=spacingAngles_Gs_to_Wn_ixyz,
+ phaseAngles_Gs_to_Wn_ixyz=phaseAngles_Gs_to_Wn_ixyz,
+ rotationPointOffset_Gs_Ler=rotationPointOffset_Gs_Ler,
)
- delta_time = _parameter_validation.number_in_range_return_float(
- delta_time, "delta_time", min_val=0.0, min_inclusive=False
- )
-
- # Generate oscillating values for each dimension of Ler_Gs_Cgs.
- listLer_Gs_Cgs = np.zeros((3, num_steps), dtype=float)
- for dim in range(3):
- spacing = self._spacingLer_Gs_Cgs[dim]
- if spacing == "sine":
- listLer_Gs_Cgs[dim, :] = _functions.oscillating_sinspaces(
- amps=self._ampLer_Gs_Cgs[dim],
- periods=self._periodLer_Gs_Cgs[dim],
- phases=self._phaseLer_Gs_Cgs[dim],
- bases=self._base_wing.Ler_Gs_Cgs[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif spacing == "uniform":
- listLer_Gs_Cgs[dim, :] = _functions.oscillating_linspaces(
- amps=self._ampLer_Gs_Cgs[dim],
- periods=self._periodLer_Gs_Cgs[dim],
- phases=self._phaseLer_Gs_Cgs[dim],
- bases=self._base_wing.Ler_Gs_Cgs[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif callable(spacing):
- listLer_Gs_Cgs[dim, :] = _functions.oscillating_customspaces(
- amps=self._ampLer_Gs_Cgs[dim],
- periods=self._periodLer_Gs_Cgs[dim],
- phases=self._phaseLer_Gs_Cgs[dim],
- bases=self._base_wing.Ler_Gs_Cgs[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- custom_function=spacing,
- )
- else:
- raise ValueError(f"Invalid spacing value: {spacing}")
-
- # Generate oscillating values for each dimension of angles_Gs_to_Wn_ixyz.
- listAngles_Gs_to_Wn_ixyz = np.zeros((3, num_steps), dtype=float)
- for dim in range(3):
- spacing = self._spacingAngles_Gs_to_Wn_ixyz[dim]
- if spacing == "sine":
- listAngles_Gs_to_Wn_ixyz[dim, :] = _functions.oscillating_sinspaces(
- amps=self._ampAngles_Gs_to_Wn_ixyz[dim],
- periods=self._periodAngles_Gs_to_Wn_ixyz[dim],
- phases=self._phaseAngles_Gs_to_Wn_ixyz[dim],
- bases=self._base_wing.angles_Gs_to_Wn_ixyz[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif spacing == "uniform":
- listAngles_Gs_to_Wn_ixyz[dim, :] = _functions.oscillating_linspaces(
- amps=self._ampAngles_Gs_to_Wn_ixyz[dim],
- periods=self._periodAngles_Gs_to_Wn_ixyz[dim],
- phases=self._phaseAngles_Gs_to_Wn_ixyz[dim],
- bases=self._base_wing.angles_Gs_to_Wn_ixyz[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- )
- elif callable(spacing):
- listAngles_Gs_to_Wn_ixyz[dim, :] = _functions.oscillating_customspaces(
- amps=self._ampAngles_Gs_to_Wn_ixyz[dim],
- periods=self._periodAngles_Gs_to_Wn_ixyz[dim],
- phases=self._phaseAngles_Gs_to_Wn_ixyz[dim],
- bases=self._base_wing.angles_Gs_to_Wn_ixyz[dim],
- num_steps=num_steps,
- delta_time=delta_time,
- custom_function=spacing,
- )
- else:
- raise ValueError(f"Invalid spacing value: {spacing}")
-
- # Create an empty 2D ndarray that will hold each of the Wings's
- # WingCrossSection's vector of WingCrossSections representing its changing
- # state at each time step. The first index denotes a particular base
- # WingCrossSection, and the second index denotes the time step.
- wing_cross_sections = np.empty(
- (len(self._wing_cross_section_movements), num_steps), dtype=object
- )
-
- # Iterate through the WingCrossSectionMovements.
- for (
- wing_cross_section_movement_id,
- wing_cross_section_movement,
- ) in enumerate(self._wing_cross_section_movements):
-
- # Generate this WingCrossSection's vector of WingCrossSections
- # representing its changing state at each time step.
- this_wing_cross_sections_list_of_wing_cross_sections = np.array(
- wing_cross_section_movement.generate_wing_cross_sections(
- num_steps=num_steps, delta_time=delta_time
- )
- )
-
- # Add this vector the Wing's 2D ndarray of WingCrossSections'
- # WingCrossSections.
- wing_cross_sections[wing_cross_section_movement_id, :] = (
- this_wing_cross_sections_list_of_wing_cross_sections
- )
-
- # Create an empty list to hold each time step's Wing.
- wings = []
-
- # Get the non changing Wing attributes.
- this_name = self._base_wing.name
- this_symmetric = self._base_wing.symmetric
- this_mirror_only = self._base_wing.mirror_only
- this_symmetryNormal_G = self._base_wing.symmetryNormal_G
- this_symmetryPoint_G_Cg = self._base_wing.symmetryPoint_G_Cg
- this_num_chordwise_panels = self._base_wing.num_chordwise_panels
- this_chordwise_spacing = self._base_wing.chordwise_spacing
-
- # Check if there is any offset rotation.
- offset_rotation = not np.allclose(
- self._rotationPointOffset_Gs_Ler, np.zeros(3, dtype=float)
- )
-
- # Initialize an identity matrix.
- identity = np.eye(3, dtype=float)
-
- # Iterate through the time steps.
- for step in range(num_steps):
- thisLer_Gs_Cgs = listLer_Gs_Cgs[:, step]
- theseAngles_Gs_to_Wn_ixyz = listAngles_Gs_to_Wn_ixyz[:, step]
- these_wing_cross_sections = list(wing_cross_sections[:, step])
-
- # If there is a non zero rotation point offset, adjust the positions to
- # account for rotation about the offset point instead of the leading edge
- # root.
- if offset_rotation:
- # TODO: Refactor this procedure for producing offset rotations to be a
- # function in _transformations.py.
- # Get the active rotation matrix for this step's angles.
- rot_T_act = _transformations.generate_rot_T(
- theseAngles_Gs_to_Wn_ixyz,
- passive=False,
- intrinsic=True,
- order="xyz",
- )
- rot_R_act = rot_T_act[:3, :3]
-
- # Compute the position adjustment due to the offset rotation point.
- offsetRotationPointAdjustment_Gs = (
- identity - rot_R_act
- ) @ self._rotationPointOffset_Gs_Ler
-
- # Apply the position adjustment to the leading edge root.
- thisLer_Gs_Cgs = thisLer_Gs_Cgs + offsetRotationPointAdjustment_Gs
-
- # Make a new Wing for this time step.
- this_wing = geometry.wing.Wing(
- wing_cross_sections=these_wing_cross_sections,
- name=this_name,
- Ler_Gs_Cgs=thisLer_Gs_Cgs,
- angles_Gs_to_Wn_ixyz=theseAngles_Gs_to_Wn_ixyz,
- symmetric=this_symmetric,
- mirror_only=this_mirror_only,
- symmetryNormal_G=this_symmetryNormal_G,
- symmetryPoint_G_Cg=this_symmetryPoint_G_Cg,
- num_chordwise_panels=this_num_chordwise_panels,
- chordwise_spacing=this_chordwise_spacing,
- )
-
- # Add this new Wing to the list of Wings.
- wings.append(this_wing)
-
- return wings
diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py
index b10bd06c4..080dc05aa 100644
--- a/pterasoftware/problems.py
+++ b/pterasoftware/problems.py
@@ -13,11 +13,9 @@
from __future__ import annotations
-import math
-
import numpy as np
-from . import _parameter_validation, _transformations, geometry, movements
+from . import _core, _transformations, geometry, movements
from . import operating_point as operating_point_mod
@@ -138,34 +136,32 @@ def reynolds_numbers(self) -> tuple[float, ...]:
return self._reynolds_numbers
-class UnsteadyProblem:
+class UnsteadyProblem(_core.CoreUnsteadyProblem):
"""A class used to contain unsteady aerodynamics problems.
**Contains the following methods:**
- None
+ only_final_results: Determines whether the solver will only calculate loads for the
+ final time step or final cycle.
+
+ num_steps: The number of time steps.
+
+ delta_time: The time step size in seconds.
+
+ first_averaging_step: The first time step included in cycle averaging.
+
+ first_results_step: The first time step for which loads are calculated.
+
+ max_wake_rows: The maximum chordwise wake rows per Wing.
+
+ movement: The Movement that contains this UnsteadyProblem's OperatingPointMovement
+ and AirplaneMovements.
+
+ steady_problems: A tuple of SteadyProblems, one for each time step.
"""
__slots__ = (
"_movement",
- "_only_final_results",
- "_num_steps",
- "_delta_time",
- "_max_wake_rows",
- "_first_averaging_step",
- "_first_results_step",
- "finalForces_W",
- "finalForceCoefficients_W",
- "finalMoments_W_CgP1",
- "finalMomentCoefficients_W_CgP1",
- "finalMeanForces_W",
- "finalMeanForceCoefficients_W",
- "finalMeanMoments_W_CgP1",
- "finalMeanMomentCoefficients_W_CgP1",
- "finalRmsForces_W",
- "finalRmsForceCoefficients_W",
- "finalRmsMoments_W_CgP1",
- "finalRmsMomentCoefficients_W_CgP1",
"_steady_problems",
)
@@ -186,69 +182,21 @@ def __init__(
will be converted internally to a bool. The default is False.
:return: None
"""
- # Validate and store immutable attributes.
+ # Validate and store the Movement before calling super().__init__() because
+ # the Movement provides the parameters that the core class needs.
if not isinstance(movement, movements.movement.Movement):
raise TypeError("movement must be a Movement.")
self._movement = movement
- self._only_final_results = _parameter_validation.boolLike_return_bool(
- only_final_results, "only_final_results"
- )
- self._num_steps: int = self._movement.num_steps
- self._delta_time: float = self._movement.delta_time
- self._max_wake_rows: int | None = self._movement.max_wake_rows
-
- # For UnsteadyProblems with a static Movement, we are typically interested in
- # the final time step's forces and moments, which, assuming convergence, will be
- # the most accurate. For UnsteadyProblems with cyclic movement, (e.g. flapping
- # wings) we are typically interested in the forces and moments averaged over the
- # last cycle simulated. Use the LCM of all motion periods to ensure we average
- # over a complete cycle of all motions.
- _movement_lcm_period = self._movement.lcm_period
- self._first_averaging_step: int
- if _movement_lcm_period == 0:
- self._first_averaging_step = self._num_steps - 1
- else:
- self._first_averaging_step = max(
- 0,
- math.floor(self._num_steps - (_movement_lcm_period / self._delta_time)),
- )
-
- # If we only wants to calculate forces and moments for the final cycle (for a
- # cyclic Movement) or for the final time step (for a static Movement) set the
- # first step to calculate results to the first averaging step. Otherwise, set it
- # to the zero, which is the first time step.
- self._first_results_step: int
- if self._only_final_results:
- self._first_results_step = self._first_averaging_step
- else:
- self._first_results_step = 0
-
- # Initialize empty lists to hold the final loads and load coefficients each
- # Airplane experiences. These will only be populated if this UnsteadyProblem's
- # Movement is static. These are mutable and populated by the solver.
- self.finalForces_W: list[np.ndarray] = []
- self.finalForceCoefficients_W: list[np.ndarray] = []
- self.finalMoments_W_CgP1: list[np.ndarray] = []
- self.finalMomentCoefficients_W_CgP1: list[np.ndarray] = []
-
- # Initialize empty lists to hold the final cycle-averaged loads and load
- # coefficients each Airplane experiences. These will only be populated if this
- # UnsteadyProblem's Movement is cyclic. These are mutable and populated by the
- # solver.
- self.finalMeanForces_W: list[np.ndarray] = []
- self.finalMeanForceCoefficients_W: list[np.ndarray] = []
- self.finalMeanMoments_W_CgP1: list[np.ndarray] = []
- self.finalMeanMomentCoefficients_W_CgP1: list[np.ndarray] = []
-
- # Initialize empty lists to hold the final cycle-root-mean-squared loads and
- # load coefficients each airplane object experiences. These will only be
- # populated for variable geometry problems. These are mutable and populated by
- # the solver.
- self.finalRmsForces_W: list[np.ndarray] = []
- self.finalRmsForceCoefficients_W: list[np.ndarray] = []
- self.finalRmsMoments_W_CgP1: list[np.ndarray] = []
- self.finalRmsMomentCoefficients_W_CgP1: list[np.ndarray] = []
+ # Delegate shared initialization (validation, first_averaging_step computation,
+ # load list initialization) to the core class.
+ super().__init__(
+ only_final_results=only_final_results,
+ delta_time=self._movement.delta_time,
+ num_steps=self._movement.num_steps,
+ max_wake_rows=self._movement.max_wake_rows,
+ lcm_period=self._movement.lcm_period,
+ )
# Initialize an empty list to hold the SteadyProblems as they are generated.
steady_problems_temp: list[SteadyProblem] = []
@@ -278,30 +226,6 @@ def __init__(
def movement(self) -> movements.movement.Movement:
return self._movement
- @property
- def only_final_results(self) -> bool:
- return self._only_final_results
-
- @property
- def num_steps(self) -> int:
- return self._num_steps
-
- @property
- def delta_time(self) -> float:
- return self._delta_time
-
- @property
- def first_averaging_step(self) -> int:
- return self._first_averaging_step
-
- @property
- def first_results_step(self) -> int:
- return self._first_results_step
-
- @property
- def max_wake_rows(self) -> int | None:
- return self._max_wake_rows
-
@property
def steady_problems(self) -> tuple[SteadyProblem, ...]:
return self._steady_problems
diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py
index a57de7eb1..692d4c887 100644
--- a/pterasoftware/unsteady_ring_vortex_lattice_method.py
+++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py
@@ -147,7 +147,7 @@ def __init__(self, unsteady_problem: problems.UnsteadyProblem) -> None:
self.steady_problems = self.unsteady_problem.steady_problems
- first_steady_problem: problems.SteadyProblem = self.steady_problems[0]
+ first_steady_problem: problems.SteadyProblem = self._get_steady_problem_at(0)
self.current_airplanes: tuple[geometry.airplane.Airplane, ...] = ()
self.current_operating_point: operating_point.OperatingPoint = (
@@ -293,7 +293,7 @@ def run(
# method eliminates the need for computationally expensive on-the-fly
# allocation and object copying.
for step in range(self.num_steps):
- this_problem: problems.SteadyProblem = self.steady_problems[step]
+ this_problem: problems.SteadyProblem = self._get_steady_problem_at(step)
these_airplanes = this_problem.airplanes
# Loop through this time step's Airplanes to gather their Wings.
@@ -362,7 +362,7 @@ def run(
# progress bar during the simulation initialization.
approx_times = np.zeros(self.num_steps + 1, dtype=float)
for step in range(1, self.num_steps):
- this_problem = self.steady_problems[step]
+ this_problem = self._get_steady_problem_at(step)
these_airplanes = this_problem.airplanes
# Iterate through this time step's Airplanes to get the total number of
@@ -414,9 +414,9 @@ def run(
# and OperatingPoint, and freestream velocity (in the first
# Airplane's geometry axes, observed from the Earth frame).
self._current_step = step
- current_problem: problems.SteadyProblem = self.steady_problems[
+ current_problem: problems.SteadyProblem = self._get_steady_problem_at(
self._current_step
- ]
+ )
self.current_airplanes = current_problem.airplanes
self.current_operating_point = current_problem.operating_point
self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E
@@ -598,7 +598,7 @@ def initialize_step_geometry(self, step: int) -> None:
# Set the current step and related state.
self._current_step = step
- current_problem: problems.SteadyProblem = self.steady_problems[step]
+ current_problem: problems.SteadyProblem = self._get_steady_problem_at(step)
self.current_airplanes = current_problem.airplanes
self.current_operating_point = current_problem.operating_point
self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E
@@ -609,8 +609,8 @@ def initialize_step_geometry(self, step: int) -> None:
self._populate_next_airplanes_wake_vortices()
def _initialize_panel_vortices(self) -> None:
- """Calculates the locations of the bound RingVortex vertices, and then
- initializes them.
+ """Calculates the locations of the bound RingVortex vertices for all time steps,
+ and then initializes them.
Every Panel has a RingVortex, which is a quadrangle whose front leg is a
LineVortex at the Panel's quarter chord. The left and right legs are
@@ -621,152 +621,8 @@ def _initialize_panel_vortices(self) -> None:
:return: None
"""
- for steady_problem_id, steady_problem in enumerate(self.steady_problems):
- # Find the freestream velocity (in the first Airplane's geometry axes,
- # observed from the Earth frame) at this time step.
- this_operating_point = steady_problem.operating_point
- vInf_GP1__E = this_operating_point.vInf_GP1__E
-
- # Iterate through this SteadyProblem's Airplanes' Wings.
- for airplane_id, airplane in enumerate(steady_problem.airplanes):
- for wing_id, wing in enumerate(airplane.wings):
- _num_spanwise_panels = wing.num_spanwise_panels
- assert _num_spanwise_panels is not None
-
- # Iterate through the Wing's chordwise and spanwise positions.
- for chordwise_position in range(wing.num_chordwise_panels):
- for spanwise_position in range(_num_spanwise_panels):
- _panels = wing.panels
- assert _panels is not None
-
- # Pull the Panel out of the Wing's 2D ndarray of Panels.
- panel: _panel.Panel = _panels[
- chordwise_position, spanwise_position
- ]
-
- _Flbvp_GP1_CgP1 = panel.Flbvp_GP1_CgP1
- assert _Flbvp_GP1_CgP1 is not None
-
- _Frbvp_GP1_CgP1 = panel.Frbvp_GP1_CgP1
- assert _Frbvp_GP1_CgP1 is not None
-
- # Find the location of this Panel's front left and
- # front right RingVortex points (in the first Airplane's
- # geometry axes, relative to the first Airplane's CG).
- Flrvp_GP1_CgP1 = _Flbvp_GP1_CgP1
- Frrvp_GP1_CgP1 = _Frbvp_GP1_CgP1
-
- # Define the location of the back left and back right
- # RingVortex points based on whether the Panel is along
- # the trailing edge or not.
- if not panel.is_trailing_edge:
- next_chordwise_panel: _panel.Panel = _panels[
- chordwise_position + 1, spanwise_position
- ]
-
- _nextFlbvp_GP1_CgP1 = (
- next_chordwise_panel.Flbvp_GP1_CgP1
- )
- assert _nextFlbvp_GP1_CgP1 is not None
-
- _nextFrbvp_GP1_CgP1 = (
- next_chordwise_panel.Frbvp_GP1_CgP1
- )
- assert _nextFrbvp_GP1_CgP1 is not None
-
- Blrvp_GP1_CgP1 = _nextFlbvp_GP1_CgP1
- Brrvp_GP1_CgP1 = _nextFrbvp_GP1_CgP1
- else:
- # As these vertices are directly behind the trailing
- # edge, they are spaced back from their Panel's
- # vertex by one quarter of the distance traveled by
- # the trailing edge during a time step. This is to
- # more accurately predict drag. More information can
- # be found on pages 37-39 of "Modeling of aerodynamic
- # forces in flapping flight with the Unsteady Vortex
- # Lattice Method" by Thomas Lambert.
- if steady_problem_id == 0:
- _Blpp_GP1_CgP1 = panel.Blpp_GP1_CgP1
- assert _Blpp_GP1_CgP1 is not None
-
- _Brpp_GP1_CgP1 = panel.Brpp_GP1_CgP1
- assert _Brpp_GP1_CgP1 is not None
-
- Blrvp_GP1_CgP1 = (
- _Blpp_GP1_CgP1
- + vInf_GP1__E * self.delta_time * 0.25
- )
- Brrvp_GP1_CgP1 = (
- _Brpp_GP1_CgP1
- + vInf_GP1__E * self.delta_time * 0.25
- )
- else:
- last_steady_problem = self.steady_problems[
- steady_problem_id - 1
- ]
- last_airplane = last_steady_problem.airplanes[
- airplane_id
- ]
- last_wing = last_airplane.wings[wing_id]
-
- _last_panels = last_wing.panels
- assert _last_panels is not None
-
- last_panel: _panel.Panel = _last_panels[
- chordwise_position, spanwise_position
- ]
-
- _thisBlpp_GP1_CgP1 = panel.Blpp_GP1_CgP1
- assert _thisBlpp_GP1_CgP1 is not None
-
- _lastBlpp_GP1_CgP1 = last_panel.Blpp_GP1_CgP1
- assert _lastBlpp_GP1_CgP1 is not None
-
- # We subtract (thisBlpp_GP1_CgP1 -
- # lastBlpp_GP1_CgP1) / self.delta_time from
- # vInf_GP1__E, because we want the apparent fluid
- # velocity due to motion (observed in the Earth
- # frame, in the first Airplane's geometry axes).
- # This is the vector pointing opposite the
- # velocity from motion.
- Blrvp_GP1_CgP1 = (
- _thisBlpp_GP1_CgP1
- + (
- vInf_GP1__E
- - (_thisBlpp_GP1_CgP1 - _lastBlpp_GP1_CgP1)
- / self.delta_time
- )
- * self.delta_time
- * 0.25
- )
-
- _thisBrpp_GP1_CgP1 = panel.Brpp_GP1_CgP1
- assert _thisBrpp_GP1_CgP1 is not None
-
- _lastBrpp_GP1_CgP1 = last_panel.Brpp_GP1_CgP1
- assert _lastBrpp_GP1_CgP1 is not None
-
- # The comment from above about apparent fluid
- # velocity due to motion applies here as well.
- Brrvp_GP1_CgP1 = (
- _thisBrpp_GP1_CgP1
- + (
- vInf_GP1__E
- - (_thisBrpp_GP1_CgP1 - _lastBrpp_GP1_CgP1)
- / self.delta_time
- )
- * self.delta_time
- * 0.25
- )
-
- # Initialize the Panel's RingVortex.
- panel.ring_vortex = _vortices.ring_vortex.RingVortex(
- Flrvp_GP1_CgP1=Flrvp_GP1_CgP1,
- Frrvp_GP1_CgP1=Frrvp_GP1_CgP1,
- Blrvp_GP1_CgP1=Blrvp_GP1_CgP1,
- Brrvp_GP1_CgP1=Brrvp_GP1_CgP1,
- strength=1.0,
- )
+ for step in range(self.num_steps):
+ self._initialize_panel_vortices_at(step)
def _collapse_geometry(self) -> None:
"""Converts attributes of the UnsteadyProblem's geometry into 1D ndarrays.
@@ -848,7 +704,7 @@ def _collapse_geometry(self) -> None:
# Reset the global Panel position variable.
global_panel_position = 0
- last_problem = self.steady_problems[self._current_step - 1]
+ last_problem = self._get_steady_problem_at(self._current_step - 1)
last_airplanes = last_problem.airplanes
# Iterate through the last time step's Airplanes' Wings.
@@ -1660,9 +1516,9 @@ def _populate_next_airplanes_wake_vortex_points(self) -> None:
wake_singularity_counts = np.zeros(4, dtype=np.int64)
# Get the next time step's Airplanes.
- next_problem: problems.SteadyProblem = self.steady_problems[
+ next_problem: problems.SteadyProblem = self._get_steady_problem_at(
self._current_step + 1
- ]
+ )
next_airplanes = next_problem.airplanes
# Get the current Airplanes' combined number of Wings.
@@ -1923,7 +1779,7 @@ def _populate_next_airplanes_wake_vortices(self) -> None:
if self._current_step < self.num_steps - 1:
# Get the next time step's Airplanes.
- next_problem = self.steady_problems[self._current_step + 1]
+ next_problem = self._get_steady_problem_at(self._current_step + 1)
next_airplanes = next_problem.airplanes
# Iterate through the next time step's Airplanes.
@@ -2252,7 +2108,9 @@ def _finalize_loads(self) -> None:
for step in range(self._first_averaging_step, self.num_steps):
# Get the Airplanes from the SteadyProblem at this time step.
- this_steady_problem: problems.SteadyProblem = self.steady_problems[step]
+ this_steady_problem: problems.SteadyProblem = self._get_steady_problem_at(
+ step
+ )
these_airplanes = this_steady_problem.airplanes
# Iterate through this time step's Airplanes.
@@ -2272,7 +2130,7 @@ def _finalize_loads(self) -> None:
# RMS loads and load coefficients. For variable geometry cases, use the
# trapezoidal rule to compute the time-averaged mean and RMS over the final
# cycle.
- first_problem: problems.SteadyProblem = self.steady_problems[0]
+ first_problem: problems.SteadyProblem = self._get_steady_problem_at(0)
for airplane_id, airplane in enumerate(first_problem.airplanes):
if static:
self.unsteady_problem.finalForces_W.append(forces_W[airplane_id, :, -1])
@@ -2341,3 +2199,165 @@ def _finalize_loads(self) -> None:
/ num_intervals
)
)
+
+ def _get_steady_problem_at(self, step: int) -> problems.SteadyProblem:
+ """Gets the SteadyProblem at a given time step.
+
+ Dynamic dispatch is used with _CoreUnsteadyProblems to provide different ways of
+ accessing SteadyProblems based on the solver type without added code
+ duplication. However, other methods must behave the same way regardless of
+ solver type.
+
+ :param step: The time step of the desired SteadyProblem.
+ :return: The SteadyProblem at the given time step.
+ """
+ return self.steady_problems[step]
+
+ def _initialize_panel_vortices_at(self, step: int) -> None:
+ """Calculates the locations of the bound RingVortex vertices at a given time
+ step, and then initializes them.
+
+ :param step: The time step at which to initialize the Panels' RingVortices.
+ :return: None
+ """
+ steady_problem = self._get_steady_problem_at(step)
+
+ # Find the freestream velocity (in the first Airplane's geometry axes, observed
+ # from the Earth frame) at this time step.
+ this_operating_point = steady_problem.operating_point
+ vInf_GP1__E = this_operating_point.vInf_GP1__E
+
+ # Iterate through this SteadyProblem's Airplanes' Wings.
+ for airplane_id, airplane in enumerate(steady_problem.airplanes):
+ for wing_id, wing in enumerate(airplane.wings):
+ _num_spanwise_panels = wing.num_spanwise_panels
+ assert _num_spanwise_panels is not None
+
+ # Iterate through the Wing's chordwise and spanwise positions.
+ for chordwise_position in range(wing.num_chordwise_panels):
+ for spanwise_position in range(_num_spanwise_panels):
+ _panels = wing.panels
+ assert _panels is not None
+
+ # Pull the Panel out of the Wing's 2D ndarray of Panels.
+ panel: _panel.Panel = _panels[
+ chordwise_position, spanwise_position
+ ]
+
+ _Flbvp_GP1_CgP1 = panel.Flbvp_GP1_CgP1
+ assert _Flbvp_GP1_CgP1 is not None
+
+ _Frbvp_GP1_CgP1 = panel.Frbvp_GP1_CgP1
+ assert _Frbvp_GP1_CgP1 is not None
+
+ # Find the location of this Panel's front left and front right
+ # RingVortex points (in the first Airplane's geometry axes,
+ # relative to the first Airplane's CG).
+ Flrvp_GP1_CgP1 = _Flbvp_GP1_CgP1
+ Frrvp_GP1_CgP1 = _Frbvp_GP1_CgP1
+
+ # Define the location of the back left and back right RingVortex
+ # points based on whether the Panel is along the trailing edge
+ # or not.
+ if not panel.is_trailing_edge:
+ next_chordwise_panel: _panel.Panel = _panels[
+ chordwise_position + 1, spanwise_position
+ ]
+
+ _nextFlbvp_GP1_CgP1 = next_chordwise_panel.Flbvp_GP1_CgP1
+ assert _nextFlbvp_GP1_CgP1 is not None
+
+ _nextFrbvp_GP1_CgP1 = next_chordwise_panel.Frbvp_GP1_CgP1
+ assert _nextFrbvp_GP1_CgP1 is not None
+
+ Blrvp_GP1_CgP1 = _nextFlbvp_GP1_CgP1
+ Brrvp_GP1_CgP1 = _nextFrbvp_GP1_CgP1
+ else:
+ # As these vertices are directly behind the trailing edge,
+ # they are spaced back from their Panel's vertex by one
+ # quarter of the distance traveled by the trailing edge
+ # during a time step. This is to more accurately predict
+ # drag. More information can be found on pages 37-39 of
+ # "Modeling of aerodynamic forces in flapping flight with
+ # the Unsteady Vortex Lattice Method" by Thomas Lambert.
+ if step == 0:
+ _Blpp_GP1_CgP1 = panel.Blpp_GP1_CgP1
+ assert _Blpp_GP1_CgP1 is not None
+
+ _Brpp_GP1_CgP1 = panel.Brpp_GP1_CgP1
+ assert _Brpp_GP1_CgP1 is not None
+
+ Blrvp_GP1_CgP1 = (
+ _Blpp_GP1_CgP1
+ + vInf_GP1__E * self.delta_time * 0.25
+ )
+ Brrvp_GP1_CgP1 = (
+ _Brpp_GP1_CgP1
+ + vInf_GP1__E * self.delta_time * 0.25
+ )
+ else:
+ last_steady_problem = self._get_steady_problem_at(
+ step - 1
+ )
+ last_airplane = last_steady_problem.airplanes[
+ airplane_id
+ ]
+ last_wing = last_airplane.wings[wing_id]
+
+ _last_panels = last_wing.panels
+ assert _last_panels is not None
+
+ last_panel: _panel.Panel = _last_panels[
+ chordwise_position, spanwise_position
+ ]
+
+ _thisBlpp_GP1_CgP1 = panel.Blpp_GP1_CgP1
+ assert _thisBlpp_GP1_CgP1 is not None
+
+ _lastBlpp_GP1_CgP1 = last_panel.Blpp_GP1_CgP1
+ assert _lastBlpp_GP1_CgP1 is not None
+
+ # We subtract (thisBlpp_GP1_CgP1 - lastBlpp_GP1_CgP1) /
+ # self.delta_time from vInf_GP1__E, because we want the
+ # apparent fluid velocity due to motion (observed in the
+ # Earth frame, in the first Airplane's geometry axes).
+ # This is the vector pointing opposite the velocity from
+ # motion.
+ Blrvp_GP1_CgP1 = (
+ _thisBlpp_GP1_CgP1
+ + (
+ vInf_GP1__E
+ - (_thisBlpp_GP1_CgP1 - _lastBlpp_GP1_CgP1)
+ / self.delta_time
+ )
+ * self.delta_time
+ * 0.25
+ )
+
+ _thisBrpp_GP1_CgP1 = panel.Brpp_GP1_CgP1
+ assert _thisBrpp_GP1_CgP1 is not None
+
+ _lastBrpp_GP1_CgP1 = last_panel.Brpp_GP1_CgP1
+ assert _lastBrpp_GP1_CgP1 is not None
+
+ # The comment from above about apparent fluid velocity
+ # due to motion applies here as well.
+ Brrvp_GP1_CgP1 = (
+ _thisBrpp_GP1_CgP1
+ + (
+ vInf_GP1__E
+ - (_thisBrpp_GP1_CgP1 - _lastBrpp_GP1_CgP1)
+ / self.delta_time
+ )
+ * self.delta_time
+ * 0.25
+ )
+
+ # Initialize the Panel's RingVortex.
+ panel.ring_vortex = _vortices.ring_vortex.RingVortex(
+ Flrvp_GP1_CgP1=Flrvp_GP1_CgP1,
+ Frrvp_GP1_CgP1=Frrvp_GP1_CgP1,
+ Blrvp_GP1_CgP1=Blrvp_GP1_CgP1,
+ Brrvp_GP1_CgP1=Brrvp_GP1_CgP1,
+ strength=1.0,
+ )
diff --git a/pyproject.toml b/pyproject.toml
index 733570a74..4b7c9e14e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,13 @@ include = '\.pyi?$'
ignore-words = ".codespell-ignore.txt"
skip = "docs/_build/*,*.dat"
+[tool.coverage.run]
+source = ["pterasoftware"]
+omit = [
+ "pterasoftware/movements/aeroelastic_*.py",
+ "pterasoftware/movements/free_flight_*.py",
+]
+
[tool.docformatter]
black = true
recursive = true
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
index 05b4b7c79..9caf35cc5 100644
--- a/tests/unit/__init__.py
+++ b/tests/unit/__init__.py
@@ -18,6 +18,25 @@
test_airplane_movement.py: This module contains classes to test AirplaneMovements.
+ test_core.py: This module contains classes to test the core classes in _core.py.
+
+ test_core_airplane_movement.py: This module contains classes to test
+ CoreAirplaneMovements.
+
+ test_core_movement.py: This module contains classes to test CoreMovements.
+
+ test_core_operating_point_movement.py: This module contains classes to test
+ CoreOperatingPointMovements.
+
+ test_core_unsteady_problem.py: This module contains classes to test
+ CoreUnsteadyProblems.
+
+ test_core_wing_cross_section_movement.py: This module contains classes to test
+ CoreWingCrossSectionMovements.
+
+ test_core_wing_movement.py: This module contains classes to test
+ CoreWingMovements.
+
test_horseshoe_vortex.py: This module contains classes to test HorseshoeVortices.
test_line_vortex.py: This module contains classes to test LineVortices.
@@ -25,14 +44,13 @@
test_movement.py: This module contains classes to test Movements and related
functions.
- test_movements_functions.py: This module contains a class to test movements
- functions.
-
test_operating_point.py: This module contains a class to test OperatingPoints.
test_operating_point_movement.py: This module contains classes to test
OperatingPointMovements.
+ test_oscillation.py: This module contains a class to test oscillation functions.
+
test_package_init.py: This module contains tests for the pterasoftware package
__init__.py.
@@ -69,12 +87,19 @@
import tests.unit.test_airfoil
import tests.unit.test_airplane
import tests.unit.test_airplane_movement
+import tests.unit.test_core
+import tests.unit.test_core_airplane_movement
+import tests.unit.test_core_movement
+import tests.unit.test_core_operating_point_movement
+import tests.unit.test_core_unsteady_problem
+import tests.unit.test_core_wing_cross_section_movement
+import tests.unit.test_core_wing_movement
import tests.unit.test_horseshoe_vortex
import tests.unit.test_line_vortex
import tests.unit.test_movement
-import tests.unit.test_movements_functions
import tests.unit.test_operating_point
import tests.unit.test_operating_point_movement
+import tests.unit.test_oscillation
import tests.unit.test_package_init
import tests.unit.test_panel
import tests.unit.test_parameter_validation
diff --git a/tests/unit/fixtures/__init__.py b/tests/unit/fixtures/__init__.py
index 6df7edbc0..f957f77b7 100644
--- a/tests/unit/fixtures/__init__.py
+++ b/tests/unit/fixtures/__init__.py
@@ -15,6 +15,18 @@
airplane_movement_fixtures.py: This module contains functions to create
AirplaneMovements for use in tests.
+ core_airplane_movement_fixtures.py: This module contains functions to create
+ CoreAirplaneMovements for use in tests.
+
+ core_movement_fixtures.py: This module contains functions to create CoreMovements
+ for use in tests.
+
+ core_wing_cross_section_movement_fixtures.py: This module contains functions to
+ create CoreWingCrossSectionMovements for use in tests.
+
+ core_wing_movement_fixtures.py: This module contains functions to create
+ CoreWingMovements for use in tests.
+
geometry_fixtures.py: This module contains functions to create geometry objects
for use in tests.
@@ -27,15 +39,18 @@
movement_fixtures.py: This module contains functions to create Movements for use
in tests.
- movements_functions_fixtures.py: This module contains functions to create
- fixtures for movements functions tests.
-
operating_point_fixtures.py: This module contains functions to create
OperatingPoints for use in tests.
+ core_operating_point_movement_fixtures.py: This module contains functions to
+ create CoreOperatingPointMovements for use in tests.
+
operating_point_movement_fixtures.py: This module contains functions to create
OperatingPointMovements for use in tests.
+ oscillation_fixtures.py: This module contains functions to create fixtures for
+ oscillation tests.
+
panel_fixtures.py: This module contains functions to create Panels for use in
tests.
@@ -63,13 +78,18 @@
import tests.unit.fixtures.aerodynamics_functions_fixtures
import tests.unit.fixtures.airplane_movement_fixtures
+import tests.unit.fixtures.core_airplane_movement_fixtures
+import tests.unit.fixtures.core_movement_fixtures
+import tests.unit.fixtures.core_operating_point_movement_fixtures
+import tests.unit.fixtures.core_wing_cross_section_movement_fixtures
+import tests.unit.fixtures.core_wing_movement_fixtures
import tests.unit.fixtures.geometry_fixtures
import tests.unit.fixtures.horseshoe_vortex_fixtures
import tests.unit.fixtures.line_vortex_fixtures
import tests.unit.fixtures.movement_fixtures
-import tests.unit.fixtures.movements_functions_fixtures
import tests.unit.fixtures.operating_point_fixtures
import tests.unit.fixtures.operating_point_movement_fixtures
+import tests.unit.fixtures.oscillation_fixtures
import tests.unit.fixtures.panel_fixtures
import tests.unit.fixtures.parameter_validation_fixtures
import tests.unit.fixtures.problem_fixtures
diff --git a/tests/unit/fixtures/aerodynamics_functions_fixtures.py b/tests/unit/fixtures/aerodynamics_functions_fixtures.py
index 56260573f..4dd405c74 100644
--- a/tests/unit/fixtures/aerodynamics_functions_fixtures.py
+++ b/tests/unit/fixtures/aerodynamics_functions_fixtures.py
@@ -198,18 +198,6 @@ def make_ages_fixture():
return ages_fixture
-def make_zero_ages_fixture():
- """This method makes a fixture that is a ndarray of zero ages for bound vortices.
-
- :return zero_ages_fixture: (3,) ndarray of floats
- This is a ndarray of zero ages for 3 vortices (simulating bound vortices
- with no core radius).
- """
- zero_ages_fixture = np.array([0.0, 0.0, 0.0], dtype=float)
-
- return zero_ages_fixture
-
-
def make_kinematic_viscosity_fixture():
"""This method makes a fixture that is a kinematic viscosity value for air.
diff --git a/tests/unit/fixtures/airplane_movement_fixtures.py b/tests/unit/fixtures/airplane_movement_fixtures.py
index dacb69996..6a15af2c3 100644
--- a/tests/unit/fixtures/airplane_movement_fixtures.py
+++ b/tests/unit/fixtures/airplane_movement_fixtures.py
@@ -1,7 +1,5 @@
"""This module contains functions to create AirplaneMovements for use in tests."""
-import numpy as np
-
import pterasoftware as ps
from . import geometry_fixtures, wing_movement_fixtures
@@ -57,254 +55,6 @@ def make_basic_airplane_movement_fixture():
return basic_airplane_movement_fixture
-def make_sine_spacing_Cg_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement with sine spacing
- for Cg_GP1_CgP1.
-
- :return sine_spacing_Cg_airplane_movement_fixture: AirplaneMovement
- This is the AirplaneMovement with sine spacing for Cg_GP1_CgP1.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Create the AirplaneMovement with sine spacing for Cg_GP1_CgP1.
- sine_spacing_Cg_airplane_movement_fixture = (
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.1, 0.0, 0.0),
- periodCg_GP1_CgP1=(1.0, 0.0, 0.0),
- spacingCg_GP1_CgP1=("sine", "sine", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the AirplaneMovement fixture.
- return sine_spacing_Cg_airplane_movement_fixture
-
-
-def make_uniform_spacing_Cg_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement with uniform spacing
- for Cg_GP1_CgP1.
-
- :return uniform_spacing_Cg_airplane_movement_fixture: AirplaneMovement
- This is the AirplaneMovement with uniform spacing for Cg_GP1_CgP1.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Create the AirplaneMovement with uniform spacing for Cg_GP1_CgP1.
- uniform_spacing_Cg_airplane_movement_fixture = (
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.1, 0.0, 0.0),
- periodCg_GP1_CgP1=(1.0, 0.0, 0.0),
- spacingCg_GP1_CgP1=("uniform", "uniform", "uniform"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the AirplaneMovement fixture.
- return uniform_spacing_Cg_airplane_movement_fixture
-
-
-def make_mixed_spacing_Cg_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement with mixed spacing
- for Cg_GP1_CgP1.
-
- :return mixed_spacing_Cg_airplane_movement_fixture: AirplaneMovement
- This is the AirplaneMovement with mixed spacing for Cg_GP1_CgP1.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Create the AirplaneMovement with mixed spacing for Cg_GP1_CgP1.
- mixed_spacing_Cg_airplane_movement_fixture = (
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.1, 0.08, 0.06),
- periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
- spacingCg_GP1_CgP1=("sine", "uniform", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the AirplaneMovement fixture.
- return mixed_spacing_Cg_airplane_movement_fixture
-
-
-def make_Cg_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement where Cg_GP1_CgP1 moves.
-
- :return Cg_airplane_movement_fixture: AirplaneMovement
- This is the AirplaneMovement with Cg_GP1_CgP1 movement.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Create the moving Cg AirplaneMovement.
- Cg_airplane_movement_fixture = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.08, 0.06, 0.05),
- periodCg_GP1_CgP1=(1.5, 1.5, 1.5),
- spacingCg_GP1_CgP1=("sine", "sine", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
-
- # Return the AirplaneMovement fixture.
- return Cg_airplane_movement_fixture
-
-
-def make_phase_offset_Cg_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement with non-zero phase
- offset for Cg_GP1_CgP1.
-
- :return phase_offset_Cg_airplane_movement_fixture: AirplaneMovement
- This is the AirplaneMovement with phase offset for Cg_GP1_CgP1.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Create the phase-offset AirplaneMovement.
- phase_offset_Cg_airplane_movement_fixture = (
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.08, 0.06, 0.05),
- periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
- spacingCg_GP1_CgP1=("sine", "sine", "sine"),
- phaseCg_GP1_CgP1=(90.0, -45.0, 60.0),
- )
- )
-
- # Return the AirplaneMovement fixture.
- return phase_offset_Cg_airplane_movement_fixture
-
-
-def make_multiple_periods_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement with different
- periods for different dimensions.
-
- :return multiple_periods_airplane_movement_fixture: AirplaneMovement
- This is the AirplaneMovement with different periods.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [
- wing_movement_fixtures.make_multiple_periods_wing_movement_fixture()
- ]
-
- # Create the multiple-periods AirplaneMovement.
- multiple_periods_airplane_movement_fixture = (
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.06, 0.05, 0.04),
- periodCg_GP1_CgP1=(1.0, 2.0, 3.0),
- spacingCg_GP1_CgP1=("sine", "sine", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the AirplaneMovement fixture.
- return multiple_periods_airplane_movement_fixture
-
-
-def make_custom_spacing_Cg_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement with a custom
- spacing function for Cg_GP1_CgP1.
-
- :return custom_spacing_Cg_airplane_movement_fixture: AirplaneMovement
- This is the AirplaneMovement with custom spacing for Cg_GP1_CgP1.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Define a custom harmonic spacing function.
- def custom_harmonic(x):
- """Custom harmonic spacing function: normalized combination of harmonics.
-
- This function satisfies all requirements: starts at 0, returns to 0 at
- 2*pi, has zero mean, has amplitude of 1, and is periodic.
-
- :param x: (N,) ndarray of floats
- The input angles in radians.
-
- :return: (N,) ndarray of floats
- The output values.
- """
- return (3.0 / (2.0 * np.sqrt(2.0))) * (
- np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
- )
-
- # Create the custom-spacing AirplaneMovement.
- custom_spacing_Cg_airplane_movement_fixture = (
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.08, 0.0, 0.0),
- periodCg_GP1_CgP1=(1.0, 0.0, 0.0),
- spacingCg_GP1_CgP1=(custom_harmonic, "sine", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the AirplaneMovement fixture.
- return custom_spacing_Cg_airplane_movement_fixture
-
-
-def make_mixed_custom_and_standard_spacing_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement with mixed custom
- and standard spacing functions.
-
- :return mixed_custom_and_standard_spacing_airplane_movement_fixture: AirplaneMovement
- This is the AirplaneMovement with mixed custom and standard spacing.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [
- wing_movement_fixtures.make_mixed_custom_and_standard_spacing_wing_movement_fixture()
- ]
-
- # Define a custom harmonic spacing function.
- def custom_harmonic(x):
- """Custom harmonic spacing function: normalized combination of harmonics.
-
- :param x: (N,) ndarray of floats
- The input angles in radians.
-
- :return: (N,) ndarray of floats
- The output values.
- """
- return (3.0 / (2.0 * np.sqrt(2.0))) * (
- np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
- )
-
- # Create the mixed-spacing AirplaneMovement.
- mixed_custom_and_standard_spacing_airplane_movement_fixture = (
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.06, 0.05, 0.04),
- periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
- spacingCg_GP1_CgP1=(custom_harmonic, "uniform", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the AirplaneMovement fixture.
- return mixed_custom_and_standard_spacing_airplane_movement_fixture
-
-
def make_periodic_geometry_airplane_movement_fixture():
"""This method makes a fixture that is an AirplaneMovement with periodic geometry
motion suitable for testing the variable geometry optimization.
@@ -335,87 +85,3 @@ def make_periodic_geometry_airplane_movement_fixture():
# Return the AirplaneMovement fixture.
return periodic_geometry_airplane_movement_fixture
-
-
-def make_angles_only_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement where only Wing angles
- move (no position movement).
-
- This is useful for testing geometry matching code that compares Wing angles
- separately from Wing positions.
-
- :return angles_only_airplane_movement_fixture: AirplaneMovement
- This is the AirplaneMovement with only Wing angle movement.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_angles_only_wing_movement_fixture()]
-
- # Create the angles-only AirplaneMovement.
- angles_only_airplane_movement_fixture = (
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
- periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
- spacingCg_GP1_CgP1=("sine", "sine", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the AirplaneMovement fixture.
- return angles_only_airplane_movement_fixture
-
-
-def make_2_chordwise_panels_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement with a Wing that has
- 2 chordwise panels.
-
- This is useful for testing panel shape comparison in geometry matching.
-
- :return: AirplaneMovement with a Wing that has 2 chordwise panels.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_2_chordwise_panels_airplane_fixture()
- wing_movements = [
- wing_movement_fixtures.make_2_chordwise_panels_wing_movement_fixture()
- ]
-
- # Create the AirplaneMovement.
- fixture = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
- periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
- spacingCg_GP1_CgP1=("sine", "sine", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
-
- return fixture
-
-
-def make_3_chordwise_panels_airplane_movement_fixture():
- """This method makes a fixture that is an AirplaneMovement with a Wing that has
- 3 chordwise panels.
-
- This is useful for testing panel shape comparison in geometry matching.
-
- :return: AirplaneMovement with a Wing that has 3 chordwise panels.
- """
- # Initialize the constructing fixtures.
- base_airplane = geometry_fixtures.make_3_chordwise_panels_airplane_fixture()
- wing_movements = [
- wing_movement_fixtures.make_3_chordwise_panels_wing_movement_fixture()
- ]
-
- # Create the AirplaneMovement.
- fixture = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
- periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
- spacingCg_GP1_CgP1=("sine", "sine", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
-
- return fixture
diff --git a/tests/unit/fixtures/core_airplane_movement_fixtures.py b/tests/unit/fixtures/core_airplane_movement_fixtures.py
new file mode 100644
index 000000000..1e7e44d52
--- /dev/null
+++ b/tests/unit/fixtures/core_airplane_movement_fixtures.py
@@ -0,0 +1,424 @@
+"""This module contains functions to create CoreAirplaneMovements for use in tests."""
+
+import numpy as np
+
+# noinspection PyProtectedMember
+from pterasoftware._core import CoreAirplaneMovement
+
+from . import core_wing_movement_fixtures, geometry_fixtures
+
+
+def make_static_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with all parameters
+ zero (no movement).
+
+ :return static_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with no movement.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Create the static CoreAirplaneMovement.
+ static_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return static_core_airplane_movement_fixture
+
+
+def make_basic_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with general-purpose
+ moderate values.
+
+ :return basic_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with general-purpose values.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_basic_core_wing_movement_fixture()
+ ]
+
+ # Create the basic CoreAirplaneMovement.
+ basic_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return basic_core_airplane_movement_fixture
+
+
+def make_sine_spacing_Cg_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with sine spacing
+ for Cg_GP1_CgP1.
+
+ :return sine_spacing_Cg_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with sine spacing for Cg_GP1_CgP1.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Create the CoreAirplaneMovement with sine spacing for Cg_GP1_CgP1.
+ sine_spacing_Cg_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.1, 0.0, 0.0),
+ periodCg_GP1_CgP1=(1.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return sine_spacing_Cg_core_airplane_movement_fixture
+
+
+def make_uniform_spacing_Cg_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with uniform spacing
+ for Cg_GP1_CgP1.
+
+ :return uniform_spacing_Cg_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with uniform spacing for Cg_GP1_CgP1.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Create the CoreAirplaneMovement with uniform spacing for Cg_GP1_CgP1.
+ uniform_spacing_Cg_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.1, 0.0, 0.0),
+ periodCg_GP1_CgP1=(1.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("uniform", "uniform", "uniform"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return uniform_spacing_Cg_core_airplane_movement_fixture
+
+
+def make_mixed_spacing_Cg_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with mixed spacing
+ for Cg_GP1_CgP1.
+
+ :return mixed_spacing_Cg_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with mixed spacing for Cg_GP1_CgP1.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Create the CoreAirplaneMovement with mixed spacing for Cg_GP1_CgP1.
+ mixed_spacing_Cg_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.1, 0.08, 0.06),
+ periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
+ spacingCg_GP1_CgP1=("sine", "uniform", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return mixed_spacing_Cg_core_airplane_movement_fixture
+
+
+def make_Cg_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement where Cg_GP1_CgP1 moves.
+
+ :return Cg_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with Cg_GP1_CgP1 movement.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Create the moving Cg CoreAirplaneMovement.
+ Cg_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.08, 0.06, 0.05),
+ periodCg_GP1_CgP1=(1.5, 1.5, 1.5),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return Cg_core_airplane_movement_fixture
+
+
+def make_phase_offset_Cg_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with non-zero phase
+ offset for Cg_GP1_CgP1.
+
+ :return phase_offset_Cg_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with phase offset for Cg_GP1_CgP1.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Create the phase-offset CoreAirplaneMovement.
+ phase_offset_Cg_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.08, 0.06, 0.05),
+ periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(90.0, -45.0, 60.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return phase_offset_Cg_core_airplane_movement_fixture
+
+
+def make_multiple_periods_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with different
+ periods for different dimensions.
+
+ :return multiple_periods_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with different periods.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_multiple_periods_core_wing_movement_fixture()
+ ]
+
+ # Create the multiple-periods CoreAirplaneMovement.
+ multiple_periods_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.06, 0.05, 0.04),
+ periodCg_GP1_CgP1=(1.0, 2.0, 3.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return multiple_periods_core_airplane_movement_fixture
+
+
+def make_custom_spacing_Cg_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with a custom
+ spacing function for Cg_GP1_CgP1.
+
+ :return custom_spacing_Cg_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with custom spacing for Cg_GP1_CgP1.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Define a custom harmonic spacing function.
+ def custom_harmonic(x):
+ """Custom harmonic spacing function: normalized combination of harmonics.
+
+ This function satisfies all requirements: starts at 0, returns to 0 at
+ 2*pi, has zero mean, has amplitude of 1, and is periodic.
+
+ :param x: (N,) ndarray of floats
+ The input angles in radians.
+
+ :return: (N,) ndarray of floats
+ The output values.
+ """
+ return (3.0 / (2.0 * np.sqrt(2.0))) * (
+ np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
+ )
+
+ # Create the custom spacing CoreAirplaneMovement.
+ custom_spacing_Cg_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.08, 0.0, 0.0),
+ periodCg_GP1_CgP1=(1.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=(custom_harmonic, "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return custom_spacing_Cg_core_airplane_movement_fixture
+
+
+def make_mixed_custom_and_standard_spacing_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with mixed custom
+ and standard spacing functions.
+
+ :return mixed_custom_and_standard_spacing_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with mixed custom and standard spacing.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_mixed_custom_and_standard_spacing_core_wing_movement_fixture()
+ ]
+
+ # Define a custom harmonic spacing function.
+ def custom_harmonic(x):
+ """Custom harmonic spacing function: normalized combination of harmonics.
+
+ :param x: (N,) ndarray of floats
+ The input angles in radians.
+
+ :return: (N,) ndarray of floats
+ The output values.
+ """
+ return (3.0 / (2.0 * np.sqrt(2.0))) * (
+ np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
+ )
+
+ # Create the mixed-spacing CoreAirplaneMovement.
+ mixed_custom_and_standard_spacing_core_airplane_movement_fixture = (
+ CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.06, 0.05, 0.04),
+ periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
+ spacingCg_GP1_CgP1=(custom_harmonic, "uniform", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return mixed_custom_and_standard_spacing_core_airplane_movement_fixture
+
+
+def make_periodic_geometry_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with periodic geometry
+ motion suitable for testing the variable geometry optimization.
+
+ The fixture uses a 0.1s period which aligns well with common delta_time values
+ like 0.01s (10 steps per period) and 0.02s (5 steps per period).
+
+ :return periodic_geometry_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with periodic geometry motion.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_periodic_geometry_core_wing_movement_fixture()
+ ]
+
+ # Create the periodic geometry CoreAirplaneMovement.
+ periodic_geometry_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return periodic_geometry_core_airplane_movement_fixture
+
+
+def make_angles_only_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement where only Wing angles
+ move (no position movement).
+
+ This is useful for testing geometry matching code that compares Wing angles
+ separately from Wing positions.
+
+ :return angles_only_core_airplane_movement_fixture: CoreAirplaneMovement
+ This is the CoreAirplaneMovement with only Wing angle movement.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_angles_only_core_wing_movement_fixture()
+ ]
+
+ # Create the angles-only CoreAirplaneMovement.
+ angles_only_core_airplane_movement_fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreAirplaneMovement fixture.
+ return angles_only_core_airplane_movement_fixture
+
+
+def make_2_chordwise_panels_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with a Wing that has
+ 2 chordwise panels.
+
+ This is useful for testing panel shape comparison in geometry matching.
+
+ :return: CoreAirplaneMovement with a Wing that has 2 chordwise panels.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_2_chordwise_panels_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_2_chordwise_panels_core_wing_movement_fixture()
+ ]
+
+ # Create the CoreAirplaneMovement.
+ fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ return fixture
+
+
+def make_3_chordwise_panels_core_airplane_movement_fixture():
+ """This method makes a fixture that is an CoreAirplaneMovement with a Wing that has
+ 3 chordwise panels.
+
+ This is useful for testing panel shape comparison in geometry matching.
+
+ :return: CoreAirplaneMovement with a Wing that has 3 chordwise panels.
+ """
+ # Initialize the constructing fixtures.
+ base_airplane = geometry_fixtures.make_3_chordwise_panels_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_3_chordwise_panels_core_wing_movement_fixture()
+ ]
+
+ # Create the CoreAirplaneMovement.
+ fixture = CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ return fixture
diff --git a/tests/unit/fixtures/core_movement_fixtures.py b/tests/unit/fixtures/core_movement_fixtures.py
new file mode 100644
index 000000000..5e7fcf72b
--- /dev/null
+++ b/tests/unit/fixtures/core_movement_fixtures.py
@@ -0,0 +1,58 @@
+"""This module contains functions to create CoreMovements for use in tests."""
+
+# noinspection PyProtectedMember
+from pterasoftware._core import CoreMovement
+
+from . import core_airplane_movement_fixtures, core_operating_point_movement_fixtures
+
+
+def make_static_core_movement_fixture():
+ """This method makes a fixture that is a CoreMovement with all static components.
+
+ :return static_core_movement_fixture: CoreMovement
+ This is the CoreMovement with no motion.
+ """
+ # Initialize the constructing fixtures.
+ airplane_movements = [
+ core_airplane_movement_fixtures.make_static_core_airplane_movement_fixture()
+ ]
+ operating_point_movement = (
+ core_operating_point_movement_fixtures.make_static_core_operating_point_movement_fixture()
+ )
+
+ # Create the static CoreMovement.
+ static_core_movement_fixture = CoreMovement(
+ airplane_movements=airplane_movements,
+ operating_point_movement=operating_point_movement,
+ delta_time=0.01,
+ num_steps=5,
+ )
+
+ # Return the CoreMovement fixture.
+ return static_core_movement_fixture
+
+
+def make_basic_core_movement_fixture():
+ """This method makes a fixture that is a CoreMovement with general-purpose values.
+
+ :return basic_core_movement_fixture: CoreMovement
+ This is the CoreMovement with general-purpose values for testing.
+ """
+ # Initialize the constructing fixtures.
+ airplane_movements = [
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
+ ]
+ operating_point_movement = (
+ core_operating_point_movement_fixtures.make_static_core_operating_point_movement_fixture()
+ )
+
+ # Create the basic CoreMovement.
+ basic_core_movement_fixture = CoreMovement(
+ airplane_movements=airplane_movements,
+ operating_point_movement=operating_point_movement,
+ delta_time=0.01,
+ num_steps=50,
+ )
+
+ # Return the CoreMovement fixture.
+ return basic_core_movement_fixture
diff --git a/tests/unit/fixtures/core_operating_point_movement_fixtures.py b/tests/unit/fixtures/core_operating_point_movement_fixtures.py
new file mode 100644
index 000000000..9b9ec0632
--- /dev/null
+++ b/tests/unit/fixtures/core_operating_point_movement_fixtures.py
@@ -0,0 +1,220 @@
+"""This module contains functions to create CoreOperatingPointMovements for use in
+tests."""
+
+import numpy as np
+
+# noinspection PyProtectedMember
+from pterasoftware._core import CoreOperatingPointMovement
+
+from . import operating_point_fixtures
+
+
+def make_static_core_operating_point_movement_fixture():
+ """This method makes a fixture that is an CoreOperatingPointMovement with all
+ parameters zero (no movement).
+
+ :return static_core_operating_point_movement_fixture: CoreOperatingPointMovement
+ This is the CoreOperatingPointMovement with no movement.
+ """
+ # Initialize the constructing fixture.
+ base_operating_point = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Create the static CoreOperatingPointMovement.
+ static_core_operating_point_movement_fixture = CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=0.0,
+ periodVCg__E=0.0,
+ spacingVCg__E="sine",
+ phaseVCg__E=0.0,
+ )
+
+ # Return the CoreOperatingPointMovement fixture.
+ return static_core_operating_point_movement_fixture
+
+
+def make_sine_spacing_core_operating_point_movement_fixture():
+ """This method makes a fixture that is an CoreOperatingPointMovement with sine
+ spacing for vCg__E.
+
+ :return sine_spacing_core_operating_point_movement_fixture: CoreOperatingPointMovement
+ This is the CoreOperatingPointMovement with sine spacing for vCg__E.
+ """
+ # Initialize the constructing fixture.
+ base_operating_point = (
+ operating_point_fixtures.make_high_speed_operating_point_fixture()
+ )
+
+ # Create the sine spacing CoreOperatingPointMovement.
+ sine_spacing_core_operating_point_movement_fixture = CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=10.0,
+ periodVCg__E=1.0,
+ spacingVCg__E="sine",
+ phaseVCg__E=0.0,
+ )
+
+ # Return the CoreOperatingPointMovement fixture.
+ return sine_spacing_core_operating_point_movement_fixture
+
+
+def make_uniform_spacing_core_operating_point_movement_fixture():
+ """This method makes a fixture that is an CoreOperatingPointMovement with uniform
+ spacing for vCg__E.
+
+ :return uniform_spacing_core_operating_point_movement_fixture: CoreOperatingPointMovement
+ This is the CoreOperatingPointMovement with uniform spacing for vCg__E.
+ """
+ # Initialize the constructing fixture.
+ base_operating_point = (
+ operating_point_fixtures.make_high_speed_operating_point_fixture()
+ )
+
+ # Create the uniform spacing CoreOperatingPointMovement.
+ uniform_spacing_core_operating_point_movement_fixture = CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=10.0,
+ periodVCg__E=1.0,
+ spacingVCg__E="uniform",
+ phaseVCg__E=0.0,
+ )
+
+ # Return the CoreOperatingPointMovement fixture.
+ return uniform_spacing_core_operating_point_movement_fixture
+
+
+def make_phase_offset_core_operating_point_movement_fixture():
+ """This method makes a fixture that is an CoreOperatingPointMovement with
+ non-zero phase offset for vCg__E.
+
+ :return phase_offset_core_operating_point_movement_fixture: CoreOperatingPointMovement
+ This is the CoreOperatingPointMovement with phase offset for vCg__E.
+ """
+ # Initialize the constructing fixture.
+ base_operating_point = (
+ operating_point_fixtures.make_high_speed_operating_point_fixture()
+ )
+
+ # Create the phase offset CoreOperatingPointMovement.
+ phase_offset_core_operating_point_movement_fixture = CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=20.0,
+ periodVCg__E=2.0,
+ spacingVCg__E="sine",
+ phaseVCg__E=90.0,
+ )
+
+ # Return the CoreOperatingPointMovement fixture.
+ return phase_offset_core_operating_point_movement_fixture
+
+
+def make_custom_spacing_core_operating_point_movement_fixture():
+ """This method makes a fixture that is an CoreOperatingPointMovement with a
+ custom spacing function for vCg__E.
+
+ :return custom_spacing_core_operating_point_movement_fixture: CoreOperatingPointMovement
+ This is the CoreOperatingPointMovement with custom spacing for vCg__E.
+ """
+ # Initialize the constructing fixture.
+ base_operating_point = (
+ operating_point_fixtures.make_high_speed_operating_point_fixture()
+ )
+
+ # Define a custom harmonic spacing function.
+ def custom_harmonic(x):
+ """Custom harmonic spacing function: normalized combination of harmonics.
+
+ This function satisfies all requirements: starts at 0, returns to 0 at
+ 2*pi, has zero mean, has amplitude of 1, and is periodic.
+
+ :param x: (N,) ndarray of floats
+ The input angles in radians.
+
+ :return: (N,) ndarray of floats
+ The output values.
+ """
+ return (3.0 / (2.0 * np.sqrt(2.0))) * (
+ np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
+ )
+
+ # Create the custom spacing CoreOperatingPointMovement.
+ custom_spacing_core_operating_point_movement_fixture = CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=15.0,
+ periodVCg__E=1.5,
+ spacingVCg__E=custom_harmonic,
+ phaseVCg__E=0.0,
+ )
+
+ # Return the CoreOperatingPointMovement fixture.
+ return custom_spacing_core_operating_point_movement_fixture
+
+
+def make_basic_core_operating_point_movement_fixture():
+ """This method makes a fixture that is an CoreOperatingPointMovement with
+ general-purpose moderate values.
+
+ :return basic_core_operating_point_movement_fixture: CoreOperatingPointMovement
+ This is the CoreOperatingPointMovement with general-purpose values.
+ """
+ # Initialize the constructing fixture.
+ base_operating_point = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Create the basic CoreOperatingPointMovement.
+ basic_core_operating_point_movement_fixture = CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=5.0,
+ periodVCg__E=2.0,
+ spacingVCg__E="sine",
+ phaseVCg__E=0.0,
+ )
+
+ # Return the CoreOperatingPointMovement fixture.
+ return basic_core_operating_point_movement_fixture
+
+
+def make_large_amplitude_core_operating_point_movement_fixture():
+ """This method makes a fixture that is an CoreOperatingPointMovement with large
+ amplitude relative to base speed.
+
+ :return large_amplitude_core_operating_point_movement_fixture: CoreOperatingPointMovement
+ This is the CoreOperatingPointMovement with large amplitude for vCg__E.
+ """
+ # Initialize the constructing fixture.
+ base_operating_point = (
+ operating_point_fixtures.make_high_speed_operating_point_fixture()
+ )
+
+ # Create the large amplitude CoreOperatingPointMovement.
+ large_amplitude_core_operating_point_movement_fixture = CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=50.0,
+ periodVCg__E=1.0,
+ spacingVCg__E="sine",
+ phaseVCg__E=0.0,
+ )
+
+ # Return the CoreOperatingPointMovement fixture.
+ return large_amplitude_core_operating_point_movement_fixture
+
+
+def make_long_period_core_operating_point_movement_fixture():
+ """This method makes a fixture that is an CoreOperatingPointMovement with a long
+ period.
+
+ :return long_period_core_operating_point_movement_fixture: CoreOperatingPointMovement
+ This is the CoreOperatingPointMovement with a long period for vCg__E.
+ """
+ # Initialize the constructing fixture.
+ base_operating_point = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Create the long period CoreOperatingPointMovement.
+ long_period_core_operating_point_movement_fixture = CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=3.0,
+ periodVCg__E=10.0,
+ spacingVCg__E="sine",
+ phaseVCg__E=0.0,
+ )
+
+ # Return the CoreOperatingPointMovement fixture.
+ return long_period_core_operating_point_movement_fixture
diff --git a/tests/unit/fixtures/core_wing_cross_section_movement_fixtures.py b/tests/unit/fixtures/core_wing_cross_section_movement_fixtures.py
new file mode 100644
index 000000000..6767d71c4
--- /dev/null
+++ b/tests/unit/fixtures/core_wing_cross_section_movement_fixtures.py
@@ -0,0 +1,532 @@
+"""This module contains functions to create CoreWingCrossSectionMovements for use in
+tests."""
+
+import numpy as np
+
+# noinspection PyProtectedMember
+from pterasoftware._core import CoreWingCrossSectionMovement
+
+from . import geometry_fixtures
+
+
+def make_sine_spacing_Lp_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with sine
+ spacing for Lp_Wcsp_Lpp.
+
+ :return sine_spacing_Lp_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with sine spacing for Lp_Wcsp_Lpp.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create the CoreWingCrossSectionMovement with sine spacing.
+ sine_spacing_Lp_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return sine_spacing_Lp_core_wing_cross_section_movement_fixture
+
+
+def make_uniform_spacing_Lp_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with uniform
+ spacing for Lp_Wcsp_Lpp.
+
+ :return uniform_spacing_Lp_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with uniform spacing for
+ Lp_Wcsp_Lpp.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create the CoreWingCrossSectionMovement with uniform spacing.
+ uniform_spacing_Lp_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=("uniform", "uniform", "uniform"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return uniform_spacing_Lp_core_wing_cross_section_movement_fixture
+
+
+def make_mixed_spacing_Lp_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with mixed
+ spacing for Lp_Wcsp_Lpp.
+
+ :return mixed_spacing_Lp_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with mixed spacing for Lp_Wcsp_Lpp.
+ """
+ # Initialize the constructing fixture.
+ # Use tip fixture which has Lp_Wcsp_Lpp[1] = 2.0, allowing for amplitude of 1.5.
+ base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
+
+ # Create the CoreWingCrossSectionMovement with mixed spacing.
+ mixed_spacing_Lp_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(1.0, 1.5, 0.5),
+ periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
+ spacingLp_Wcsp_Lpp=("sine", "uniform", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return mixed_spacing_Lp_core_wing_cross_section_movement_fixture
+
+
+def make_sine_spacing_angles_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with sine
+ spacing for angles_Wcsp_to_Wcs_ixyz.
+
+ :return sine_spacing_angles_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with sine spacing for
+ angles_Wcsp_to_Wcs_ixyz.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create the CoreWingCrossSectionMovement with sine spacing for angles.
+ sine_spacing_angles_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return sine_spacing_angles_core_wing_cross_section_movement_fixture
+
+
+def make_uniform_spacing_angles_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with uniform
+ spacing for angles_Wcsp_to_Wcs_ixyz.
+
+ :return uniform_spacing_angles_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with uniform spacing for
+ angles_Wcsp_to_Wcs_ixyz.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create the CoreWingCrossSectionMovement with uniform spacing for angles.
+ uniform_spacing_angles_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("uniform", "uniform", "uniform"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return uniform_spacing_angles_core_wing_cross_section_movement_fixture
+
+
+def make_mixed_spacing_angles_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with mixed
+ spacing for angles_Wcsp_to_Wcs_ixyz.
+
+ :return mixed_spacing_angles_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with mixed spacing for
+ angles_Wcsp_to_Wcs_ixyz.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create the CoreWingCrossSectionMovement with mixed spacing for angles.
+ mixed_spacing_angles_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 20.0, 5.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 1.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "uniform", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return mixed_spacing_angles_core_wing_cross_section_movement_fixture
+
+
+def make_static_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with all
+ parameters zero (no movement).
+
+ :return static_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with no movement.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create the static CoreWingCrossSectionMovement.
+ static_core_wing_cross_section_movement_fixture = CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return static_core_wing_cross_section_movement_fixture
+
+
+def make_static_tip_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with all
+ parameters zero (no movement), using a tip WingCrossSection as the base.
+
+ :return static_tip_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with no movement for a tip cross
+ section.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
+
+ # Create the static tip CoreWingCrossSectionMovement.
+ static_tip_core_wing_cross_section_movement_fixture = CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return static_tip_core_wing_cross_section_movement_fixture
+
+
+def make_basic_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with
+ general-purpose moderate values.
+
+ :return basic_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with general-purpose values.
+ """
+ # Initialize the constructing fixture.
+ # Use tip fixture to ensure Lp values stay non-negative during oscillation.
+ base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
+
+ # Create the basic CoreWingCrossSectionMovement.
+ basic_core_wing_cross_section_movement_fixture = CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.4, 0.3, 0.15),
+ periodLp_Wcsp_Lpp=(2.0, 2.0, 2.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(15.0, 10.0, 5.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(2.0, 2.0, 2.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return basic_core_wing_cross_section_movement_fixture
+
+
+def make_Lp_only_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement where only
+ Lp_Wcsp_Lpp moves.
+
+ :return Lp_only_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with only Lp_Wcsp_Lpp movement.
+ """
+ # Initialize the constructing fixture.
+ # Use tip fixture to ensure Lp values stay non-negative during oscillation.
+ base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
+
+ # Create the Lp-only CoreWingCrossSectionMovement.
+ Lp_only_core_wing_cross_section_movement_fixture = CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.4, 0.5, 0.15),
+ periodLp_Wcsp_Lpp=(1.5, 1.5, 1.5),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return Lp_only_core_wing_cross_section_movement_fixture
+
+
+def make_angles_only_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement where only
+ angles_Wcsp_to_Wcs_ixyz moves.
+
+ :return angles_only_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with only angles_Wcsp_to_Wcs_ixyz
+ movement.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create the angles-only CoreWingCrossSectionMovement.
+ angles_only_core_wing_cross_section_movement_fixture = CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(20.0, 15.0, 10.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.5, 1.5, 1.5),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return angles_only_core_wing_cross_section_movement_fixture
+
+
+def make_phase_offset_Lp_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with
+ non-zero phase offset for Lp_Wcsp_Lpp.
+
+ :return phase_offset_Lp_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with phase offset for Lp_Wcsp_Lpp.
+ """
+ # Initialize the constructing fixture.
+ # Use tip fixture to ensure Lp values stay non-negative during oscillation.
+ base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
+
+ # Create the phase-offset CoreWingCrossSectionMovement.
+ phase_offset_Lp_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.4, 0.3, 0.15),
+ periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(90.0, -90.0, 45.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return phase_offset_Lp_core_wing_cross_section_movement_fixture
+
+
+def make_phase_offset_angles_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with
+ non-zero phase offset for angles_Wcsp_to_Wcs_ixyz.
+
+ :return phase_offset_angles_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with phase offset for
+ angles_Wcsp_to_Wcs_ixyz.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create the phase-offset CoreWingCrossSectionMovement.
+ phase_offset_angles_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 15.0, 20.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 1.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(45.0, 90.0, -45.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return phase_offset_angles_core_wing_cross_section_movement_fixture
+
+
+def make_multiple_periods_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with
+ different periods for different dimensions.
+
+ :return multiple_periods_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with different periods.
+ """
+ # Initialize the constructing fixture.
+ # Use tip fixture to ensure Lp values stay non-negative during oscillation.
+ base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
+
+ # Create the multiple-periods CoreWingCrossSectionMovement.
+ multiple_periods_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.4, 0.4, 0.15),
+ periodLp_Wcsp_Lpp=(1.0, 2.0, 3.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 15.0, 20.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(0.5, 1.5, 2.5),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return multiple_periods_core_wing_cross_section_movement_fixture
+
+
+def make_custom_spacing_Lp_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with a
+ custom spacing function for Lp_Wcsp_Lpp.
+
+ :return custom_spacing_Lp_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with custom spacing for Lp_Wcsp_Lpp.
+ """
+ # Initialize the constructing fixture.
+ # Use tip fixture to ensure Lp values stay non-negative during oscillation.
+ base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
+
+ # Define a custom harmonic spacing function.
+ def custom_harmonic(x):
+ """Custom harmonic spacing function: normalized combination of harmonics.
+
+ This function satisfies all requirements: starts at 0, returns to 0 at
+ 2*pi, has zero mean, has amplitude of 1, and is periodic.
+
+ :param x: (N,) ndarray of floats
+ The input angles in radians.
+
+ :return: (N,) ndarray of floats
+ The output values.
+ """
+ return (3.0 / (2.0 * np.sqrt(2.0))) * (
+ np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
+ )
+
+ # Create the custom-spacing CoreWingCrossSectionMovement.
+ custom_spacing_Lp_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.4, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=(custom_harmonic, "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return custom_spacing_Lp_core_wing_cross_section_movement_fixture
+
+
+def make_custom_spacing_angles_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with a
+ custom spacing function for angles_Wcsp_to_Wcs_ixyz.
+
+ :return custom_spacing_angles_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with custom spacing for
+ angles_Wcsp_to_Wcs_ixyz.
+ """
+ # Initialize the constructing fixture.
+ base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Define a custom harmonic spacing function.
+ def custom_harmonic(x):
+ """Custom harmonic spacing function: normalized combination of harmonics.
+
+ This function satisfies all requirements: starts at 0, returns to 0 at
+ 2*pi, has zero mean, has amplitude of 1, and is periodic.
+
+ :param x: (N,) ndarray of floats
+ The input angles in radians.
+
+ :return: (N,) ndarray of floats
+ The output values.
+ """
+ return (3.0 / (2.0 * np.sqrt(2.0))) * (
+ np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
+ )
+
+ # Create the custom-spacing CoreWingCrossSectionMovement.
+ custom_spacing_angles_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=(custom_harmonic, "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return custom_spacing_angles_core_wing_cross_section_movement_fixture
+
+
+def make_mixed_custom_and_standard_spacing_core_wing_cross_section_movement_fixture():
+ """This method makes a fixture that is a CoreWingCrossSectionMovement with mixed
+ custom and standard spacing functions.
+
+ :return mixed_custom_and_standard_spacing_core_wing_cross_section_movement_fixture: CoreWingCrossSectionMovement
+ This is the CoreWingCrossSectionMovement with mixed custom and standard
+ spacing.
+ """
+ # Initialize the constructing fixture.
+ # Use tip fixture to ensure Lp values stay non-negative during oscillation.
+ base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
+
+ # Define a custom harmonic spacing function.
+ def custom_harmonic(x):
+ """Custom harmonic spacing function: normalized combination of harmonics.
+
+ :param x: (N,) ndarray of floats
+ The input angles in radians.
+
+ :return: (N,) ndarray of floats
+ The output values.
+ """
+ return (3.0 / (2.0 * np.sqrt(2.0))) * (
+ np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
+ )
+
+ # Create the mixed-spacing CoreWingCrossSectionMovement.
+ mixed_custom_and_standard_spacing_core_wing_cross_section_movement_fixture = (
+ CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
+ ampLp_Wcsp_Lpp=(0.4, 0.3, 0.15),
+ periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
+ spacingLp_Wcsp_Lpp=(custom_harmonic, "uniform", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampAngles_Wcsp_to_Wcs_ixyz=(15.0, 10.0, 5.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 1.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", custom_harmonic, "uniform"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+ )
+
+ # Return the CoreWingCrossSectionMovement fixture.
+ return mixed_custom_and_standard_spacing_core_wing_cross_section_movement_fixture
diff --git a/tests/unit/fixtures/core_wing_movement_fixtures.py b/tests/unit/fixtures/core_wing_movement_fixtures.py
new file mode 100644
index 000000000..548c8b688
--- /dev/null
+++ b/tests/unit/fixtures/core_wing_movement_fixtures.py
@@ -0,0 +1,695 @@
+"""This module contains functions to create CoreWingMovements for use in tests."""
+
+import numpy as np
+
+# noinspection PyProtectedMember
+from pterasoftware._core import CoreWingMovement
+
+from . import core_wing_cross_section_movement_fixtures, geometry_fixtures
+
+
+def make_static_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with all parameters
+ zero (no movement).
+
+ :return static_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with no movement.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the static CoreWingMovement.
+ static_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return static_core_wing_movement_fixture
+
+
+def make_basic_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with general-purpose
+ moderate values.
+
+ :return basic_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with general-purpose values.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_basic_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the basic CoreWingMovement.
+ basic_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.1, 0.05, 0.08),
+ periodLer_Gs_Cgs=(2.0, 2.0, 2.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(5.0, 3.0, 2.0),
+ periodAngles_Gs_to_Wn_ixyz=(2.0, 2.0, 2.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return basic_core_wing_movement_fixture
+
+
+def make_sine_spacing_Ler_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with sine spacing for
+ Ler_Gs_Cgs.
+
+ :return sine_spacing_Ler_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with sine spacing for Ler_Gs_Cgs.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the CoreWingMovement with sine spacing for Ler_Gs_Cgs.
+ sine_spacing_Ler_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.2, 0.0, 0.0),
+ periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return sine_spacing_Ler_core_wing_movement_fixture
+
+
+def make_uniform_spacing_Ler_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with uniform spacing for
+ Ler_Gs_Cgs.
+
+ :return uniform_spacing_Ler_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with uniform spacing for Ler_Gs_Cgs.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the CoreWingMovement with uniform spacing for Ler_Gs_Cgs.
+ uniform_spacing_Ler_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.2, 0.0, 0.0),
+ periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("uniform", "uniform", "uniform"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return uniform_spacing_Ler_core_wing_movement_fixture
+
+
+def make_mixed_spacing_Ler_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with mixed spacing for
+ Ler_Gs_Cgs.
+
+ :return mixed_spacing_Ler_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with mixed spacing for Ler_Gs_Cgs.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the CoreWingMovement with mixed spacing for Ler_Gs_Cgs.
+ mixed_spacing_Ler_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.2, 0.15, 0.1),
+ periodLer_Gs_Cgs=(1.0, 1.0, 1.0),
+ spacingLer_Gs_Cgs=("sine", "uniform", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return mixed_spacing_Ler_core_wing_movement_fixture
+
+
+def make_sine_spacing_angles_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with sine spacing for
+ angles_Gs_to_Wn_ixyz.
+
+ :return sine_spacing_angles_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with sine spacing for angles_Gs_to_Wn_ixyz.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the CoreWingMovement with sine spacing for angles_Gs_to_Wn_ixyz.
+ sine_spacing_angles_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return sine_spacing_angles_core_wing_movement_fixture
+
+
+def make_uniform_spacing_angles_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with uniform spacing for
+ angles_Gs_to_Wn_ixyz.
+
+ :return uniform_spacing_angles_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with uniform spacing for angles_Gs_to_Wn_ixyz.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the CoreWingMovement with uniform spacing for angles_Gs_to_Wn_ixyz.
+ uniform_spacing_angles_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("uniform", "uniform", "uniform"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return uniform_spacing_angles_core_wing_movement_fixture
+
+
+def make_mixed_spacing_angles_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with mixed spacing for
+ angles_Gs_to_Wn_ixyz.
+
+ :return mixed_spacing_angles_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with mixed spacing for angles_Gs_to_Wn_ixyz.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the CoreWingMovement with mixed spacing for angles_Gs_to_Wn_ixyz.
+ mixed_spacing_angles_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(10.0, 15.0, 8.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 1.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "uniform", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return mixed_spacing_angles_core_wing_movement_fixture
+
+
+def make_Ler_only_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement where only Ler_Gs_Cgs
+ moves.
+
+ :return Ler_only_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with only Ler_Gs_Cgs movement.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the Ler-only CoreWingMovement.
+ Ler_only_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.15, 0.1, 0.08),
+ periodLer_Gs_Cgs=(1.5, 1.5, 1.5),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return Ler_only_core_wing_movement_fixture
+
+
+def make_angles_only_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement where only
+ angles_Gs_to_Wn_ixyz moves.
+
+ :return angles_only_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with only angles_Gs_to_Wn_ixyz movement.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the angles-only CoreWingMovement.
+ angles_only_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(12.0, 8.0, 5.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.5, 1.5, 1.5),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return angles_only_core_wing_movement_fixture
+
+
+def make_phase_offset_Ler_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with non-zero phase offset
+ for Ler_Gs_Cgs.
+
+ :return phase_offset_Ler_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with phase offset for Ler_Gs_Cgs.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the phase-offset CoreWingMovement.
+ phase_offset_Ler_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.1, 0.08, 0.06),
+ periodLer_Gs_Cgs=(1.0, 1.0, 1.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(90.0, -45.0, 60.0),
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return phase_offset_Ler_core_wing_movement_fixture
+
+
+def make_phase_offset_angles_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with non-zero phase offset
+ for angles_Gs_to_Wn_ixyz.
+
+ :return phase_offset_angles_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with phase offset for angles_Gs_to_Wn_ixyz.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the phase-offset CoreWingMovement.
+ phase_offset_angles_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(10.0, 12.0, 8.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 1.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(45.0, 90.0, -30.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return phase_offset_angles_core_wing_movement_fixture
+
+
+def make_multiple_periods_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with different periods
+ for different dimensions.
+
+ :return multiple_periods_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with different periods.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_multiple_periods_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the multiple-periods CoreWingMovement.
+ multiple_periods_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.1, 0.08, 0.06),
+ periodLer_Gs_Cgs=(1.0, 2.0, 3.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(8.0, 10.0, 12.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.5, 1.5, 2.5),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return multiple_periods_core_wing_movement_fixture
+
+
+def make_custom_spacing_Ler_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with a custom spacing
+ function for Ler_Gs_Cgs.
+
+ :return custom_spacing_Ler_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with custom spacing for Ler_Gs_Cgs.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Define a custom harmonic spacing function.
+ def custom_harmonic(x):
+ """Custom harmonic spacing function: normalized combination of harmonics.
+
+ This function satisfies all requirements: starts at 0, returns to 0 at
+ 2*pi, has zero mean, has amplitude of 1, and is periodic.
+
+ :param x: (N,) ndarray of floats
+ The input angles in radians.
+
+ :return: (N,) ndarray of floats
+ The output values.
+ """
+ return (3.0 / (2.0 * np.sqrt(2.0))) * (
+ np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
+ )
+
+ # Create the custom-spacing CoreWingMovement.
+ custom_spacing_Ler_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.15, 0.0, 0.0),
+ periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=(custom_harmonic, "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return custom_spacing_Ler_core_wing_movement_fixture
+
+
+def make_custom_spacing_angles_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with a custom spacing
+ function for angles_Gs_to_Wn_ixyz.
+
+ :return custom_spacing_angles_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with custom spacing for angles_Gs_to_Wn_ixyz.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Define a custom harmonic spacing function.
+ def custom_harmonic(x):
+ """Custom harmonic spacing function: normalized combination of harmonics.
+
+ This function satisfies all requirements: starts at 0, returns to 0 at
+ 2*pi, has zero mean, has amplitude of 1, and is periodic.
+
+ :param x: (N,) ndarray of floats
+ The input angles in radians.
+
+ :return: (N,) ndarray of floats
+ The output values.
+ """
+ return (3.0 / (2.0 * np.sqrt(2.0))) * (
+ np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
+ )
+
+ # Create the custom-spacing CoreWingMovement.
+ custom_spacing_angles_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=(custom_harmonic, "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return custom_spacing_angles_core_wing_movement_fixture
+
+
+def make_mixed_custom_and_standard_spacing_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with mixed custom and
+ standard spacing functions.
+
+ :return mixed_custom_and_standard_spacing_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with mixed custom and standard spacing.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_mixed_custom_and_standard_spacing_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Define a custom harmonic spacing function.
+ def custom_harmonic(x):
+ """Custom harmonic spacing function: normalized combination of harmonics.
+
+ :param x: (N,) ndarray of floats
+ The input angles in radians.
+
+ :return: (N,) ndarray of floats
+ The output values.
+ """
+ return (3.0 / (2.0 * np.sqrt(2.0))) * (
+ np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
+ )
+
+ # Create the mixed-spacing CoreWingMovement.
+ mixed_custom_and_standard_spacing_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.1, 0.08, 0.06),
+ periodLer_Gs_Cgs=(1.0, 1.0, 1.0),
+ spacingLer_Gs_Cgs=(custom_harmonic, "uniform", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(8.0, 10.0, 6.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 1.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", custom_harmonic, "uniform"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return mixed_custom_and_standard_spacing_core_wing_movement_fixture
+
+
+def make_rotation_point_offset_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with a non zero rotation
+ point offset.
+
+ :return rotation_point_offset_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with a non zero rotationPointOffset_Gs_Ler.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the CoreWingMovement with rotation point offset.
+ # The offset is in y direction (0.0, 0.5, 0.0), and we rotate about the x axis.
+ # This causes the wing to trace an arc in the yz plane as it rotates.
+ rotation_point_offset_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ rotationPointOffset_Gs_Ler=(0.0, 0.5, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return rotation_point_offset_core_wing_movement_fixture
+
+
+def make_periodic_geometry_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement with periodic geometry motion
+ suitable for testing the variable geometry optimization.
+
+ The fixture uses a 0.1s period which aligns well with common delta_time values
+ like 0.01s (10 steps per period) and 0.02s (5 steps per period).
+
+ :return periodic_geometry_core_wing_movement_fixture: CoreWingMovement
+ This is the CoreWingMovement with periodic geometry motion.
+ """
+ # Initialize the constructing fixtures.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ # Create the periodic geometry CoreWingMovement.
+ # Use a 0.1s period for angular motion (plunging wing motion).
+ periodic_geometry_core_wing_movement_fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(5.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.1, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ # Return the CoreWingMovement fixture.
+ return periodic_geometry_core_wing_movement_fixture
+
+
+def make_2_chordwise_panels_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement for a Wing with
+ 2 chordwise panels.
+
+ :return: CoreWingMovement for a Wing with 2 chordwise panels.
+ """
+ base_wing = geometry_fixtures.make_wing_with_2_chordwise_panels()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ return fixture
+
+
+def make_3_chordwise_panels_core_wing_movement_fixture():
+ """This method makes a fixture that is a CoreWingMovement for a Wing with
+ 3 chordwise panels.
+
+ :return: CoreWingMovement for a Wing with 3 chordwise panels.
+ """
+ base_wing = geometry_fixtures.make_wing_with_3_chordwise_panels()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ fixture = CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ spacingLer_Gs_Cgs=("sine", "sine", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ return fixture
diff --git a/tests/unit/fixtures/movement_fixtures.py b/tests/unit/fixtures/movement_fixtures.py
index 4fbb904d3..03652199e 100644
--- a/tests/unit/fixtures/movement_fixtures.py
+++ b/tests/unit/fixtures/movement_fixtures.py
@@ -135,31 +135,6 @@ def make_movement_with_custom_delta_time_fixture():
return movement_with_custom_delta_time_fixture
-def make_cyclic_movement_fixture():
- """This method makes a fixture that is a Movement with cyclic motion.
-
- :return cyclic_movement_fixture: Movement
- This is the Movement with cyclic motion.
- """
- # Initialize the constructing fixtures.
- airplane_movements = [
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- ]
- operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=operating_point_fixtures.make_basic_operating_point_fixture()
- )
-
- # Create the cyclic Movement.
- cyclic_movement_fixture = ps.movements.movement.Movement(
- airplane_movements=airplane_movements,
- operating_point_movement=operating_point_movement,
- num_cycles=2,
- )
-
- # Return the Movement fixture.
- return cyclic_movement_fixture
-
-
def make_movement_with_multiple_airplanes_fixture():
"""This method makes a fixture that is a Movement with multiple AirplaneMovements.
diff --git a/tests/unit/fixtures/movements_functions_fixtures.py b/tests/unit/fixtures/movements_functions_fixtures.py
deleted file mode 100644
index f1068aeb3..000000000
--- a/tests/unit/fixtures/movements_functions_fixtures.py
+++ /dev/null
@@ -1,345 +0,0 @@
-"""This module contains functions to create fixtures for movements functions tests."""
-
-import numpy as np
-
-
-def make_scalar_parameters_fixture():
- """This method makes a fixture with scalar parameters for testing oscillating
- functions.
-
- :return tuple of scalars
- This returns a tuple containing scalar values for amps, periods, phases, and
- bases.
- """
- amps = 1.0
- periods = 1.0
- phases = 0.0
- bases = 0.0
-
- return amps, periods, phases, bases
-
-
-def make_array_parameters_fixture():
- """This method makes a fixture with array parameters for testing oscillating
- functions.
-
- :return tuple of (3,) ndarrays of floats
- This returns a tuple containing array values for amps, periods, phases, and
- bases.
- """
- amps = np.array([1.0, 2.0, 0.5], dtype=float)
- periods = np.array([1.0, 2.0, 0.5], dtype=float)
- phases = np.array([0.0, 90.0, -45.0], dtype=float)
- bases = np.array([0.0, 1.0, -0.5], dtype=float)
-
- return amps, periods, phases, bases
-
-
-def make_static_parameters_fixture():
- """This method makes a fixture with static parameters for testing oscillating
- functions.
-
- :return tuple of scalars
- This returns a tuple containing scalar values representing static motion where
- amps and periods are both 0.0.
- """
- amps = 0.0
- periods = 0.0
- phases = 0.0
- bases = 2.0
-
- return amps, periods, phases, bases
-
-
-def make_mixed_static_parameters_fixture():
- """This method makes a fixture with mixed static and dynamic parameters for testing
- oscillating functions.
-
- :return tuple of (3,) ndarrays of floats
- This returns a tuple containing array values where some elements are static
- and some are dynamic.
- """
- amps = np.array([1.0, 0.0, 0.5], dtype=float)
- periods = np.array([1.0, 0.0, 0.5], dtype=float)
- phases = np.array([0.0, 0.0, -45.0], dtype=float)
- bases = np.array([0.0, 1.0, -0.5], dtype=float)
-
- return amps, periods, phases, bases
-
-
-def make_phase_offset_parameters_fixture():
- """This method makes a fixture with phase-offset parameters for testing oscillating
- functions.
-
- :return tuple of scalars
- This returns a tuple containing scalar values with a non-zero phase.
- """
- amps = 1.0
- periods = 1.0
- phases = 90.0
- bases = 0.0
-
- return amps, periods, phases, bases
-
-
-def make_large_amplitude_parameters_fixture():
- """This method makes a fixture with large amplitude parameters for testing
- oscillating functions.
-
- :return tuple of scalars
- This returns a tuple containing scalar values with a large amplitude.
- """
- amps = 10.0
- periods = 1.0
- phases = 0.0
- bases = 5.0
-
- return amps, periods, phases, bases
-
-
-def make_small_period_parameters_fixture():
- """This method makes a fixture with small period parameters for testing oscillating
- functions.
-
- :return tuple of scalars
- This returns a tuple containing scalar values with a small period.
- """
- amps = 1.0
- periods = 0.1
- phases = 0.0
- bases = 0.0
-
- return amps, periods, phases, bases
-
-
-def make_negative_phase_parameters_fixture():
- """This method makes a fixture with negative phase parameters for testing
- oscillating functions.
-
- :return tuple of scalars
- This returns a tuple containing scalar values with a negative phase.
- """
- amps = 1.0
- periods = 1.0
- phases = -90.0
- bases = 0.0
-
- return amps, periods, phases, bases
-
-
-def make_max_phase_parameters_fixture():
- """This method makes a fixture with maximum phase parameters for testing
- oscillating functions.
-
- :return tuple of scalars
- This returns a tuple containing scalar values with phase at the maximum
- allowed value.
- """
- amps = 1.0
- periods = 1.0
- phases = 180.0
- bases = 0.0
-
- return amps, periods, phases, bases
-
-
-def make_min_phase_parameters_fixture():
- """This method makes a fixture with minimum phase parameters for testing
- oscillating functions.
-
- :return tuple of scalars
- This returns a tuple containing scalar values with phase just above the
- minimum allowed value.
- """
- amps = 1.0
- periods = 1.0
- phases = -179.9
- bases = 0.0
-
- return amps, periods, phases, bases
-
-
-def make_num_steps_and_delta_time_fixture():
- """This method makes a fixture for num_steps and delta_time parameters.
-
- :return tuple of int and float
- This returns a tuple containing num_steps as an int and delta_time as a
- float.
- """
- num_steps = 100
- delta_time = 0.01
-
- return num_steps, delta_time
-
-
-def make_small_num_steps_fixture():
- """This method makes a fixture for small num_steps parameter.
-
- :return int
- This returns num_steps as a small positive int.
- """
- num_steps = 10
-
- return num_steps
-
-
-def make_large_num_steps_fixture():
- """This method makes a fixture for large num_steps parameter.
-
- :return int
- This returns num_steps as a large positive int.
- """
- num_steps = 1000
-
- return num_steps
-
-
-def make_valid_custom_sine_function_fixture():
- """This method makes a fixture that is a valid custom sine function for testing
- oscillating_customspaces.
-
- :return callable
- This returns a valid custom function that satisfies all requirements for
- oscillating_customspaces.
- """
-
- def custom_sine(x):
- return np.sin(x)
-
- return custom_sine
-
-
-def make_valid_custom_triangle_function_fixture():
- """This method makes a fixture that is a valid custom triangle function for testing
- oscillating_customspaces.
-
- :return callable
- This returns a valid custom triangle function that satisfies all requirements
- for oscillating_customspaces.
- """
-
- def custom_triangle(x):
- return 2.0 / np.pi * np.arcsin(np.sin(x))
-
- return custom_triangle
-
-
-def make_valid_custom_harmonic_function_fixture():
- """This method makes a fixture that is a valid custom harmonic function for testing
- oscillating_customspaces.
-
- :return callable
- This returns a valid custom harmonic function that satisfies all requirements
- for oscillating_customspaces.
- """
-
- def custom_harmonic(x):
- # Create harmonic with fundamental and second harmonic.
- raw = np.sin(x) + 0.5 * np.sin(2 * x)
- # Normalize to have amplitude of 1.
- raw_min = float(np.min(raw))
- raw_max = float(np.max(raw))
- amplitude = (raw_max - raw_min) / 2.0
- return raw / amplitude
-
- return custom_harmonic
-
-
-def make_invalid_custom_function_wrong_start_fixture():
- """This method makes a fixture that is an invalid custom function that doesn't
- start at 0.
-
- :return callable
- This returns an invalid custom function for testing validation.
- """
-
- def invalid_start(x):
- return np.sin(x) + 0.5
-
- return invalid_start
-
-
-def make_invalid_custom_function_wrong_end_fixture():
- """This method makes a fixture that is an invalid custom function that doesn't
- return to 0 after one period.
-
- :return callable
- This returns an invalid custom function for testing validation.
- """
-
- def invalid_end(x):
- return np.sin(x * 1.01)
-
- return invalid_end
-
-
-def make_invalid_custom_function_wrong_amplitude_fixture():
- """This method makes a fixture that is an invalid custom function with amplitude
- not equal to 1.
-
- :return callable
- This returns an invalid custom function for testing validation.
- """
-
- def invalid_amplitude(x):
- return 2.0 * np.sin(x)
-
- return invalid_amplitude
-
-
-def make_invalid_custom_function_not_periodic_fixture():
- """This method makes a fixture that is an invalid custom function that is not
- periodic.
-
- :return callable
- This returns an invalid custom function for testing validation.
- """
-
- def invalid_periodic(x):
- return np.sin(x) * (1.0 + 0.01 * x)
-
- return invalid_periodic
-
-
-def make_invalid_custom_function_returns_nan_fixture():
- """This method makes a fixture that is an invalid custom function that returns NaN.
-
- :return callable
- This returns an invalid custom function for testing validation.
- """
-
- def invalid_nan(x):
- result = np.sin(x)
- result[len(result) // 2] = np.nan
- return result
-
- return invalid_nan
-
-
-def make_invalid_custom_function_returns_inf_fixture():
- """This method makes a fixture that is an invalid custom function that returns Inf.
-
- :return callable
- This returns an invalid custom function for testing validation.
- """
-
- def invalid_inf(x):
- result = np.sin(x)
- result[len(result) // 2] = np.inf
- return result
-
- return invalid_inf
-
-
-def make_invalid_custom_function_wrong_shape_fixture():
- """This method makes a fixture that is an invalid custom function that returns the
- wrong shape.
-
- :return callable
- This returns an invalid custom function for testing validation.
- """
-
- def invalid_shape(x):
- return np.sin(x)[:-1]
-
- return invalid_shape
diff --git a/tests/unit/fixtures/operating_point_movement_fixtures.py b/tests/unit/fixtures/operating_point_movement_fixtures.py
index dcd8ca408..31b87acda 100644
--- a/tests/unit/fixtures/operating_point_movement_fixtures.py
+++ b/tests/unit/fixtures/operating_point_movement_fixtures.py
@@ -1,8 +1,6 @@
"""This module contains functions to create OperatingPointMovements for use in
tests."""
-import numpy as np
-
import pterasoftware as ps
from . import operating_point_fixtures
@@ -58,178 +56,3 @@ def make_sine_spacing_operating_point_movement_fixture():
# Return the OperatingPointMovement fixture.
return sine_spacing_operating_point_movement_fixture
-
-
-def make_uniform_spacing_operating_point_movement_fixture():
- """This method makes a fixture that is an OperatingPointMovement with uniform
- spacing for vCg__E.
-
- :return uniform_spacing_operating_point_movement_fixture: OperatingPointMovement
- This is the OperatingPointMovement with uniform spacing for vCg__E.
- """
- # Initialize the constructing fixture.
- base_operating_point = (
- operating_point_fixtures.make_high_speed_operating_point_fixture()
- )
-
- # Create the uniform spacing OperatingPointMovement.
- uniform_spacing_operating_point_movement_fixture = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=10.0,
- periodVCg__E=1.0,
- spacingVCg__E="uniform",
- phaseVCg__E=0.0,
- )
- )
-
- # Return the OperatingPointMovement fixture.
- return uniform_spacing_operating_point_movement_fixture
-
-
-def make_phase_offset_operating_point_movement_fixture():
- """This method makes a fixture that is an OperatingPointMovement with
- non-zero phase offset for vCg__E.
-
- :return phase_offset_operating_point_movement_fixture: OperatingPointMovement
- This is the OperatingPointMovement with phase offset for vCg__E.
- """
- # Initialize the constructing fixture.
- base_operating_point = (
- operating_point_fixtures.make_high_speed_operating_point_fixture()
- )
-
- # Create the phase offset OperatingPointMovement.
- phase_offset_operating_point_movement_fixture = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=20.0,
- periodVCg__E=2.0,
- spacingVCg__E="sine",
- phaseVCg__E=90.0,
- )
- )
-
- # Return the OperatingPointMovement fixture.
- return phase_offset_operating_point_movement_fixture
-
-
-def make_custom_spacing_operating_point_movement_fixture():
- """This method makes a fixture that is an OperatingPointMovement with a
- custom spacing function for vCg__E.
-
- :return custom_spacing_operating_point_movement_fixture: OperatingPointMovement
- This is the OperatingPointMovement with custom spacing for vCg__E.
- """
- # Initialize the constructing fixture.
- base_operating_point = (
- operating_point_fixtures.make_high_speed_operating_point_fixture()
- )
-
- # Define a custom harmonic spacing function.
- def custom_harmonic(x):
- """Custom harmonic spacing function: normalized combination of harmonics.
-
- This function satisfies all requirements: starts at 0, returns to 0 at
- 2*pi, has zero mean, has amplitude of 1, and is periodic.
-
- :param x: (N,) ndarray of floats
- The input angles in radians.
-
- :return: (N,) ndarray of floats
- The output values.
- """
- return (3.0 / (2.0 * np.sqrt(2.0))) * (
- np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
- )
-
- # Create the custom spacing OperatingPointMovement.
- custom_spacing_operating_point_movement_fixture = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=15.0,
- periodVCg__E=1.5,
- spacingVCg__E=custom_harmonic,
- phaseVCg__E=0.0,
- )
- )
-
- # Return the OperatingPointMovement fixture.
- return custom_spacing_operating_point_movement_fixture
-
-
-def make_basic_operating_point_movement_fixture():
- """This method makes a fixture that is an OperatingPointMovement with
- general-purpose moderate values.
-
- :return basic_operating_point_movement_fixture: OperatingPointMovement
- This is the OperatingPointMovement with general-purpose values.
- """
- # Initialize the constructing fixture.
- base_operating_point = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Create the basic OperatingPointMovement.
- basic_operating_point_movement_fixture = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=5.0,
- periodVCg__E=2.0,
- spacingVCg__E="sine",
- phaseVCg__E=0.0,
- )
- )
-
- # Return the OperatingPointMovement fixture.
- return basic_operating_point_movement_fixture
-
-
-def make_large_amplitude_operating_point_movement_fixture():
- """This method makes a fixture that is an OperatingPointMovement with large
- amplitude relative to base speed.
-
- :return large_amplitude_operating_point_movement_fixture: OperatingPointMovement
- This is the OperatingPointMovement with large amplitude for vCg__E.
- """
- # Initialize the constructing fixture.
- base_operating_point = (
- operating_point_fixtures.make_high_speed_operating_point_fixture()
- )
-
- # Create the large amplitude OperatingPointMovement.
- large_amplitude_operating_point_movement_fixture = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=50.0,
- periodVCg__E=1.0,
- spacingVCg__E="sine",
- phaseVCg__E=0.0,
- )
- )
-
- # Return the OperatingPointMovement fixture.
- return large_amplitude_operating_point_movement_fixture
-
-
-def make_long_period_operating_point_movement_fixture():
- """This method makes a fixture that is an OperatingPointMovement with a long
- period.
-
- :return long_period_operating_point_movement_fixture: OperatingPointMovement
- This is the OperatingPointMovement with a long period for vCg__E.
- """
- # Initialize the constructing fixture.
- base_operating_point = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Create the long period OperatingPointMovement.
- long_period_operating_point_movement_fixture = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=3.0,
- periodVCg__E=10.0,
- spacingVCg__E="sine",
- phaseVCg__E=0.0,
- )
- )
-
- # Return the OperatingPointMovement fixture.
- return long_period_operating_point_movement_fixture
diff --git a/tests/unit/fixtures/oscillation_fixtures.py b/tests/unit/fixtures/oscillation_fixtures.py
new file mode 100644
index 000000000..2a1549f2d
--- /dev/null
+++ b/tests/unit/fixtures/oscillation_fixtures.py
@@ -0,0 +1,76 @@
+"""This module contains functions to create fixtures for oscillation tests."""
+
+import numpy as np
+
+
+def make_static_parameters_fixture():
+ """This method makes a fixture with static parameters for testing oscillating
+ functions.
+
+ :return tuple of scalars
+ This returns a tuple containing scalar values representing static motion where
+ amp and period are both 0.0.
+ """
+ amp = 0.0
+ period = 0.0
+ phase = 0.0
+ base = 2.0
+
+ return amp, period, phase, base
+
+
+def make_phase_offset_parameters_fixture():
+ """This method makes a fixture with a non zero phase offset parameter for testing
+ oscillating functions.
+
+ :return tuple of scalars
+ This returns a tuple containing scalar values with a non zero phase.
+ """
+ amp = 1.0
+ period = 1.0
+ phase = 90.0
+ base = 0.0
+
+ return amp, period, phase, base
+
+
+def make_max_phase_parameters_fixture():
+ """This method makes a fixture with maximum phase parameters for testing
+ oscillating functions.
+
+ :return tuple of scalars
+ This returns a tuple containing scalar values with phase at the maximum
+ allowed value.
+ """
+ amp = 1.0
+ period = 1.0
+ phase = 180.0
+ base = 0.0
+
+ return amp, period, phase, base
+
+
+def make_time_fixture():
+ """This method makes a fixture for a time parameter.
+
+ :return float
+ This returns a time.
+ """
+ time = 5.32
+
+ return time
+
+
+def make_valid_custom_sine_function_fixture():
+ """This method makes a fixture that is a valid custom sine function for testing
+ oscillating_customspaces.
+
+ :return callable
+ This returns a valid custom function that satisfies all requirements for
+ oscillating_customspaces.
+ """
+
+ def custom_sine(x):
+ return np.sin(x)
+
+ return custom_sine
diff --git a/tests/unit/fixtures/panel_fixtures.py b/tests/unit/fixtures/panel_fixtures.py
index 8041621d5..dc41bf2fa 100644
--- a/tests/unit/fixtures/panel_fixtures.py
+++ b/tests/unit/fixtures/panel_fixtures.py
@@ -26,40 +26,6 @@ def make_basic_panel_fixture():
return basic_panel_fixture
-def make_leading_edge_panel_fixture():
- """This method makes a fixture that is a Panel at the leading edge.
-
- :return: A Panel at the leading edge configured for testing.
- """
- leading_edge_panel_fixture = _panel.Panel(
- Frpp_G_Cg=np.array([0.0, 0.5, 0.0]),
- Flpp_G_Cg=np.array([0.0, 0.0, 0.0]),
- Blpp_G_Cg=np.array([0.25, 0.0, 0.0]),
- Brpp_G_Cg=np.array([0.25, 0.5, 0.0]),
- is_leading_edge=True,
- is_trailing_edge=False,
- )
-
- return leading_edge_panel_fixture
-
-
-def make_trailing_edge_panel_fixture():
- """This method makes a fixture that is a Panel at the trailing edge.
-
- :return: A Panel at the trailing edge configured for testing.
- """
- trailing_edge_panel_fixture = _panel.Panel(
- Frpp_G_Cg=np.array([0.75, 0.5, 0.0]),
- Flpp_G_Cg=np.array([0.75, 0.0, 0.0]),
- Blpp_G_Cg=np.array([1.0, 0.0, 0.0]),
- Brpp_G_Cg=np.array([1.0, 0.5, 0.0]),
- is_leading_edge=False,
- is_trailing_edge=True,
- )
-
- return trailing_edge_panel_fixture
-
-
def make_leading_and_trailing_edge_panel_fixture():
"""This method makes a fixture that is a Panel at both leading and trailing edges.
@@ -79,80 +45,6 @@ def make_leading_and_trailing_edge_panel_fixture():
return leading_and_trailing_edge_panel_fixture
-def make_square_panel_fixture():
- """This method makes a fixture that is a square Panel.
-
- The Panel is 1.0 m x 1.0 m, lying flat in the xy plane.
-
- :return: A square Panel configured for testing.
- """
- square_panel_fixture = _panel.Panel(
- Frpp_G_Cg=np.array([0.0, 1.0, 0.0]),
- Flpp_G_Cg=np.array([0.0, 0.0, 0.0]),
- Blpp_G_Cg=np.array([1.0, 0.0, 0.0]),
- Brpp_G_Cg=np.array([1.0, 1.0, 0.0]),
- is_leading_edge=False,
- is_trailing_edge=False,
- )
-
- return square_panel_fixture
-
-
-def make_high_aspect_ratio_panel_fixture():
- """This method makes a fixture that is a high aspect ratio Panel.
-
- The Panel has span 4.0 m and chord 1.0 m.
-
- :return: A high aspect ratio Panel configured for testing.
- """
- high_aspect_ratio_panel_fixture = _panel.Panel(
- Frpp_G_Cg=np.array([0.0, 4.0, 0.0]),
- Flpp_G_Cg=np.array([0.0, 0.0, 0.0]),
- Blpp_G_Cg=np.array([1.0, 0.0, 0.0]),
- Brpp_G_Cg=np.array([1.0, 4.0, 0.0]),
- is_leading_edge=False,
- is_trailing_edge=False,
- )
-
- return high_aspect_ratio_panel_fixture
-
-
-def make_twisted_panel_fixture():
- """This method makes a fixture that is a twisted (non planar) Panel.
-
- :return: A twisted Panel configured for testing.
- """
- twisted_panel_fixture = _panel.Panel(
- Frpp_G_Cg=np.array([0.0, 1.0, 0.5]),
- Flpp_G_Cg=np.array([0.0, 0.0, 0.0]),
- Blpp_G_Cg=np.array([2.0, 0.0, -0.5]),
- Brpp_G_Cg=np.array([2.0, 1.0, 0.0]),
- is_leading_edge=False,
- is_trailing_edge=False,
- )
-
- return twisted_panel_fixture
-
-
-def make_small_panel_fixture():
- """This method makes a fixture that is a very small Panel.
-
- The Panel is 0.01 m x 0.01 m.
-
- :return: A very small Panel configured for testing.
- """
- small_panel_fixture = _panel.Panel(
- Frpp_G_Cg=np.array([0.00, 0.01, 0.0]),
- Flpp_G_Cg=np.array([0.00, 0.00, 0.0]),
- Blpp_G_Cg=np.array([0.01, 0.00, 0.0]),
- Brpp_G_Cg=np.array([0.01, 0.01, 0.0]),
- is_leading_edge=False,
- is_trailing_edge=False,
- )
-
- return small_panel_fixture
-
-
def make_panel_with_set_once_attributes_fixture():
"""This method makes a fixture that is a Panel with set once attributes populated.
diff --git a/tests/unit/fixtures/parameter_validation_fixtures.py b/tests/unit/fixtures/parameter_validation_fixtures.py
index 630d1cf2d..568e61bd5 100644
--- a/tests/unit/fixtures/parameter_validation_fixtures.py
+++ b/tests/unit/fixtures/parameter_validation_fixtures.py
@@ -21,12 +21,6 @@ def make_empty_str_fixture():
# Valid bool fixtures.
-def make_valid_bool_fixture():
- """Makes a fixture that is a valid bool for boolLike_return_bool.
-
- :return: A valid bool.
- """
- return True
def make_valid_numpy_bool_fixture():
@@ -46,22 +40,6 @@ def make_valid_int_fixture():
return 5
-def make_valid_int_at_lower_bound_inclusive_fixture():
- """Makes a fixture that is a valid int at the lower bound (inclusive).
-
- :return: A valid int at lower bound.
- """
- return 0
-
-
-def make_valid_int_at_upper_bound_inclusive_fixture():
- """Makes a fixture that is a valid int at the upper bound (inclusive).
-
- :return: A valid int at upper bound.
- """
- return 10
-
-
# Valid number in range fixtures.
def make_valid_float_fixture():
"""Makes a fixture that is a valid float for number_in_range_return_float.
@@ -393,14 +371,6 @@ def make_invalid_spacing_type_fixture():
return "sine", 123, "uniform"
-def make_scalar_fixture():
- """Makes a fixture that is a scalar for testing array-like validation errors.
-
- :return: A scalar value.
- """
- return 5.0
-
-
def make_invalid_array_with_nan_fixture():
"""Makes a fixture that is an array containing NaN.
diff --git a/tests/unit/fixtures/problem_fixtures.py b/tests/unit/fixtures/problem_fixtures.py
index 2a5f0c4ba..970be1d62 100644
--- a/tests/unit/fixtures/problem_fixtures.py
+++ b/tests/unit/fixtures/problem_fixtures.py
@@ -87,42 +87,6 @@ def make_only_final_results_unsteady_problem_fixture():
return only_final_results_unsteady_problem_fixture
-def make_static_unsteady_problem_fixture():
- """This method makes a fixture that is an UnsteadyProblem with static Movement.
-
- :return static_unsteady_problem_fixture: UnsteadyProblem
- This is the UnsteadyProblem with static Movement.
- """
- # Create a static Movement.
- static_movement = movement_fixtures.make_static_movement_fixture()
-
- # Create the UnsteadyProblem with static Movement.
- static_unsteady_problem_fixture = ps.problems.UnsteadyProblem(
- movement=static_movement,
- only_final_results=False,
- )
-
- return static_unsteady_problem_fixture
-
-
-def make_cyclic_unsteady_problem_fixture():
- """This method makes a fixture that is an UnsteadyProblem with cyclic Movement.
-
- :return cyclic_unsteady_problem_fixture: UnsteadyProblem
- This is the UnsteadyProblem with cyclic Movement.
- """
- # Create a cyclic Movement.
- cyclic_movement = movement_fixtures.make_cyclic_movement_fixture()
-
- # Create the UnsteadyProblem with cyclic Movement.
- cyclic_unsteady_problem_fixture = ps.problems.UnsteadyProblem(
- movement=cyclic_movement,
- only_final_results=False,
- )
-
- return cyclic_unsteady_problem_fixture
-
-
def make_multi_airplane_unsteady_problem_fixture():
"""This method makes a fixture that is an UnsteadyProblem with multiple Airplanes.
diff --git a/tests/unit/fixtures/wing_cross_section_movement_fixtures.py b/tests/unit/fixtures/wing_cross_section_movement_fixtures.py
index 23b670639..231b65a9d 100644
--- a/tests/unit/fixtures/wing_cross_section_movement_fixtures.py
+++ b/tests/unit/fixtures/wing_cross_section_movement_fixtures.py
@@ -1,168 +1,11 @@
"""This module contains functions to create WingCrossSectionMovements for use in
tests."""
-import numpy as np
-
import pterasoftware as ps
from . import geometry_fixtures
-def make_sine_spacing_Lp_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with sine
- spacing for Lp_Wcsp_Lpp.
-
- :return sine_spacing_Lp_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with sine spacing for Lp_Wcsp_Lpp.
- """
- # Initialize the constructing fixture.
- base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Create the WingCrossSectionMovement with sine spacing.
- sine_spacing_Lp_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return sine_spacing_Lp_wing_cross_section_movement_fixture
-
-
-def make_uniform_spacing_Lp_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with uniform
- spacing for Lp_Wcsp_Lpp.
-
- :return uniform_spacing_Lp_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with uniform spacing for
- Lp_Wcsp_Lpp.
- """
- # Initialize the constructing fixture.
- base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Create the WingCrossSectionMovement with uniform spacing.
- uniform_spacing_Lp_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=("uniform", "uniform", "uniform"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return uniform_spacing_Lp_wing_cross_section_movement_fixture
-
-
-def make_mixed_spacing_Lp_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with mixed
- spacing for Lp_Wcsp_Lpp.
-
- :return mixed_spacing_Lp_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with mixed spacing for Lp_Wcsp_Lpp.
- """
- # Initialize the constructing fixture.
- # Use tip fixture which has Lp_Wcsp_Lpp[1] = 2.0, allowing for amplitude of 1.5.
- base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
-
- # Create the WingCrossSectionMovement with mixed spacing.
- mixed_spacing_Lp_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(1.0, 1.5, 0.5),
- periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
- spacingLp_Wcsp_Lpp=("sine", "uniform", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return mixed_spacing_Lp_wing_cross_section_movement_fixture
-
-
-def make_sine_spacing_angles_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with sine
- spacing for angles_Wcsp_to_Wcs_ixyz.
-
- :return sine_spacing_angles_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with sine spacing for
- angles_Wcsp_to_Wcs_ixyz.
- """
- # Initialize the constructing fixture.
- base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Create the WingCrossSectionMovement with sine spacing for angles.
- sine_spacing_angles_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return sine_spacing_angles_wing_cross_section_movement_fixture
-
-
-def make_uniform_spacing_angles_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with uniform
- spacing for angles_Wcsp_to_Wcs_ixyz.
-
- :return uniform_spacing_angles_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with uniform spacing for
- angles_Wcsp_to_Wcs_ixyz.
- """
- # Initialize the constructing fixture.
- base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Create the WingCrossSectionMovement with uniform spacing for angles.
- uniform_spacing_angles_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=("uniform", "uniform", "uniform"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return uniform_spacing_angles_wing_cross_section_movement_fixture
-
-
-def make_mixed_spacing_angles_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with mixed
- spacing for angles_Wcsp_to_Wcs_ixyz.
-
- :return mixed_spacing_angles_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with mixed spacing for
- angles_Wcsp_to_Wcs_ixyz.
- """
- # Initialize the constructing fixture.
- base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Create the WingCrossSectionMovement with mixed spacing for angles.
- mixed_spacing_angles_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 20.0, 5.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 1.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "uniform", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return mixed_spacing_angles_wing_cross_section_movement_fixture
-
-
def make_static_wing_cross_section_movement_fixture():
"""This method makes a fixture that is a WingCrossSectionMovement with all
parameters zero (no movement).
@@ -250,292 +93,3 @@ def make_basic_wing_cross_section_movement_fixture():
# Return the WingCrossSectionMovement fixture.
return basic_wing_cross_section_movement_fixture
-
-
-def make_Lp_only_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement where only
- Lp_Wcsp_Lpp moves.
-
- :return Lp_only_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with only Lp_Wcsp_Lpp movement.
- """
- # Initialize the constructing fixture.
- # Use tip fixture to ensure Lp values stay non-negative during oscillation.
- base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
-
- # Create the Lp-only WingCrossSectionMovement.
- Lp_only_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(0.4, 0.5, 0.15),
- periodLp_Wcsp_Lpp=(1.5, 1.5, 1.5),
- spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return Lp_only_wing_cross_section_movement_fixture
-
-
-def make_angles_only_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement where only
- angles_Wcsp_to_Wcs_ixyz moves.
-
- :return angles_only_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with only angles_Wcsp_to_Wcs_ixyz
- movement.
- """
- # Initialize the constructing fixture.
- base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Create the angles-only WingCrossSectionMovement.
- angles_only_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampAngles_Wcsp_to_Wcs_ixyz=(20.0, 15.0, 10.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.5, 1.5, 1.5),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return angles_only_wing_cross_section_movement_fixture
-
-
-def make_phase_offset_Lp_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with
- non-zero phase offset for Lp_Wcsp_Lpp.
-
- :return phase_offset_Lp_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with phase offset for Lp_Wcsp_Lpp.
- """
- # Initialize the constructing fixture.
- # Use tip fixture to ensure Lp values stay non-negative during oscillation.
- base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
-
- # Create the phase-offset WingCrossSectionMovement.
- phase_offset_Lp_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(0.4, 0.3, 0.15),
- periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
- spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
- phaseLp_Wcsp_Lpp=(90.0, -90.0, 45.0),
- ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return phase_offset_Lp_wing_cross_section_movement_fixture
-
-
-def make_phase_offset_angles_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with
- non-zero phase offset for angles_Wcsp_to_Wcs_ixyz.
-
- :return phase_offset_angles_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with phase offset for
- angles_Wcsp_to_Wcs_ixyz.
- """
- # Initialize the constructing fixture.
- base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Create the phase-offset WingCrossSectionMovement.
- phase_offset_angles_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 15.0, 20.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 1.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(45.0, 90.0, -45.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return phase_offset_angles_wing_cross_section_movement_fixture
-
-
-def make_multiple_periods_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with
- different periods for different dimensions.
-
- :return multiple_periods_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with different periods.
- """
- # Initialize the constructing fixture.
- # Use tip fixture to ensure Lp values stay non-negative during oscillation.
- base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
-
- # Create the multiple-periods WingCrossSectionMovement.
- multiple_periods_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(0.4, 0.4, 0.15),
- periodLp_Wcsp_Lpp=(1.0, 2.0, 3.0),
- spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 15.0, 20.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(0.5, 1.5, 2.5),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return multiple_periods_wing_cross_section_movement_fixture
-
-
-def make_custom_spacing_Lp_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with a
- custom spacing function for Lp_Wcsp_Lpp.
-
- :return custom_spacing_Lp_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with custom spacing for Lp_Wcsp_Lpp.
- """
- # Initialize the constructing fixture.
- # Use tip fixture to ensure Lp values stay non-negative during oscillation.
- base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
-
- # Define a custom harmonic spacing function.
- def custom_harmonic(x):
- """Custom harmonic spacing function: normalized combination of harmonics.
-
- This function satisfies all requirements: starts at 0, returns to 0 at
- 2*pi, has zero mean, has amplitude of 1, and is periodic.
-
- :param x: (N,) ndarray of floats
- The input angles in radians.
-
- :return: (N,) ndarray of floats
- The output values.
- """
- return (3.0 / (2.0 * np.sqrt(2.0))) * (
- np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
- )
-
- # Create the custom-spacing WingCrossSectionMovement.
- custom_spacing_Lp_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(0.4, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=(custom_harmonic, "sine", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return custom_spacing_Lp_wing_cross_section_movement_fixture
-
-
-def make_custom_spacing_angles_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with a
- custom spacing function for angles_Wcsp_to_Wcs_ixyz.
-
- :return custom_spacing_angles_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with custom spacing for
- angles_Wcsp_to_Wcs_ixyz.
- """
- # Initialize the constructing fixture.
- base_wing_cross_section = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Define a custom harmonic spacing function.
- def custom_harmonic(x):
- """Custom harmonic spacing function: normalized combination of harmonics.
-
- This function satisfies all requirements: starts at 0, returns to 0 at
- 2*pi, has zero mean, has amplitude of 1, and is periodic.
-
- :param x: (N,) ndarray of floats
- The input angles in radians.
-
- :return: (N,) ndarray of floats
- The output values.
- """
- return (3.0 / (2.0 * np.sqrt(2.0))) * (
- np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
- )
-
- # Create the custom-spacing WingCrossSectionMovement.
- custom_spacing_angles_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=(custom_harmonic, "sine", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return custom_spacing_angles_wing_cross_section_movement_fixture
-
-
-def make_mixed_custom_and_standard_spacing_wing_cross_section_movement_fixture():
- """This method makes a fixture that is a WingCrossSectionMovement with mixed
- custom and standard spacing functions.
-
- :return mixed_custom_and_standard_spacing_wing_cross_section_movement_fixture: WingCrossSectionMovement
- This is the WingCrossSectionMovement with mixed custom and standard
- spacing.
- """
- # Initialize the constructing fixture.
- # Use tip fixture to ensure Lp values stay non-negative during oscillation.
- base_wing_cross_section = geometry_fixtures.make_tip_wing_cross_section_fixture()
-
- # Define a custom harmonic spacing function.
- def custom_harmonic(x):
- """Custom harmonic spacing function: normalized combination of harmonics.
-
- :param x: (N,) ndarray of floats
- The input angles in radians.
-
- :return: (N,) ndarray of floats
- The output values.
- """
- return (3.0 / (2.0 * np.sqrt(2.0))) * (
- np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
- )
-
- # Create the mixed-spacing WingCrossSectionMovement.
- mixed_custom_and_standard_spacing_wing_cross_section_movement_fixture = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_cross_section,
- ampLp_Wcsp_Lpp=(0.4, 0.3, 0.15),
- periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
- spacingLp_Wcsp_Lpp=(custom_harmonic, "uniform", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampAngles_Wcsp_to_Wcs_ixyz=(15.0, 10.0, 5.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 1.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", custom_harmonic, "uniform"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingCrossSectionMovement fixture.
- return mixed_custom_and_standard_spacing_wing_cross_section_movement_fixture
diff --git a/tests/unit/fixtures/wing_movement_fixtures.py b/tests/unit/fixtures/wing_movement_fixtures.py
index 3d311df06..e9b62bcd9 100644
--- a/tests/unit/fixtures/wing_movement_fixtures.py
+++ b/tests/unit/fixtures/wing_movement_fixtures.py
@@ -1,22 +1,20 @@
"""This module contains functions to create WingMovements for use in tests."""
-import numpy as np
-
import pterasoftware as ps
from . import geometry_fixtures, wing_cross_section_movement_fixtures
def make_static_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with all parameters
- zero (no movement).
+ """This method makes a fixture that is a WingMovement with all parameters zero
+ (no movement).
:return static_wing_movement_fixture: WingMovement
This is the WingMovement with no movement.
"""
# Initialize the constructing fixtures.
base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
+ wing_cross_section_movements = [
wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
]
@@ -24,7 +22,7 @@ def make_static_wing_movement_fixture():
# Create the static WingMovement.
static_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
+ wing_cross_section_movements=wing_cross_section_movements,
ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
spacingLer_Gs_Cgs=("sine", "sine", "sine"),
@@ -48,7 +46,7 @@ def make_basic_wing_movement_fixture():
"""
# Initialize the constructing fixtures.
base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
+ wing_cross_section_movements = [
wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
wing_cross_section_movement_fixtures.make_basic_wing_cross_section_movement_fixture(),
]
@@ -56,7 +54,7 @@ def make_basic_wing_movement_fixture():
# Create the basic WingMovement.
basic_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
+ wing_cross_section_movements=wing_cross_section_movements,
ampLer_Gs_Cgs=(0.1, 0.05, 0.08),
periodLer_Gs_Cgs=(2.0, 2.0, 2.0),
spacingLer_Gs_Cgs=("sine", "sine", "sine"),
@@ -71,569 +69,26 @@ def make_basic_wing_movement_fixture():
return basic_wing_movement_fixture
-def make_sine_spacing_Ler_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with sine spacing for
- Ler_Gs_Cgs.
-
- :return sine_spacing_Ler_wing_movement_fixture: WingMovement
- This is the WingMovement with sine spacing for Ler_Gs_Cgs.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the WingMovement with sine spacing for Ler_Gs_Cgs.
- sine_spacing_Ler_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.2, 0.0, 0.0),
- periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- # Return the WingMovement fixture.
- return sine_spacing_Ler_wing_movement_fixture
-
-
-def make_uniform_spacing_Ler_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with uniform spacing for
- Ler_Gs_Cgs.
-
- :return uniform_spacing_Ler_wing_movement_fixture: WingMovement
- This is the WingMovement with uniform spacing for Ler_Gs_Cgs.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the WingMovement with uniform spacing for Ler_Gs_Cgs.
- uniform_spacing_Ler_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.2, 0.0, 0.0),
- periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("uniform", "uniform", "uniform"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- # Return the WingMovement fixture.
- return uniform_spacing_Ler_wing_movement_fixture
-
-
-def make_mixed_spacing_Ler_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with mixed spacing for
- Ler_Gs_Cgs.
-
- :return mixed_spacing_Ler_wing_movement_fixture: WingMovement
- This is the WingMovement with mixed spacing for Ler_Gs_Cgs.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the WingMovement with mixed spacing for Ler_Gs_Cgs.
- mixed_spacing_Ler_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.2, 0.15, 0.1),
- periodLer_Gs_Cgs=(1.0, 1.0, 1.0),
- spacingLer_Gs_Cgs=("sine", "uniform", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- # Return the WingMovement fixture.
- return mixed_spacing_Ler_wing_movement_fixture
-
-
-def make_sine_spacing_angles_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with sine spacing for
- angles_Gs_to_Wn_ixyz.
-
- :return sine_spacing_angles_wing_movement_fixture: WingMovement
- This is the WingMovement with sine spacing for angles_Gs_to_Wn_ixyz.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the WingMovement with sine spacing for angles_Gs_to_Wn_ixyz.
- sine_spacing_angles_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- # Return the WingMovement fixture.
- return sine_spacing_angles_wing_movement_fixture
-
-
-def make_uniform_spacing_angles_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with uniform spacing for
- angles_Gs_to_Wn_ixyz.
-
- :return uniform_spacing_angles_wing_movement_fixture: WingMovement
- This is the WingMovement with uniform spacing for angles_Gs_to_Wn_ixyz.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the WingMovement with uniform spacing for angles_Gs_to_Wn_ixyz.
- uniform_spacing_angles_wing_movement_fixture = (
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("uniform", "uniform", "uniform"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingMovement fixture.
- return uniform_spacing_angles_wing_movement_fixture
-
-
-def make_mixed_spacing_angles_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with mixed spacing for
- angles_Gs_to_Wn_ixyz.
-
- :return mixed_spacing_angles_wing_movement_fixture: WingMovement
- This is the WingMovement with mixed spacing for angles_Gs_to_Wn_ixyz.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the WingMovement with mixed spacing for angles_Gs_to_Wn_ixyz.
- mixed_spacing_angles_wing_movement_fixture = (
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(10.0, 15.0, 8.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 1.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "uniform", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingMovement fixture.
- return mixed_spacing_angles_wing_movement_fixture
-
-
-def make_Ler_only_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement where only Ler_Gs_Cgs
- moves.
-
- :return Ler_only_wing_movement_fixture: WingMovement
- This is the WingMovement with only Ler_Gs_Cgs movement.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the Ler-only WingMovement.
- Ler_only_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.15, 0.1, 0.08),
- periodLer_Gs_Cgs=(1.5, 1.5, 1.5),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- # Return the WingMovement fixture.
- return Ler_only_wing_movement_fixture
-
-
-def make_angles_only_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement where only
- angles_Gs_to_Wn_ixyz moves.
-
- :return angles_only_wing_movement_fixture: WingMovement
- This is the WingMovement with only angles_Gs_to_Wn_ixyz movement.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the angles-only WingMovement.
- angles_only_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(12.0, 8.0, 5.0),
- periodAngles_Gs_to_Wn_ixyz=(1.5, 1.5, 1.5),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- # Return the WingMovement fixture.
- return angles_only_wing_movement_fixture
-
-
-def make_phase_offset_Ler_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with non-zero phase offset
- for Ler_Gs_Cgs.
-
- :return phase_offset_Ler_wing_movement_fixture: WingMovement
- This is the WingMovement with phase offset for Ler_Gs_Cgs.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the phase-offset WingMovement.
- phase_offset_Ler_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.1, 0.08, 0.06),
- periodLer_Gs_Cgs=(1.0, 1.0, 1.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(90.0, -45.0, 60.0),
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- # Return the WingMovement fixture.
- return phase_offset_Ler_wing_movement_fixture
-
-
-def make_phase_offset_angles_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with non-zero phase offset
- for angles_Gs_to_Wn_ixyz.
-
- :return phase_offset_angles_wing_movement_fixture: WingMovement
- This is the WingMovement with phase offset for angles_Gs_to_Wn_ixyz.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the phase-offset WingMovement.
- phase_offset_angles_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(10.0, 12.0, 8.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 1.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(45.0, 90.0, -30.0),
- )
-
- # Return the WingMovement fixture.
- return phase_offset_angles_wing_movement_fixture
-
-
-def make_multiple_periods_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with different periods
- for different dimensions.
-
- :return multiple_periods_wing_movement_fixture: WingMovement
- This is the WingMovement with different periods.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_multiple_periods_wing_cross_section_movement_fixture(),
- ]
-
- # Create the multiple-periods WingMovement.
- multiple_periods_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.1, 0.08, 0.06),
- periodLer_Gs_Cgs=(1.0, 2.0, 3.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(8.0, 10.0, 12.0),
- periodAngles_Gs_to_Wn_ixyz=(0.5, 1.5, 2.5),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- # Return the WingMovement fixture.
- return multiple_periods_wing_movement_fixture
-
-
-def make_custom_spacing_Ler_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with a custom spacing
- function for Ler_Gs_Cgs.
-
- :return custom_spacing_Ler_wing_movement_fixture: WingMovement
- This is the WingMovement with custom spacing for Ler_Gs_Cgs.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Define a custom harmonic spacing function.
- def custom_harmonic(x):
- """Custom harmonic spacing function: normalized combination of harmonics.
-
- This function satisfies all requirements: starts at 0, returns to 0 at
- 2*pi, has zero mean, has amplitude of 1, and is periodic.
-
- :param x: (N,) ndarray of floats
- The input angles in radians.
-
- :return: (N,) ndarray of floats
- The output values.
- """
- return (3.0 / (2.0 * np.sqrt(2.0))) * (
- np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
- )
-
- # Create the custom-spacing WingMovement.
- custom_spacing_Ler_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.15, 0.0, 0.0),
- periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=(custom_harmonic, "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- # Return the WingMovement fixture.
- return custom_spacing_Ler_wing_movement_fixture
-
-
-def make_custom_spacing_angles_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with a custom spacing
- function for angles_Gs_to_Wn_ixyz.
-
- :return custom_spacing_angles_wing_movement_fixture: WingMovement
- This is the WingMovement with custom spacing for angles_Gs_to_Wn_ixyz.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Define a custom harmonic spacing function.
- def custom_harmonic(x):
- """Custom harmonic spacing function: normalized combination of harmonics.
-
- This function satisfies all requirements: starts at 0, returns to 0 at
- 2*pi, has zero mean, has amplitude of 1, and is periodic.
-
- :param x: (N,) ndarray of floats
- The input angles in radians.
-
- :return: (N,) ndarray of floats
- The output values.
- """
- return (3.0 / (2.0 * np.sqrt(2.0))) * (
- np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
- )
-
- # Create the custom-spacing WingMovement.
- custom_spacing_angles_wing_movement_fixture = (
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=(custom_harmonic, "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingMovement fixture.
- return custom_spacing_angles_wing_movement_fixture
-
-
-def make_mixed_custom_and_standard_spacing_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with mixed custom and
- standard spacing functions.
-
- :return mixed_custom_and_standard_spacing_wing_movement_fixture: WingMovement
- This is the WingMovement with mixed custom and standard spacing.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_mixed_custom_and_standard_spacing_wing_cross_section_movement_fixture(),
- ]
-
- # Define a custom harmonic spacing function.
- def custom_harmonic(x):
- """Custom harmonic spacing function: normalized combination of harmonics.
-
- :param x: (N,) ndarray of floats
- The input angles in radians.
-
- :return: (N,) ndarray of floats
- The output values.
- """
- return (3.0 / (2.0 * np.sqrt(2.0))) * (
- np.sin(x) + (1.0 / 3.0) * np.sin(3.0 * x)
- )
-
- # Create the mixed-spacing WingMovement.
- mixed_custom_and_standard_spacing_wing_movement_fixture = (
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.1, 0.08, 0.06),
- periodLer_Gs_Cgs=(1.0, 1.0, 1.0),
- spacingLer_Gs_Cgs=(custom_harmonic, "uniform", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(8.0, 10.0, 6.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 1.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", custom_harmonic, "uniform"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
- )
-
- # Return the WingMovement fixture.
- return mixed_custom_and_standard_spacing_wing_movement_fixture
-
-
-def make_rotation_point_offset_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with a non zero rotation
- point offset.
-
- :return rotation_point_offset_wing_movement_fixture: WingMovement
- This is the WingMovement with a non zero rotationPointOffset_Gs_Ler.
- """
- # Initialize the constructing fixtures.
- base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- # Create the WingMovement with rotation point offset.
- # The offset is in y direction (0.0, 0.5, 0.0), and we rotate about the x axis.
- # This causes the wing to trace an arc in the yz plane as it rotates.
- rotation_point_offset_wing_movement_fixture = (
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- rotationPointOffset_Gs_Ler=(0.0, 0.5, 0.0),
- )
- )
-
- # Return the WingMovement fixture.
- return rotation_point_offset_wing_movement_fixture
-
-
def make_periodic_geometry_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement with periodic geometry motion
- suitable for testing the variable geometry optimization.
-
- The fixture uses a 0.1s period which aligns well with common delta_time values
- like 0.01s (10 steps per period) and 0.02s (5 steps per period).
+ """This method makes a fixture that is a WingMovement with periodic geometry
+ motion, suitable for testing the variable geometry optimization. The fixture
+ uses a 0.1s period which aligns well with common delta_time values like 0.01s
+ (10 steps per period) and 0.02s (5 steps per period).
:return periodic_geometry_wing_movement_fixture: WingMovement
This is the WingMovement with periodic geometry motion.
"""
# Initialize the constructing fixtures.
base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
+ wing_cross_section_movements = [
wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
]
- # Create the periodic geometry WingMovement.
- # Use a 0.1s period for angular motion (plunging wing motion).
+ # Create the periodic-geometry WingMovement.
periodic_geometry_wing_movement_fixture = ps.movements.wing_movement.WingMovement(
base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
+ wing_cross_section_movements=wing_cross_section_movements,
ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
spacingLer_Gs_Cgs=("sine", "sine", "sine"),
@@ -646,59 +101,3 @@ def make_periodic_geometry_wing_movement_fixture():
# Return the WingMovement fixture.
return periodic_geometry_wing_movement_fixture
-
-
-def make_2_chordwise_panels_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement for a Wing with
- 2 chordwise panels.
-
- :return: WingMovement for a Wing with 2 chordwise panels.
- """
- base_wing = geometry_fixtures.make_wing_with_2_chordwise_panels()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- return fixture
-
-
-def make_3_chordwise_panels_wing_movement_fixture():
- """This method makes a fixture that is a WingMovement for a Wing with
- 3 chordwise panels.
-
- :return: WingMovement for a Wing with 3 chordwise panels.
- """
- base_wing = geometry_fixtures.make_wing_with_3_chordwise_panels()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ]
-
- fixture = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- spacingLer_Gs_Cgs=("sine", "sine", "sine"),
- phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
- phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- )
-
- return fixture
diff --git a/tests/unit/test_aerodynamics_functions.py b/tests/unit/test_aerodynamics_functions.py
index 6b67e0c67..ef71e276b 100644
--- a/tests/unit/test_aerodynamics_functions.py
+++ b/tests/unit/test_aerodynamics_functions.py
@@ -78,7 +78,6 @@ def setUp(self):
# Create age and viscosity fixtures.
self.ages = aerodynamics_functions_fixtures.make_ages_fixture()
- self.zero_ages = aerodynamics_functions_fixtures.make_zero_ages_fixture()
self.kinematic_viscosity = (
aerodynamics_functions_fixtures.make_kinematic_viscosity_fixture()
)
diff --git a/tests/unit/test_airfoil.py b/tests/unit/test_airfoil.py
index b8bace6d9..a99ea584e 100644
--- a/tests/unit/test_airfoil.py
+++ b/tests/unit/test_airfoil.py
@@ -636,7 +636,6 @@ class TestAirfoilImmutability(unittest.TestCase):
def setUp(self):
"""Set up test fixtures for immutability tests."""
self.naca0012_airfoil = geometry_fixtures.make_naca0012_airfoil_fixture()
- self.naca2412_airfoil = geometry_fixtures.make_naca2412_airfoil_fixture()
def test_immutable_name_property(self):
"""Test that name property is read only."""
@@ -683,11 +682,9 @@ class TestAirfoilDeepCopy(unittest.TestCase):
def setUp(self):
"""Set up test fixtures for deepcopy tests."""
self.naca0012_airfoil = geometry_fixtures.make_naca0012_airfoil_fixture()
- self.naca2412_airfoil = geometry_fixtures.make_naca2412_airfoil_fixture()
self.custom_outline_airfoil = (
geometry_fixtures.make_custom_outline_airfoil_fixture()
)
- self.resampled_airfoil = geometry_fixtures.make_resampled_airfoil_fixture()
self.non_resampled_airfoil = (
geometry_fixtures.make_non_resampled_airfoil_fixture()
)
@@ -953,7 +950,6 @@ class TestAirfoilGetResampledMcl(unittest.TestCase):
def setUp(self):
"""Set up test fixtures for get_resampled_mcl tests."""
self.naca0012_airfoil = geometry_fixtures.make_naca0012_airfoil_fixture()
- self.naca2412_airfoil = geometry_fixtures.make_naca2412_airfoil_fixture()
def test_get_resampled_mcl_returns_correct_shape(self):
"""Test that get_resampled_mcl returns correct shape."""
@@ -1044,7 +1040,6 @@ class TestAirfoilEdgeCases(unittest.TestCase):
def setUp(self):
"""Set up test fixtures for edge case tests."""
- self.naca0012_airfoil = geometry_fixtures.make_naca0012_airfoil_fixture()
def test_minimum_n_points_per_side(self):
"""Test Airfoil with minimum valid n_points_per_side (3)."""
diff --git a/tests/unit/test_airplane.py b/tests/unit/test_airplane.py
index 286bfc64d..76455ec4e 100644
--- a/tests/unit/test_airplane.py
+++ b/tests/unit/test_airplane.py
@@ -27,7 +27,6 @@ def setUp(self):
# Create additional test fixtures
self.test_wing_type_1 = geometry_fixtures.make_type_1_wing_fixture()
- self.test_wing_type_4 = geometry_fixtures.make_type_4_wing_fixture()
def test_wings_parameter_validation(self):
"""Test that wings parameter validation works correctly."""
@@ -573,7 +572,6 @@ class TestAirplaneImmutability(unittest.TestCase):
def setUp(self):
"""Set up test fixtures for immutability tests."""
self.basic_airplane = geometry_fixtures.make_basic_airplane_fixture()
- self.first_airplane = geometry_fixtures.make_first_airplane_fixture()
def test_immutable_wings_property(self):
"""Test that wings property is read only."""
@@ -883,7 +881,6 @@ def setUp(self):
"""Set up test fixtures for get_plottable_data tests."""
self.basic_airplane = geometry_fixtures.make_basic_airplane_fixture()
self.multi_wing_airplane = geometry_fixtures.make_multi_wing_airplane_fixture()
- self.first_airplane = geometry_fixtures.make_first_airplane_fixture()
def test_get_plottable_data_returns_list_when_show_is_false(self):
"""Test that get_plottable_data returns a list when show is False."""
@@ -980,7 +977,6 @@ class TestAirplaneDraw(unittest.TestCase):
def setUp(self):
"""Set up test fixtures for draw tests."""
self.basic_airplane = geometry_fixtures.make_basic_airplane_fixture()
- self.first_airplane = geometry_fixtures.make_first_airplane_fixture()
def test_draw_runs_without_error_in_testing_mode(self):
"""Test that draw runs without error in testing mode."""
diff --git a/tests/unit/test_airplane_movement.py b/tests/unit/test_airplane_movement.py
index 0c12c4a2a..54c355d02 100644
--- a/tests/unit/test_airplane_movement.py
+++ b/tests/unit/test_airplane_movement.py
@@ -1,12 +1,7 @@
"""This module contains classes to test AirplaneMovements."""
-import copy
import unittest
-import numpy as np
-import numpy.testing as npt
-from scipy import signal
-
import pterasoftware as ps
from tests.unit.fixtures import (
airplane_movement_fixtures,
@@ -18,1271 +13,57 @@
class TestAirplaneMovement(unittest.TestCase):
"""This is a class with functions to test AirplaneMovements."""
- @classmethod
- def setUpClass(cls):
- """Set up test fixtures once for all AirplaneMovement tests."""
- # Spacing test fixtures.
- cls.sine_spacing_Cg_airplane_movement = (
- airplane_movement_fixtures.make_sine_spacing_Cg_airplane_movement_fixture()
- )
- cls.uniform_spacing_Cg_airplane_movement = (
- airplane_movement_fixtures.make_uniform_spacing_Cg_airplane_movement_fixture()
- )
- cls.mixed_spacing_Cg_airplane_movement = (
- airplane_movement_fixtures.make_mixed_spacing_Cg_airplane_movement_fixture()
- )
-
- # Additional test fixtures.
- cls.static_airplane_movement = (
- airplane_movement_fixtures.make_static_airplane_movement_fixture()
- )
- cls.basic_airplane_movement = (
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- )
- cls.Cg_airplane_movement = (
- airplane_movement_fixtures.make_Cg_airplane_movement_fixture()
- )
- cls.phase_offset_Cg_airplane_movement = (
- airplane_movement_fixtures.make_phase_offset_Cg_airplane_movement_fixture()
- )
- cls.multiple_periods_airplane_movement = (
- airplane_movement_fixtures.make_multiple_periods_airplane_movement_fixture()
- )
- cls.custom_spacing_Cg_airplane_movement = (
- airplane_movement_fixtures.make_custom_spacing_Cg_airplane_movement_fixture()
- )
- cls.mixed_custom_and_standard_spacing_airplane_movement = (
- airplane_movement_fixtures.make_mixed_custom_and_standard_spacing_airplane_movement_fixture()
- )
-
- def test_spacing_sine_for_Cg_GP1_CgP1(self):
- """Test that sine spacing actually produces sinusoidal motion for Cg_GP1_CgP1."""
- num_steps = 10
- delta_time = 0.01
- airplanes = self.sine_spacing_Cg_airplane_movement.generate_airplanes(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract positions (in Earth axes, relative to the simulation starting
- # point) from generated Airplanes.
- x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
-
- # Calculate expected sine wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 0.1 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated positions match the expected sine wave.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_spacing_uniform_for_Cg_GP1_CgP1(self):
- """Test that uniform spacing actually produces triangular wave motion for
- Cg_GP1_CgP1."""
- num_steps = 10
- delta_time = 0.01
- airplanes = self.uniform_spacing_Cg_airplane_movement.generate_airplanes(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract positions (in Earth axes, relative to the simulation starting
- # point) from generated Airplanes.
- x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
-
- # Calculate expected triangular wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 0.1 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
-
- # Assert that the generated positions match the expected triangular wave.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_spacing_mixed_for_Cg_GP1_CgP1(self):
- """Test that mixed spacing types work correctly for Cg_GP1_CgP1."""
- num_steps = 10
- delta_time = 0.01
- airplanes = self.mixed_spacing_Cg_airplane_movement.generate_airplanes(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract positions (in Earth axes, relative to the simulation starting
- # point) from generated Airplanes.
- x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
- y_positions = np.array([airplane.Cg_GP1_CgP1[1] for airplane in airplanes])
- z_positions = np.array([airplane.Cg_GP1_CgP1[2] for airplane in airplanes])
-
- # Calculate expected values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 0.1 * np.sin(2 * np.pi * times / 1.0)
- expected_y = 0.08 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
- expected_z = 0.06 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated positions match the expected values.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
-
- def test_base_airplane_validation(self):
- """Test that base_airplane parameter validation works correctly."""
- # Test non-Airplane raises error.
- with self.assertRaises(TypeError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane="not an airplane",
- wing_movements=[
- wing_movement_fixtures.make_static_wing_movement_fixture()
- ],
- )
-
- # Test None raises error.
- with self.assertRaises(TypeError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=None,
- wing_movements=[
- wing_movement_fixtures.make_static_wing_movement_fixture()
- ],
- )
-
- # Test valid Airplane works.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane, wing_movements=wing_movements
- )
- self.assertEqual(airplane_movement.base_airplane, base_airplane)
-
- def test_ampCg_GP1_CgP1_validation(self):
- """Test ampCg_GP1_CgP1 parameter validation."""
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Test valid values.
- valid_amps = [
- (0.0, 0.0, 0.0),
- (1.0, 2.0, 3.0),
- [0.5, 1.5, 2.5],
- np.array([0.1, 0.2, 0.3]),
- ]
- for amp in valid_amps:
- with self.subTest(amp=amp):
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=amp,
- )
- npt.assert_array_equal(airplane_movement.ampCg_GP1_CgP1, amp)
-
- # Test negative values raise error.
- with self.assertRaises(ValueError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(-1.0, 0.0, 0.0),
- )
-
- # Test invalid types raise error.
- # noinspection PyTypeChecker
- with self.assertRaises((TypeError, ValueError)):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1="invalid",
- )
-
- def test_periodCg_GP1_CgP1_validation(self):
- """Test periodCg_GP1_CgP1 parameter validation."""
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Test valid values.
- valid_periods = [(0.0, 0.0, 0.0), (1.0, 2.0, 3.0), [0.5, 1.5, 2.5]]
- for period in valid_periods:
- with self.subTest(period=period):
- # Need matching amps for non-zero periods.
- amp = tuple(1.0 if p > 0 else 0.0 for p in period)
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=amp,
- periodCg_GP1_CgP1=period,
- )
- npt.assert_array_equal(airplane_movement.periodCg_GP1_CgP1, period)
-
- # Test negative values raise error.
- with self.assertRaises(ValueError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(1.0, 1.0, 1.0),
- periodCg_GP1_CgP1=(-1.0, 1.0, 1.0),
- )
-
- def test_spacingCg_GP1_CgP1_validation(self):
- """Test spacingCg_GP1_CgP1 parameter validation."""
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Test valid string values.
- valid_spacings = [
- ("sine", "sine", "sine"),
- ("uniform", "uniform", "uniform"),
- ("sine", "uniform", "sine"),
- ]
- for spacing in valid_spacings:
- with self.subTest(spacing=spacing):
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- spacingCg_GP1_CgP1=spacing,
- )
- self.assertEqual(airplane_movement.spacingCg_GP1_CgP1, spacing)
-
- # Test invalid string raises error.
- with self.assertRaises(ValueError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- spacingCg_GP1_CgP1=("invalid", "sine", "sine"),
- )
-
- def test_phaseCg_GP1_CgP1_validation(self):
- """Test phaseCg_GP1_CgP1 parameter validation."""
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Test valid phase values within range (-180.0, 180.0].
- valid_phases = [
- (0.0, 0.0, 0.0),
- (90.0, 180.0, -90.0),
- (179.9, 0.0, -179.9),
- ]
- for phase in valid_phases:
- with self.subTest(phase=phase):
- # Need non-zero amps for non-zero phases.
- amp = tuple(1.0 if p != 0 else 0.0 for p in phase)
- period = tuple(1.0 if p != 0 else 0.0 for p in phase)
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=amp,
- periodCg_GP1_CgP1=period,
- phaseCg_GP1_CgP1=phase,
- )
- npt.assert_array_equal(airplane_movement.phaseCg_GP1_CgP1, phase)
-
- # Test phase > 180.0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(1.0, 1.0, 1.0),
- periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
- phaseCg_GP1_CgP1=(180.1, 0.0, 0.0),
- )
-
- # Test phase <= -180.0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(1.0, 1.0, 1.0),
- periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
- phaseCg_GP1_CgP1=(-180.0, 0.0, 0.0),
+ def test_is_subclass_of_core(self):
+ """Test that AirplaneMovement is a subclass of CoreAirplaneMovement."""
+ self.assertTrue(
+ issubclass(
+ ps.movements.airplane_movement.AirplaneMovement,
+ ps._core.CoreAirplaneMovement,
)
-
- def test_amp_period_relationship_Cg(self):
- """Test that if ampCg_GP1_CgP1 element is 0, corresponding period must be 0."""
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Test amp=0 with period=0 works.
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.0, 1.0, 0.0),
- periodCg_GP1_CgP1=(0.0, 1.0, 0.0),
)
- self.assertIsNotNone(airplane_movement)
- # Test amp=0 with period!=0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.0, 1.0, 0.0),
- periodCg_GP1_CgP1=(1.0, 1.0, 0.0),
- )
-
- def test_amp_phase_relationship_Cg(self):
- """Test that if ampCg_GP1_CgP1 element is 0, corresponding phase must be 0."""
+ def test_instantiation_returns_correct_type(self):
+ """Test that AirplaneMovement instantiation returns an AirplaneMovement."""
base_airplane = geometry_fixtures.make_first_airplane_fixture()
wing_movements = [wing_movement_fixtures.make_static_wing_movement_fixture()]
-
- # Test amp=0 with phase=0 works.
airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
base_airplane=base_airplane,
wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.0, 1.0, 0.0),
- periodCg_GP1_CgP1=(0.0, 1.0, 0.0),
- phaseCg_GP1_CgP1=(0.0, -90.0, 0.0),
- )
- self.assertIsNotNone(airplane_movement)
-
- # Test amp=0 with phase!=0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.0, 1.0, 0.0),
- periodCg_GP1_CgP1=(0.0, 1.0, 0.0),
- phaseCg_GP1_CgP1=(45.0, -90.0, 0.0),
- )
-
- def test_max_period_static_movement(self):
- """Test that max_period returns 0.0 for static movement."""
- airplane_movement = self.static_airplane_movement
- self.assertEqual(airplane_movement.max_period, 0.0)
-
- def test_max_period_Cg(self):
- """Test that max_period returns correct period."""
- airplane_movement = self.Cg_airplane_movement
- # periodCg_GP1_CgP1 is (1.5, 1.5, 1.5), so max should be 1.5.
- self.assertEqual(airplane_movement.max_period, 1.5)
-
- def test_generate_airplanes_parameter_validation(self):
- """Test that generate_airplanes validates num_steps and delta_time."""
- airplane_movement = self.basic_airplane_movement
-
- # Test invalid num_steps.
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- airplane_movement.generate_airplanes(num_steps=0, delta_time=0.01)
-
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- airplane_movement.generate_airplanes(num_steps=-1, delta_time=0.01)
-
- with self.assertRaises(TypeError):
- airplane_movement.generate_airplanes(num_steps="invalid", delta_time=0.01)
-
- # Test invalid delta_time.
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- airplane_movement.generate_airplanes(num_steps=10, delta_time=0.0)
-
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- airplane_movement.generate_airplanes(num_steps=10, delta_time=-0.01)
-
- with self.assertRaises(TypeError):
- airplane_movement.generate_airplanes(num_steps=10, delta_time="invalid")
-
- def test_generate_airplanes_returns_correct_length(self):
- """Test that generate_airplanes returns list of correct length."""
- airplane_movement = self.basic_airplane_movement
-
- test_num_steps = [1, 5, 10, 50, 100]
- for num_steps in test_num_steps:
- with self.subTest(num_steps=num_steps):
- airplanes = airplane_movement.generate_airplanes(
- num_steps=num_steps, delta_time=0.01
- )
- self.assertEqual(len(airplanes), num_steps)
-
- def test_generate_airplanes_returns_correct_types(self):
- """Test that generate_airplanes returns Airplanes."""
- airplane_movement = self.basic_airplane_movement
- airplanes = airplane_movement.generate_airplanes(num_steps=10, delta_time=0.01)
-
- # Verify all elements are Airplanes.
- for airplane in airplanes:
- self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
-
- def test_generate_airplanes_preserves_non_changing_attributes(self):
- """Test that generate_airplanes preserves non-changing attributes."""
- airplane_movement = self.basic_airplane_movement
- base_airplane = airplane_movement.base_airplane
-
- airplanes = airplane_movement.generate_airplanes(num_steps=10, delta_time=0.01)
-
- # Check that non-changing attributes are preserved. Note: s_ref, c_ref,
- # and b_ref are NOT included because they are calculated from the Wings,
- # which change due to WingMovement or WingCrossSectionMovement.
- for airplane in airplanes:
- self.assertEqual(airplane.name, base_airplane.name)
- self.assertEqual(airplane.weight, base_airplane.weight)
-
- def test_generate_airplanes_static_movement(self):
- """Test that static movement produces constant positions and angles."""
- airplane_movement = self.static_airplane_movement
- base_airplane = airplane_movement.base_airplane
-
- airplanes = airplane_movement.generate_airplanes(num_steps=50, delta_time=0.01)
-
- # All Airplanes should have same Cg_GP1_CgP1.
- for airplane in airplanes:
- npt.assert_array_equal(airplane.Cg_GP1_CgP1, base_airplane.Cg_GP1_CgP1)
-
- def test_phase_offset_Cg(self):
- """Test that phase shifts initial position correctly for Cg_GP1_CgP1."""
- airplane_movement = self.phase_offset_Cg_airplane_movement
- airplanes = airplane_movement.generate_airplanes(num_steps=100, delta_time=0.01)
-
- # Extract positions (in Earth axes, relative to the simulation starting
- # point).
- x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
- y_positions = np.array([airplane.Cg_GP1_CgP1[1] for airplane in airplanes])
- z_positions = np.array([airplane.Cg_GP1_CgP1[2] for airplane in airplanes])
-
- # Verify that phase offset causes non-zero initial values.
- # With phase offsets, the first values should not all be at the base position.
- self.assertFalse(np.allclose(x_positions[0], 0.0, atol=1e-10))
- self.assertFalse(np.allclose(y_positions[0], 0.0, atol=1e-10))
- self.assertFalse(np.allclose(z_positions[0], 0.0, atol=1e-10))
-
- def test_single_dimension_movement_Cg(self):
- """Test that only one dimension of Cg_GP1_CgP1 moves."""
- airplane_movement = self.sine_spacing_Cg_airplane_movement
- airplanes = airplane_movement.generate_airplanes(num_steps=50, delta_time=0.01)
-
- # Extract positions (in Earth axes, relative to the simulation starting
- # point).
- x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
- y_positions = np.array([airplane.Cg_GP1_CgP1[1] for airplane in airplanes])
- z_positions = np.array([airplane.Cg_GP1_CgP1[2] for airplane in airplanes])
-
- # Only x should vary, y and z should be constant.
- self.assertFalse(np.allclose(x_positions, x_positions[0]))
- npt.assert_array_equal(y_positions, y_positions[0])
- npt.assert_array_equal(z_positions, z_positions[0])
-
- def test_custom_spacing_function_Cg(self):
- """Test that custom spacing function works for Cg_GP1_CgP1."""
- airplane_movement = self.custom_spacing_Cg_airplane_movement
- airplanes = airplane_movement.generate_airplanes(num_steps=100, delta_time=0.01)
-
- # Extract x-positions (in Earth axes, relative to the simulation starting
- # point).
- x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
-
- # Verify that values vary (not constant).
- self.assertFalse(np.allclose(x_positions, x_positions[0]))
-
- # Verify that values are within expected range.
- # For custom_harmonic with amp=0.08, values should be in [-0.08, 0.08].
- self.assertTrue(np.all(x_positions >= -0.09))
- self.assertTrue(np.all(x_positions <= 0.09))
-
- def test_custom_spacing_function_mixed_with_standard(self):
- """Test that custom and standard spacing functions can be mixed."""
- airplane_movement = self.mixed_custom_and_standard_spacing_airplane_movement
- airplanes = airplane_movement.generate_airplanes(num_steps=100, delta_time=0.01)
-
- # Verify that Airplanes are generated successfully.
- self.assertEqual(len(airplanes), 100)
- for airplane in airplanes:
- self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
-
-
-class TestAirplaneMovementVariableGeometryOptimization(unittest.TestCase):
- """This is a class with functions to test variable geometry optimization."""
-
- @classmethod
- def setUpClass(cls):
- """Set up test fixtures once for all variable geometry optimization tests."""
- cls.static_airplane_movement = (
- airplane_movement_fixtures.make_static_airplane_movement_fixture()
- )
- cls.periodic_geometry_airplane_movement = (
- airplane_movement_fixtures.make_periodic_geometry_airplane_movement_fixture()
- )
- cls.basic_airplane_movement = (
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- )
-
- def test_lcm_static_method(self):
- """Test the _lcm static method."""
- # Test basic LCM calculation.
- self.assertAlmostEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm(2.0, 3.0), 6.0
- )
- self.assertAlmostEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm(4.0, 6.0), 12.0
- )
-
- # Test with zero values.
- self.assertEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm(0.0, 5.0), 0.0
- )
- self.assertEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm(5.0, 0.0), 0.0
- )
- self.assertEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm(0.0, 0.0), 0.0
- )
-
- # Test with same values.
- self.assertAlmostEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm(5.0, 5.0), 5.0
- )
-
- def test_lcm_multiple_static_method(self):
- """Test the _lcm_multiple static method."""
- # Test basic LCM calculation.
- self.assertAlmostEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm_multiple(
- [2.0, 3.0, 4.0]
- ),
- 12.0,
- )
-
- # Test with empty list.
- self.assertEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm_multiple([]), 0.0
- )
-
- # Test with all zeros.
- self.assertEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm_multiple(
- [0.0, 0.0, 0.0]
- ),
- 0.0,
- )
-
- # Test with single value.
- self.assertAlmostEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm_multiple([5.0]), 5.0
)
-
- # Test with mixed zeros and non zeros.
- self.assertAlmostEqual(
- ps.movements.airplane_movement.AirplaneMovement._lcm_multiple(
- [0.0, 2.0, 0.0, 3.0]
- ),
- 6.0,
- )
-
- def test_geometry_lcm_period_static(self):
- """Test _geometry_lcm_period returns 0.0 for static geometry."""
- result = self.static_airplane_movement._geometry_lcm_period()
- self.assertEqual(result, 0.0)
-
- def test_geometry_lcm_period_periodic(self):
- """Test _geometry_lcm_period returns correct value for periodic geometry."""
- # The periodic_geometry_airplane_movement has a 0.1s period.
- result = self.periodic_geometry_airplane_movement._geometry_lcm_period()
- self.assertAlmostEqual(result, 0.1, places=6)
-
- def test_geometry_matches_identical_wings(self):
- """Test _geometry_matches returns True for identical Wings."""
- # Generate Wings for step 0.
- wings = []
- for wing_movement in self.static_airplane_movement.wing_movements:
- step_0_wings = wing_movement.generate_wings(num_steps=1, delta_time=0.01)
- wings.append(step_0_wings[0])
-
- wings_array = np.array(wings)
-
- # Identical Wings should match.
- result = ps.movements.airplane_movement.AirplaneMovement._geometry_matches(
- wings_step_a=wings_array,
- wings_step_b=wings_array,
- tolerance=1e-9,
- )
- self.assertTrue(result)
-
- def test_geometry_matches_different_wings(self):
- """Test _geometry_matches returns False for different Wings."""
- # Generate Wings for two different time steps with movement.
- airplane_movement = self.basic_airplane_movement
- wings_step_0 = []
- wings_step_5 = []
-
- for wing_movement in airplane_movement.wing_movements:
- all_wings = wing_movement.generate_wings(num_steps=10, delta_time=0.01)
- wings_step_0.append(all_wings[0])
- wings_step_5.append(all_wings[5])
-
- wings_array_0 = np.array(wings_step_0)
- wings_array_5 = np.array(wings_step_5)
-
- # Different Wings should not match.
- result = ps.movements.airplane_movement.AirplaneMovement._geometry_matches(
- wings_step_a=wings_array_0,
- wings_step_b=wings_array_5,
- tolerance=1e-9,
- )
- self.assertFalse(result)
-
- def test_geometry_matches_different_length(self):
- """Test _geometry_matches returns False for different length Wing arrays."""
- # Generate Wings.
- wings = []
- for wing_movement in self.static_airplane_movement.wing_movements:
- step_0_wings = wing_movement.generate_wings(num_steps=1, delta_time=0.01)
- wings.append(step_0_wings[0])
-
- wings_array = np.array(wings)
- # Create a shorter array.
- wings_array_short = wings_array[:0]
-
- result = ps.movements.airplane_movement.AirplaneMovement._geometry_matches(
- wings_step_a=wings_array,
- wings_step_b=wings_array_short,
- tolerance=1e-9,
- )
- self.assertFalse(result)
-
- def test_variable_geometry_optimization_applies(self):
- """Test that variable geometry optimization applies for periodic motion."""
- airplane_movement = self.periodic_geometry_airplane_movement
-
- # Use delta_time = 0.01 and period = 0.1, so steps_per_period = 10.
- # With 30 steps, we get 3 periods, so optimization should apply.
- num_steps = 30
- delta_time = 0.01
-
- airplanes = airplane_movement.generate_airplanes(
- num_steps=num_steps, delta_time=delta_time
- )
-
- # Verify correct number of Airplanes.
- self.assertEqual(len(airplanes), num_steps)
-
- # Verify all are Airplane instances.
- for airplane in airplanes:
- self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
-
- def test_variable_geometry_periodicity(self):
- """Test that variable geometry produces periodic results."""
- airplane_movement = self.periodic_geometry_airplane_movement
-
- # Use delta_time = 0.01 and period = 0.1, so steps_per_period = 10.
- num_steps = 30
- delta_time = 0.01
- steps_per_period = 10
-
- airplanes = airplane_movement.generate_airplanes(
- num_steps=num_steps, delta_time=delta_time
- )
-
- # Check that geometry repeats at period boundaries.
- # Compare step 0 to step 10 to step 20.
- for period_num in range(1, 3):
- base_step = 0
- compare_step = period_num * steps_per_period
-
- base_airplane = airplanes[base_step]
- compare_airplane = airplanes[compare_step]
-
- # Wings should have matching geometry.
- for wing_id in range(len(base_airplane.wings)):
- base_wing = base_airplane.wings[wing_id]
- compare_wing = compare_airplane.wings[wing_id]
-
- # Check Wing position.
- npt.assert_allclose(
- base_wing.Ler_Gs_Cgs,
- compare_wing.Ler_Gs_Cgs,
- atol=1e-9,
- rtol=0.0,
- )
-
- # Check Wing angles.
- npt.assert_allclose(
- base_wing.angles_Gs_to_Wn_ixyz,
- compare_wing.angles_Gs_to_Wn_ixyz,
- atol=1e-9,
- rtol=0.0,
- )
-
- def test_variable_geometry_Cg_updates(self):
- """Test that Cg_GP1_CgP1 is updated correctly for deepcopied Airplanes."""
- # Create an AirplaneMovement with both geometry motion and CG motion.
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movements = [
- wing_movement_fixtures.make_periodic_geometry_wing_movement_fixture()
- ]
-
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- ampCg_GP1_CgP1=(0.05, 0.0, 0.0),
- periodCg_GP1_CgP1=(0.2, 0.0, 0.0),
- spacingCg_GP1_CgP1=("sine", "sine", "sine"),
- phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
-
- num_steps = 30
- delta_time = 0.01
-
- airplanes = airplane_movement.generate_airplanes(
- num_steps=num_steps, delta_time=delta_time
+ self.assertIsInstance(
+ airplane_movement,
+ ps.movements.airplane_movement.AirplaneMovement,
)
- # Verify that Cg_GP1_CgP1 varies across steps (not all the same).
- x_positions = [airplane.Cg_GP1_CgP1[0] for airplane in airplanes]
- self.assertFalse(all(x == x_positions[0] for x in x_positions))
+ def test_rejects_core_wing_movement_children(self):
+ """Test that AirplaneMovement rejects CoreWingMovement instances."""
+ from tests.unit.fixtures import core_wing_movement_fixtures
- def test_fallback_when_period_not_aligned(self):
- """Test that fallback to standard generation works when period not aligned."""
- # Create an AirplaneMovement with a wing movement that has period = 1.0.
base_airplane = geometry_fixtures.make_first_airplane_fixture()
wing_movements = [
- wing_movement_fixtures.make_sine_spacing_Ler_wing_movement_fixture()
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
]
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=wing_movements,
- )
-
- # Should still work (fallback to standard generation).
- num_steps = 30
- delta_time = 0.007 # Doesn't align with period = 1.0.
-
- airplanes = airplane_movement.generate_airplanes(
- num_steps=num_steps, delta_time=delta_time
- )
-
- self.assertEqual(len(airplanes), num_steps)
-
- def test_no_optimization_when_single_period(self):
- """Test that optimization doesn't apply when num_steps <= steps_per_period."""
- airplane_movement = self.periodic_geometry_airplane_movement
-
- # Period = 0.1, delta_time = 0.01, so steps_per_period = 10.
- # Use num_steps = 10 (exactly one period). No benefit from optimization.
- num_steps = 10
- delta_time = 0.01
-
- airplanes = airplane_movement.generate_airplanes(
- num_steps=num_steps, delta_time=delta_time
- )
-
- # Should still work correctly.
- self.assertEqual(len(airplanes), num_steps)
- for airplane in airplanes:
- self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
-
-
-class TestGeometryMatchesEdgeCases(unittest.TestCase):
- """Tests for _geometry_matches edge cases and panel comparison code."""
-
- @classmethod
- def setUpClass(cls):
- """Set up test fixtures once for all geometry matching tests."""
- cls.static_airplane_movement = (
- airplane_movement_fixtures.make_static_airplane_movement_fixture()
- )
- # Fixture with only angle movement (no position movement).
- cls.angles_only_airplane_movement = (
- airplane_movement_fixtures.make_angles_only_airplane_movement_fixture()
- )
-
- def test_geometry_matches_wing_angles_mismatch(self):
- """Test _geometry_matches returns False when Wing angles don't match."""
- # Use an AirplaneMovement with only angle movement (no position movement).
- # This ensures Wing positions match but angles differ at different steps.
- airplane_movement = self.angles_only_airplane_movement
-
- # Generate Wings for two different time steps.
- wings_step_0 = []
- wings_step_5 = []
-
- for wing_movement in airplane_movement.wing_movements:
- all_wings = wing_movement.generate_wings(num_steps=10, delta_time=0.01)
- wings_step_0.append(all_wings[0])
- wings_step_5.append(all_wings[5])
-
- wings_array_0 = np.array(wings_step_0)
- wings_array_5 = np.array(wings_step_5)
-
- # Verify positions are the same (angles-only movement doesn't change position).
- for wing_0, wing_5 in zip(wings_array_0, wings_array_5):
- npt.assert_allclose(
- wing_0.Ler_Gs_Cgs, wing_5.Ler_Gs_Cgs, atol=1e-9, rtol=0.0
- )
-
- # Verify angles are different.
- angles_differ = False
- for wing_0, wing_5 in zip(wings_array_0, wings_array_5):
- if not np.allclose(
- wing_0.angles_Gs_to_Wn_ixyz,
- wing_5.angles_Gs_to_Wn_ixyz,
- atol=1e-9,
- rtol=0.0,
- ):
- angles_differ = True
- break
- self.assertTrue(angles_differ, "Angles should differ between steps")
-
- # _geometry_matches should return False due to angle mismatch.
- result = ps.movements.airplane_movement.AirplaneMovement._geometry_matches(
- wings_step_a=wings_array_0,
- wings_step_b=wings_array_5,
- tolerance=1e-9,
- )
- self.assertFalse(result)
-
- def test_geometry_matches_panel_shape_mismatch(self):
- """Test _geometry_matches returns False when Panel shapes don't match."""
- # Create AirplaneMovements with different panel grid sizes.
- airplane_movement_2 = (
- airplane_movement_fixtures.make_2_chordwise_panels_airplane_movement_fixture()
- )
- airplane_movement_3 = (
- airplane_movement_fixtures.make_3_chordwise_panels_airplane_movement_fixture()
- )
-
- # Generate Airplanes (which have meshed Wings with panels).
- airplanes_2 = airplane_movement_2.generate_airplanes(
- num_steps=1, delta_time=0.01
- )
- airplanes_3 = airplane_movement_3.generate_airplanes(
- num_steps=1, delta_time=0.01
- )
-
- # Extract Wings from Airplanes.
- wings_2 = airplanes_2[0].wings
- wings_3 = airplanes_3[0].wings
-
- wings_array_2 = np.array(wings_2)
- wings_array_3 = np.array(wings_3)
-
- # Verify Wings have panels.
- self.assertIsNotNone(wings_2[0].panels)
- self.assertIsNotNone(wings_3[0].panels)
-
- # Verify panel shapes are different.
- self.assertNotEqual(wings_2[0].panels.shape, wings_3[0].panels.shape)
-
- # _geometry_matches should return False due to panel shape mismatch.
- result = ps.movements.airplane_movement.AirplaneMovement._geometry_matches(
- wings_step_a=wings_array_2,
- wings_step_b=wings_array_3,
- tolerance=1e-9,
- )
- self.assertFalse(result)
-
- def _get_meshed_wings(self):
- """Helper to get two copies of meshed Wings for panel corner tests."""
- # Use static airplane movement to generate Airplanes with meshed Wings.
- airplane_movement = self.static_airplane_movement
-
- # Generate 2 time steps to get independent copies.
- airplanes = airplane_movement.generate_airplanes(num_steps=2, delta_time=0.01)
-
- # Extract Wings from each Airplane.
- wings_a = list(airplanes[0].wings)
- wings_b = list(airplanes[1].wings)
-
- return wings_a, wings_b
-
-
-class TestVariableGeometryFallback(unittest.TestCase):
- """Tests for the variable geometry fallback code path."""
-
- def test_fallback_when_geometry_validation_fails(self):
- """Test that fallback to standard generation works when geometry validation
- fails.
-
- This test uses unittest.mock to force _geometry_matches to return False,
- triggering the fallback path at line 353.
- """
- from unittest.mock import patch
-
- # Create a periodic AirplaneMovement.
- airplane_movement = (
- airplane_movement_fixtures.make_periodic_geometry_airplane_movement_fixture()
- )
-
- # Use parameters that would normally trigger optimization.
- # Period = 0.1s, delta_time = 0.01s → steps_per_period = 10
- # num_steps = 30 > steps_per_period, so optimization would apply.
- num_steps = 30
- delta_time = 0.01
-
- # Mock _geometry_matches to return False, triggering fallback.
- with patch.object(
- ps.movements.airplane_movement.AirplaneMovement,
- "_geometry_matches",
- return_value=False,
- ):
- airplanes = airplane_movement.generate_airplanes(
- num_steps=num_steps, delta_time=delta_time
- )
-
- # Verify correct number of Airplanes generated via fallback path.
- self.assertEqual(len(airplanes), num_steps)
-
- # Verify all are valid Airplane instances.
- for airplane in airplanes:
- self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
-
-
-class TestAirplaneMovementWingMovementsValidation(unittest.TestCase):
- """Tests for wing_movements parameter validation in AirplaneMovement."""
-
- def test_wing_movements_must_be_list(self):
- """Test that wing_movements must be a list."""
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movement = wing_movement_fixtures.make_static_wing_movement_fixture()
-
- # Test tuple raises TypeError.
- with self.assertRaises(TypeError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=(wing_movement,),
- )
-
- # Test single WingMovement (not in list) raises TypeError.
with self.assertRaises(TypeError):
ps.movements.airplane_movement.AirplaneMovement(
base_airplane=base_airplane,
- wing_movements=wing_movement,
- )
-
- def test_wing_movements_length_must_match_airplane_wings(self):
- """Test that wing_movements length must match base_airplane.wings length."""
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
- wing_movement = wing_movement_fixtures.make_static_wing_movement_fixture()
-
- # base_airplane has 1 Wing, so 2 WingMovements should raise ValueError.
- with self.assertRaises(ValueError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=[wing_movement, wing_movement],
- )
-
- # Empty list should raise ValueError.
- with self.assertRaises(ValueError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=[],
- )
-
- def test_wing_movements_elements_must_be_wing_movements(self):
- """Test that every element in wing_movements must be a WingMovement."""
- base_airplane = geometry_fixtures.make_first_airplane_fixture()
-
- # Test string raises TypeError.
- with self.assertRaises(TypeError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=["not a wing movement"],
- )
-
- # Test None raises TypeError.
- with self.assertRaises(TypeError):
- ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=[None],
+ wing_movements=wing_movements,
)
-
-class TestAirplaneMovementImmutability(unittest.TestCase):
- """Tests for AirplaneMovement attribute immutability."""
-
- def setUp(self):
- """Set up test fixtures for immutability tests."""
- self.airplane_movement = (
+ def test_generate_airplanes_returns_airplanes(self):
+ """Test that generate_airplanes returns Airplanes when called through the
+ public class.
+ """
+ airplane_movement = (
airplane_movement_fixtures.make_basic_airplane_movement_fixture()
)
-
- def test_immutable_base_airplane_property(self):
- """Test that base_airplane property is read only."""
- new_airplane = geometry_fixtures.make_first_airplane_fixture()
- with self.assertRaises(AttributeError):
- self.airplane_movement.base_airplane = new_airplane
-
- def test_immutable_wing_movements_property(self):
- """Test that wing_movements property is read only."""
- new_wing_movements = [
- wing_movement_fixtures.make_static_wing_movement_fixture()
- ]
- with self.assertRaises(AttributeError):
- self.airplane_movement.wing_movements = new_wing_movements
-
- def test_wing_movements_returns_tuple(self):
- """Test that wing_movements property returns a tuple (not a list)."""
- wing_movements = self.airplane_movement.wing_movements
- self.assertIsInstance(wing_movements, tuple)
-
- def test_immutable_ampCg_GP1_CgP1_property(self):
- """Test that ampCg_GP1_CgP1 property is read only."""
- with self.assertRaises(AttributeError):
- self.airplane_movement.ampCg_GP1_CgP1 = np.array([1.0, 2.0, 3.0])
-
- def test_immutable_ampCg_GP1_CgP1_array_read_only(self):
- """Test that ampCg_GP1_CgP1 array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.airplane_movement.ampCg_GP1_CgP1[0] = 999.0
-
- def test_immutable_periodCg_GP1_CgP1_property(self):
- """Test that periodCg_GP1_CgP1 property is read only."""
- with self.assertRaises(AttributeError):
- self.airplane_movement.periodCg_GP1_CgP1 = np.array([1.0, 2.0, 3.0])
-
- def test_immutable_periodCg_GP1_CgP1_array_read_only(self):
- """Test that periodCg_GP1_CgP1 array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.airplane_movement.periodCg_GP1_CgP1[0] = 999.0
-
- def test_immutable_spacingCg_GP1_CgP1_property(self):
- """Test that spacingCg_GP1_CgP1 property is read only."""
- with self.assertRaises(AttributeError):
- self.airplane_movement.spacingCg_GP1_CgP1 = (
- "uniform",
- "uniform",
- "uniform",
+ airplanes = airplane_movement.generate_airplanes(num_steps=5, delta_time=0.01)
+ self.assertEqual(len(airplanes), 5)
+ for airplane in airplanes:
+ self.assertIsInstance(
+ airplane,
+ ps.geometry.airplane.Airplane,
)
- def test_spacingCg_GP1_CgP1_returns_tuple(self):
- """Test that spacingCg_GP1_CgP1 property returns a tuple."""
- spacing = self.airplane_movement.spacingCg_GP1_CgP1
- self.assertIsInstance(spacing, tuple)
-
- def test_immutable_phaseCg_GP1_CgP1_property(self):
- """Test that phaseCg_GP1_CgP1 property is read only."""
- with self.assertRaises(AttributeError):
- self.airplane_movement.phaseCg_GP1_CgP1 = np.array([45.0, 45.0, 45.0])
-
- def test_immutable_phaseCg_GP1_CgP1_array_read_only(self):
- """Test that phaseCg_GP1_CgP1 array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.airplane_movement.phaseCg_GP1_CgP1[0] = 999.0
-
-
-class TestAirplaneMovementCaching(unittest.TestCase):
- """Tests for AirplaneMovement caching behavior."""
-
- def setUp(self):
- """Set up test fixtures for caching tests."""
- self.airplane_movement = (
- airplane_movement_fixtures.make_multiple_periods_airplane_movement_fixture()
- )
-
- def test_all_periods_caching_returns_same_object(self):
- """Test that repeated access to all_periods returns the same cached object."""
- all_periods_1 = self.airplane_movement.all_periods
- all_periods_2 = self.airplane_movement.all_periods
- self.assertIs(all_periods_1, all_periods_2)
-
- def test_max_period_caching_returns_same_value(self):
- """Test that repeated access to max_period returns the same cached value."""
- max_period_1 = self.airplane_movement.max_period
- max_period_2 = self.airplane_movement.max_period
- # Since floats are immutable, we check equality rather than identity.
- self.assertEqual(max_period_1, max_period_2)
-
-
-class TestAirplaneMovementAllPeriods(unittest.TestCase):
- """Tests for AirplaneMovement.all_periods property."""
-
- @classmethod
- def setUpClass(cls):
- """Set up test fixtures once for all all_periods tests."""
- cls.static_airplane_movement = (
- airplane_movement_fixtures.make_static_airplane_movement_fixture()
- )
- cls.Cg_airplane_movement = (
- airplane_movement_fixtures.make_Cg_airplane_movement_fixture()
- )
- cls.multiple_periods_airplane_movement = (
- airplane_movement_fixtures.make_multiple_periods_airplane_movement_fixture()
- )
-
- def test_all_periods_static_movement(self):
- """Test that all_periods returns empty tuple for static movement."""
- airplane_movement = self.static_airplane_movement
- self.assertEqual(airplane_movement.all_periods, ())
-
- def test_all_periods_Cg_only_movement(self):
- """Test that all_periods includes Cg periods."""
- airplane_movement = self.Cg_airplane_movement
- # periodCg_GP1_CgP1 is (1.5, 1.5, 1.5), all non zero.
- # WingMovement is static, so no geometry periods.
- # Should return tuple with three 1.5 values.
- self.assertEqual(airplane_movement.all_periods, (1.5, 1.5, 1.5))
-
- def test_all_periods_returns_tuple(self):
- """Test that all_periods returns a tuple."""
- airplane_movement = self.multiple_periods_airplane_movement
- self.assertIsInstance(airplane_movement.all_periods, tuple)
-
- def test_all_periods_includes_wing_movement_periods(self):
- """Test that all_periods includes periods from WingMovements."""
- airplane_movement = self.multiple_periods_airplane_movement
- all_periods = airplane_movement.all_periods
-
- # Should include periods from both Cg and WingMovements.
- # Cg periods: (1.0, 2.0, 3.0).
- # WingMovements contribute additional periods.
- self.assertIn(1.0, all_periods)
- self.assertIn(2.0, all_periods)
- self.assertIn(3.0, all_periods)
-
-
-class TestAirplaneMovementDeepcopy(unittest.TestCase):
- """Tests for AirplaneMovement.__deepcopy__ method."""
-
- def setUp(self):
- """Set up test fixtures for deepcopy tests."""
- self.airplane_movement = (
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- )
-
- def test_deepcopy_returns_new_instance(self):
- """Test that deepcopy returns a new AirplaneMovement instance."""
- original = self.airplane_movement
- copied = copy.deepcopy(original)
-
- self.assertIsInstance(copied, ps.movements.airplane_movement.AirplaneMovement)
- self.assertIsNot(original, copied)
-
- def test_deepcopy_preserves_attribute_values(self):
- """Test that deepcopy preserves all attribute values."""
- original = self.airplane_movement
- copied = copy.deepcopy(original)
-
- # Check numpy array attributes.
- npt.assert_array_equal(copied.ampCg_GP1_CgP1, original.ampCg_GP1_CgP1)
- npt.assert_array_equal(copied.periodCg_GP1_CgP1, original.periodCg_GP1_CgP1)
- npt.assert_array_equal(copied.phaseCg_GP1_CgP1, original.phaseCg_GP1_CgP1)
-
- # Check tuple attributes.
- self.assertEqual(copied.spacingCg_GP1_CgP1, original.spacingCg_GP1_CgP1)
-
- # Check scalar derived properties.
- self.assertEqual(copied.max_period, original.max_period)
-
- def test_deepcopy_numpy_arrays_are_independent(self):
- """Test that deepcopied numpy arrays are independent objects."""
- original = self.airplane_movement
- copied = copy.deepcopy(original)
-
- # Verify arrays are different objects.
- self.assertIsNot(copied.ampCg_GP1_CgP1, original.ampCg_GP1_CgP1)
- self.assertIsNot(copied.periodCg_GP1_CgP1, original.periodCg_GP1_CgP1)
- self.assertIsNot(copied.phaseCg_GP1_CgP1, original.phaseCg_GP1_CgP1)
-
- def test_deepcopy_numpy_arrays_cannot_be_modified_in_place(self):
- """Test that deepcopied numpy arrays raise ValueError on in place modification."""
- original = self.airplane_movement
- copied = copy.deepcopy(original)
-
- # Verify that attempting to modify copied arrays raises ValueError.
- with self.assertRaises(ValueError):
- copied.ampCg_GP1_CgP1[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.periodCg_GP1_CgP1[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.phaseCg_GP1_CgP1[0] = 999.0
-
- def test_deepcopy_base_airplane_is_independent(self):
- """Test that deepcopied base_airplane is an independent object."""
- original = self.airplane_movement
- copied = copy.deepcopy(original)
-
- # Verify base_airplane is a different object.
- self.assertIsNot(copied.base_airplane, original.base_airplane)
-
- # Verify attributes are equal.
- self.assertEqual(copied.base_airplane.name, original.base_airplane.name)
- self.assertEqual(copied.base_airplane.weight, original.base_airplane.weight)
- npt.assert_array_equal(
- copied.base_airplane.Cg_GP1_CgP1, original.base_airplane.Cg_GP1_CgP1
- )
-
- def test_deepcopy_wing_movements_are_independent(self):
- """Test that deepcopied wing_movements are independent objects."""
- original = self.airplane_movement
- copied = copy.deepcopy(original)
-
- # Verify wing_movements tuple is a different object.
- self.assertIsNot(copied.wing_movements, original.wing_movements)
-
- # Verify each WingMovement is a different object.
- for original_wm, copied_wm in zip(
- original.wing_movements, copied.wing_movements
- ):
- self.assertIsNot(copied_wm, original_wm)
-
- def test_deepcopy_resets_caches_to_none(self):
- """Test that deepcopy resets cached derived properties to None."""
- original = self.airplane_movement
-
- # Access cached properties to populate caches.
- _ = original.all_periods
- _ = original.max_period
-
- # Verify original caches are populated.
- self.assertIsNotNone(original._all_periods)
- self.assertIsNotNone(original._max_period)
-
- # Deepcopy the object.
- copied = copy.deepcopy(original)
-
- # Verify copied caches are reset to None.
- self.assertIsNone(copied._all_periods)
- self.assertIsNone(copied._max_period)
-
- def test_deepcopy_cached_properties_can_be_recomputed(self):
- """Test that cached properties work correctly after deepcopy."""
- original = self.airplane_movement
-
- # Get original cached values.
- original_all_periods = original.all_periods
- original_max_period = original.max_period
-
- # Deepcopy the object.
- copied = copy.deepcopy(original)
-
- # Verify cached properties can be computed and match original.
- self.assertEqual(copied.all_periods, original_all_periods)
- self.assertEqual(copied.max_period, original_max_period)
-
- def test_deepcopy_generate_airplanes_produces_same_results(self):
- """Test that generate_airplanes produces same results after deepcopy."""
- original = self.airplane_movement
- copied = copy.deepcopy(original)
-
- num_steps = 10
- delta_time = 0.01
-
- original_airplanes = original.generate_airplanes(
- num_steps=num_steps, delta_time=delta_time
- )
- copied_airplanes = copied.generate_airplanes(
- num_steps=num_steps, delta_time=delta_time
- )
-
- # Verify same number of Airplanes.
- self.assertEqual(len(copied_airplanes), len(original_airplanes))
-
- # Verify each Airplane has matching Cg_GP1_CgP1.
- for original_ap, copied_ap in zip(original_airplanes, copied_airplanes):
- npt.assert_array_equal(copied_ap.Cg_GP1_CgP1, original_ap.Cg_GP1_CgP1)
- self.assertEqual(copied_ap.name, original_ap.name)
- self.assertEqual(copied_ap.weight, original_ap.weight)
-
- def test_deepcopy_handles_memo_correctly(self):
- """Test that deepcopy handles the memo dict correctly for circular references."""
- original = self.airplane_movement
- memo = {}
-
- # First deepcopy.
- copied1 = copy.deepcopy(original, memo)
-
- # Verify original is in memo.
- self.assertIn(id(original), memo)
- self.assertIs(memo[id(original)], copied1)
-
- # Second deepcopy with same memo should return same object.
- copied2 = copy.deepcopy(original, memo)
- self.assertIs(copied1, copied2)
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py
new file mode 100644
index 000000000..b3e681100
--- /dev/null
+++ b/tests/unit/test_core.py
@@ -0,0 +1,116 @@
+"""This module contains classes to test the core classes in _core.py."""
+
+import unittest
+
+import pterasoftware as ps
+
+
+class TestLcmFunctions(unittest.TestCase):
+ """Tests for _lcm and _lcm_multiple functions in _core.py."""
+
+ def test_lcm_of_two_positive_numbers(self):
+ """Test _lcm returns correct LCM for two positive numbers."""
+ result = ps._core.lcm(2.0, 3.0)
+ self.assertEqual(result, 6.0)
+
+ def test_lcm_of_same_numbers(self):
+ """Test _lcm returns the number when both inputs are the same."""
+ result = ps._core.lcm(4.0, 4.0)
+ self.assertEqual(result, 4.0)
+
+ def test_lcm_with_first_zero(self):
+ """Test _lcm returns 0.0 when first input is zero."""
+ result = ps._core.lcm(0.0, 5.0)
+ self.assertEqual(result, 0.0)
+
+ def test_lcm_with_second_zero(self):
+ """Test _lcm returns 0.0 when second input is zero."""
+ result = ps._core.lcm(5.0, 0.0)
+ self.assertEqual(result, 0.0)
+
+ def test_lcm_with_both_zero(self):
+ """Test _lcm returns 0.0 when both inputs are zero."""
+ result = ps._core.lcm(0.0, 0.0)
+ self.assertEqual(result, 0.0)
+
+ def test_lcm_of_one_and_any_number(self):
+ """Test _lcm of 1.0 and any number returns that number."""
+ result = ps._core.lcm(1.0, 7.0)
+ self.assertEqual(result, 7.0)
+
+ result = ps._core.lcm(7.0, 1.0)
+ self.assertEqual(result, 7.0)
+
+ def test_lcm_of_multiples(self):
+ """Test _lcm of a number and its multiple returns the larger number."""
+ result = ps._core.lcm(3.0, 9.0)
+ self.assertEqual(result, 9.0)
+
+ result = ps._core.lcm(9.0, 3.0)
+ self.assertEqual(result, 9.0)
+
+ def test_lcm_multiple_empty_list(self):
+ """Test _lcm_multiple returns 0.0 for empty list."""
+ result = ps._core.lcm_multiple([])
+ self.assertEqual(result, 0.0)
+
+ def test_lcm_multiple_all_zeros(self):
+ """Test _lcm_multiple returns 0.0 when all periods are zero."""
+ result = ps._core.lcm_multiple([0.0, 0.0, 0.0])
+ self.assertEqual(result, 0.0)
+
+ def test_lcm_multiple_single_nonzero(self):
+ """Test _lcm_multiple returns the value for a single non zero period."""
+ result = ps._core.lcm_multiple([5.0])
+ self.assertEqual(result, 5.0)
+
+ def test_lcm_multiple_single_zero(self):
+ """Test _lcm_multiple returns 0.0 for a single zero period."""
+ result = ps._core.lcm_multiple([0.0])
+ self.assertEqual(result, 0.0)
+
+ def test_lcm_multiple_mixed_with_zeros(self):
+ """Test _lcm_multiple correctly ignores zeros in the list."""
+ result = ps._core.lcm_multiple([0.0, 2.0, 0.0, 3.0, 0.0])
+ self.assertEqual(result, 6.0)
+
+ def test_lcm_multiple_three_periods(self):
+ """Test _lcm_multiple returns correct LCM for three periods."""
+ result = ps._core.lcm_multiple([2.0, 3.0, 4.0])
+ self.assertEqual(result, 12.0)
+
+ def test_lcm_multiple_many_same_periods(self):
+ """Test _lcm_multiple returns the period when all are the same."""
+ result = ps._core.lcm_multiple([5.0, 5.0, 5.0, 5.0])
+ self.assertEqual(result, 5.0)
+
+ def test_lcm_multiple_coprime_periods(self):
+ """Test _lcm_multiple of coprime numbers returns their product."""
+ # 2, 3, and 5 are coprime, so LCM = 2 * 3 * 5 = 30.
+ result = ps._core.lcm_multiple([2.0, 3.0, 5.0])
+ self.assertEqual(result, 30.0)
+
+ def test_lcm_non_integer_periods(self):
+ """Test _lcm returns correct LCM for non integer periods."""
+ # LCM(1.5, 2.5) = 7.5 (both divide 7.5 evenly: 7.5/1.5=5, 7.5/2.5=3).
+ result = ps._core.lcm(1.5, 2.5)
+ self.assertAlmostEqual(result, 7.5, places=6)
+
+ def test_lcm_multiple_non_integer_periods(self):
+ """Test _lcm_multiple returns correct LCM for non integer periods."""
+ # LCM(1.5, 2.0, 2.5) = 30.0.
+ # 30.0/1.5=20, 30.0/2.0=15, 30.0/2.5=12.
+ result = ps._core.lcm_multiple([1.5, 2.0, 2.5])
+ self.assertAlmostEqual(result, 30.0, places=6)
+
+ def test_lcm_small_periods(self):
+ """Test _lcm handles small periods correctly without precision issues."""
+ # LCM(0.001, 0.002) = 0.002.
+ result = ps._core.lcm(0.001, 0.002)
+ self.assertAlmostEqual(result, 0.002, places=9)
+
+ def test_lcm_multiple_small_periods(self):
+ """Test _lcm_multiple handles small periods correctly."""
+ # LCM(0.01, 0.02, 0.03) = 0.06.
+ result = ps._core.lcm_multiple([0.01, 0.02, 0.03])
+ self.assertAlmostEqual(result, 0.06, places=9)
diff --git a/tests/unit/test_core_airplane_movement.py b/tests/unit/test_core_airplane_movement.py
new file mode 100644
index 000000000..66b224b72
--- /dev/null
+++ b/tests/unit/test_core_airplane_movement.py
@@ -0,0 +1,1241 @@
+"""This module contains classes to test CoreAirplaneMovements."""
+
+import copy
+import unittest
+
+import numpy as np
+import numpy.testing as npt
+from scipy import signal
+
+import pterasoftware as ps
+from tests.unit.fixtures import (
+ core_airplane_movement_fixtures,
+ core_wing_movement_fixtures,
+ geometry_fixtures,
+)
+
+
+class TestCoreAirplaneMovement(unittest.TestCase):
+ """This is a class with functions to test CoreAirplaneMovements."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all CoreAirplaneMovement tests."""
+ # Spacing test fixtures.
+ cls.sine_spacing_Cg_airplane_movement = (
+ core_airplane_movement_fixtures.make_sine_spacing_Cg_core_airplane_movement_fixture()
+ )
+ cls.uniform_spacing_Cg_airplane_movement = (
+ core_airplane_movement_fixtures.make_uniform_spacing_Cg_core_airplane_movement_fixture()
+ )
+ cls.mixed_spacing_Cg_airplane_movement = (
+ core_airplane_movement_fixtures.make_mixed_spacing_Cg_core_airplane_movement_fixture()
+ )
+
+ # Additional test fixtures.
+ cls.static_airplane_movement = (
+ core_airplane_movement_fixtures.make_static_core_airplane_movement_fixture()
+ )
+ cls.basic_airplane_movement = (
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
+ )
+ cls.Cg_airplane_movement = (
+ core_airplane_movement_fixtures.make_Cg_core_airplane_movement_fixture()
+ )
+ cls.phase_offset_Cg_airplane_movement = (
+ core_airplane_movement_fixtures.make_phase_offset_Cg_core_airplane_movement_fixture()
+ )
+ cls.custom_spacing_Cg_airplane_movement = (
+ core_airplane_movement_fixtures.make_custom_spacing_Cg_core_airplane_movement_fixture()
+ )
+ cls.mixed_custom_and_standard_spacing_airplane_movement = (
+ core_airplane_movement_fixtures.make_mixed_custom_and_standard_spacing_core_airplane_movement_fixture()
+ )
+
+ def test_spacing_sine_for_Cg_GP1_CgP1(self):
+ """Test that sine spacing actually produces sinusoidal motion for Cg_GP1_CgP1."""
+ num_steps = 10
+ delta_time = 0.01
+ airplanes = self.sine_spacing_Cg_airplane_movement.generate_airplanes(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract positions (in Earth axes, relative to the simulation starting
+ # point) from generated Airplanes.
+ x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
+
+ # Calculate expected sine wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 0.1 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated positions match the expected sine wave.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_uniform_for_Cg_GP1_CgP1(self):
+ """Test that uniform spacing actually produces triangular wave motion for
+ Cg_GP1_CgP1."""
+ num_steps = 10
+ delta_time = 0.01
+ airplanes = self.uniform_spacing_Cg_airplane_movement.generate_airplanes(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract positions (in Earth axes, relative to the simulation starting
+ # point) from generated Airplanes.
+ x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
+
+ # Calculate expected triangular wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 0.1 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
+
+ # Assert that the generated positions match the expected triangular wave.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_mixed_for_Cg_GP1_CgP1(self):
+ """Test that mixed spacing types work correctly for Cg_GP1_CgP1."""
+ num_steps = 10
+ delta_time = 0.01
+ airplanes = self.mixed_spacing_Cg_airplane_movement.generate_airplanes(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract positions (in Earth axes, relative to the simulation starting
+ # point) from generated Airplanes.
+ x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
+ y_positions = np.array([airplane.Cg_GP1_CgP1[1] for airplane in airplanes])
+ z_positions = np.array([airplane.Cg_GP1_CgP1[2] for airplane in airplanes])
+
+ # Calculate expected values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 0.1 * np.sin(2 * np.pi * times / 1.0)
+ expected_y = 0.08 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
+ expected_z = 0.06 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated positions match the expected values.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
+
+ def test_base_airplane_validation(self):
+ """Test that base_airplane parameter validation works correctly."""
+ # Test non-Airplane raises error.
+ with self.assertRaises(TypeError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane="not an airplane",
+ wing_movements=[
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ],
+ )
+
+ # Test None raises error.
+ with self.assertRaises(TypeError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=None,
+ wing_movements=[
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ],
+ )
+
+ # Test valid Airplane works.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane, wing_movements=wing_movements
+ )
+ self.assertEqual(airplane_movement.base_airplane, base_airplane)
+
+ def test_ampCg_GP1_CgP1_validation(self):
+ """Test ampCg_GP1_CgP1 parameter validation."""
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Test valid values.
+ valid_amps = [
+ (0.0, 0.0, 0.0),
+ (1.0, 2.0, 3.0),
+ [0.5, 1.5, 2.5],
+ np.array([0.1, 0.2, 0.3]),
+ ]
+ for amp in valid_amps:
+ with self.subTest(amp=amp):
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=amp,
+ )
+ npt.assert_array_equal(airplane_movement.ampCg_GP1_CgP1, amp)
+
+ # Test negative values raise error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(-1.0, 0.0, 0.0),
+ )
+
+ # Test invalid types raise error.
+ # noinspection PyTypeChecker
+ with self.assertRaises((TypeError, ValueError)):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1="invalid",
+ )
+
+ def test_periodCg_GP1_CgP1_validation(self):
+ """Test periodCg_GP1_CgP1 parameter validation."""
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Test valid values.
+ valid_periods = [(0.0, 0.0, 0.0), (1.0, 2.0, 3.0), [0.5, 1.5, 2.5]]
+ for period in valid_periods:
+ with self.subTest(period=period):
+ # Need matching amps for non-zero periods.
+ amp = tuple(1.0 if p > 0 else 0.0 for p in period)
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=amp,
+ periodCg_GP1_CgP1=period,
+ )
+ npt.assert_array_equal(airplane_movement.periodCg_GP1_CgP1, period)
+
+ # Test negative values raise error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(1.0, 1.0, 1.0),
+ periodCg_GP1_CgP1=(-1.0, 1.0, 1.0),
+ )
+
+ def test_spacingCg_GP1_CgP1_validation(self):
+ """Test spacingCg_GP1_CgP1 parameter validation."""
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Test valid string values.
+ valid_spacings = [
+ ("sine", "sine", "sine"),
+ ("uniform", "uniform", "uniform"),
+ ("sine", "uniform", "sine"),
+ ]
+ for spacing in valid_spacings:
+ with self.subTest(spacing=spacing):
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ spacingCg_GP1_CgP1=spacing,
+ )
+ self.assertEqual(airplane_movement.spacingCg_GP1_CgP1, spacing)
+
+ # Test invalid string raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ spacingCg_GP1_CgP1=("invalid", "sine", "sine"),
+ )
+
+ def test_phaseCg_GP1_CgP1_validation(self):
+ """Test phaseCg_GP1_CgP1 parameter validation."""
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Test valid phase values within range (-180.0, 180.0].
+ valid_phases = [
+ (0.0, 0.0, 0.0),
+ (90.0, 180.0, -90.0),
+ (179.9, 0.0, -179.9),
+ ]
+ for phase in valid_phases:
+ with self.subTest(phase=phase):
+ # Need non-zero amps for non-zero phases.
+ amp = tuple(1.0 if p != 0 else 0.0 for p in phase)
+ period = tuple(1.0 if p != 0 else 0.0 for p in phase)
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=amp,
+ periodCg_GP1_CgP1=period,
+ phaseCg_GP1_CgP1=phase,
+ )
+ npt.assert_array_equal(airplane_movement.phaseCg_GP1_CgP1, phase)
+
+ # Test phase > 180.0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(1.0, 1.0, 1.0),
+ periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
+ phaseCg_GP1_CgP1=(180.1, 0.0, 0.0),
+ )
+
+ # Test phase <= -180.0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(1.0, 1.0, 1.0),
+ periodCg_GP1_CgP1=(1.0, 1.0, 1.0),
+ phaseCg_GP1_CgP1=(-180.0, 0.0, 0.0),
+ )
+
+ def test_amp_period_relationship_Cg(self):
+ """Test that if ampCg_GP1_CgP1 element is 0, corresponding period must be 0."""
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Test amp=0 with period=0 works.
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 1.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 1.0, 0.0),
+ )
+ self.assertIsNotNone(airplane_movement)
+
+ # Test amp=0 with period!=0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 1.0, 0.0),
+ periodCg_GP1_CgP1=(1.0, 1.0, 0.0),
+ )
+
+ def test_amp_phase_relationship_Cg(self):
+ """Test that if ampCg_GP1_CgP1 element is 0, corresponding phase must be 0."""
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+
+ # Test amp=0 with phase=0 works.
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 1.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 1.0, 0.0),
+ phaseCg_GP1_CgP1=(0.0, -90.0, 0.0),
+ )
+ self.assertIsNotNone(airplane_movement)
+
+ # Test amp=0 with phase!=0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.0, 1.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 1.0, 0.0),
+ phaseCg_GP1_CgP1=(45.0, -90.0, 0.0),
+ )
+
+ def test_max_period_static_movement(self):
+ """Test that max_period returns 0.0 for static movement."""
+ airplane_movement = self.static_airplane_movement
+ self.assertEqual(airplane_movement.max_period, 0.0)
+
+ def test_max_period_Cg(self):
+ """Test that max_period returns correct period."""
+ airplane_movement = self.Cg_airplane_movement
+ # periodCg_GP1_CgP1 is (1.5, 1.5, 1.5), so max should be 1.5.
+ self.assertEqual(airplane_movement.max_period, 1.5)
+
+ def test_generate_airplanes_parameter_validation(self):
+ """Test that generate_airplanes validates num_steps and delta_time."""
+ airplane_movement = self.basic_airplane_movement
+
+ # Test invalid num_steps.
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ airplane_movement.generate_airplanes(num_steps=0, delta_time=0.01)
+
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ airplane_movement.generate_airplanes(num_steps=-1, delta_time=0.01)
+
+ with self.assertRaises(TypeError):
+ airplane_movement.generate_airplanes(num_steps="invalid", delta_time=0.01)
+
+ # Test invalid delta_time.
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ airplane_movement.generate_airplanes(num_steps=10, delta_time=0.0)
+
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ airplane_movement.generate_airplanes(num_steps=10, delta_time=-0.01)
+
+ with self.assertRaises(TypeError):
+ airplane_movement.generate_airplanes(num_steps=10, delta_time="invalid")
+
+ def test_generate_airplanes_returns_correct_length(self):
+ """Test that generate_airplanes returns list of correct length."""
+ airplane_movement = self.basic_airplane_movement
+
+ test_num_steps = [1, 5, 10, 50, 100]
+ for num_steps in test_num_steps:
+ with self.subTest(num_steps=num_steps):
+ airplanes = airplane_movement.generate_airplanes(
+ num_steps=num_steps, delta_time=0.01
+ )
+ self.assertEqual(len(airplanes), num_steps)
+
+ def test_generate_airplanes_returns_correct_types(self):
+ """Test that generate_airplanes returns Airplanes."""
+ airplane_movement = self.basic_airplane_movement
+ airplanes = airplane_movement.generate_airplanes(num_steps=10, delta_time=0.01)
+
+ # Verify all elements are Airplanes.
+ for airplane in airplanes:
+ self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
+
+ def test_generate_airplanes_preserves_non_changing_attributes(self):
+ """Test that generate_airplanes preserves non-changing attributes."""
+ airplane_movement = self.basic_airplane_movement
+ base_airplane = airplane_movement.base_airplane
+
+ airplanes = airplane_movement.generate_airplanes(num_steps=10, delta_time=0.01)
+
+ # Check that non-changing attributes are preserved. Note: s_ref, c_ref,
+ # and b_ref are NOT included because they are calculated from the Wings,
+ # which change due to WingMovement or WingCrossSectionMovement.
+ for airplane in airplanes:
+ self.assertEqual(airplane.name, base_airplane.name)
+ self.assertEqual(airplane.weight, base_airplane.weight)
+
+ def test_generate_airplanes_static_movement(self):
+ """Test that static movement produces constant positions and angles."""
+ airplane_movement = self.static_airplane_movement
+ base_airplane = airplane_movement.base_airplane
+
+ airplanes = airplane_movement.generate_airplanes(num_steps=50, delta_time=0.01)
+
+ # All Airplanes should have same Cg_GP1_CgP1.
+ for airplane in airplanes:
+ npt.assert_array_equal(airplane.Cg_GP1_CgP1, base_airplane.Cg_GP1_CgP1)
+
+ def test_phase_offset_Cg(self):
+ """Test that phase shifts initial position correctly for Cg_GP1_CgP1."""
+ airplane_movement = self.phase_offset_Cg_airplane_movement
+ airplanes = airplane_movement.generate_airplanes(num_steps=100, delta_time=0.01)
+
+ # Extract positions (in Earth axes, relative to the simulation starting
+ # point).
+ x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
+ y_positions = np.array([airplane.Cg_GP1_CgP1[1] for airplane in airplanes])
+ z_positions = np.array([airplane.Cg_GP1_CgP1[2] for airplane in airplanes])
+
+ # Verify that phase offset causes non-zero initial values.
+ # With phase offsets, the first values should not all be at the base position.
+ self.assertFalse(np.allclose(x_positions[0], 0.0, atol=1e-10))
+ self.assertFalse(np.allclose(y_positions[0], 0.0, atol=1e-10))
+ self.assertFalse(np.allclose(z_positions[0], 0.0, atol=1e-10))
+
+ def test_single_dimension_movement_Cg(self):
+ """Test that only one dimension of Cg_GP1_CgP1 moves."""
+ airplane_movement = self.sine_spacing_Cg_airplane_movement
+ airplanes = airplane_movement.generate_airplanes(num_steps=50, delta_time=0.01)
+
+ # Extract positions (in Earth axes, relative to the simulation starting
+ # point).
+ x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
+ y_positions = np.array([airplane.Cg_GP1_CgP1[1] for airplane in airplanes])
+ z_positions = np.array([airplane.Cg_GP1_CgP1[2] for airplane in airplanes])
+
+ # Only x should vary, y and z should be constant.
+ self.assertFalse(np.allclose(x_positions, x_positions[0]))
+ npt.assert_array_equal(y_positions, y_positions[0])
+ npt.assert_array_equal(z_positions, z_positions[0])
+
+ def test_custom_spacing_function_Cg(self):
+ """Test that custom spacing function works for Cg_GP1_CgP1."""
+ airplane_movement = self.custom_spacing_Cg_airplane_movement
+ airplanes = airplane_movement.generate_airplanes(num_steps=100, delta_time=0.01)
+
+ # Extract x-positions (in Earth axes, relative to the simulation starting
+ # point).
+ x_positions = np.array([airplane.Cg_GP1_CgP1[0] for airplane in airplanes])
+
+ # Verify that values vary (not constant).
+ self.assertFalse(np.allclose(x_positions, x_positions[0]))
+
+ # Verify that values are within expected range.
+ # For custom_harmonic with amp=0.08, values should be in [-0.08, 0.08].
+ self.assertTrue(np.all(x_positions >= -0.09))
+ self.assertTrue(np.all(x_positions <= 0.09))
+
+ def test_custom_spacing_function_mixed_with_standard(self):
+ """Test that custom and standard spacing functions can be mixed."""
+ airplane_movement = self.mixed_custom_and_standard_spacing_airplane_movement
+ airplanes = airplane_movement.generate_airplanes(num_steps=100, delta_time=0.01)
+
+ # Verify that Airplanes are generated successfully.
+ self.assertEqual(len(airplanes), 100)
+ for airplane in airplanes:
+ self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
+
+
+class TestCoreAirplaneMovementVariableGeometryOptimization(unittest.TestCase):
+ """This is a class with functions to test variable geometry optimization."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all variable geometry optimization tests."""
+ cls.static_airplane_movement = (
+ core_airplane_movement_fixtures.make_static_core_airplane_movement_fixture()
+ )
+ cls.periodic_geometry_airplane_movement = (
+ core_airplane_movement_fixtures.make_periodic_geometry_core_airplane_movement_fixture()
+ )
+ cls.basic_airplane_movement = (
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
+ )
+
+ def test_geometry_lcm_period_static(self):
+ """Test _geometry_lcm_period returns 0.0 for static geometry."""
+ result = self.static_airplane_movement._geometry_lcm_period()
+ self.assertEqual(result, 0.0)
+
+ def test_geometry_lcm_period_periodic(self):
+ """Test _geometry_lcm_period returns correct value for periodic geometry."""
+ # The periodic_geometry_airplane_movement has a 0.1s period.
+ result = self.periodic_geometry_airplane_movement._geometry_lcm_period()
+ self.assertAlmostEqual(result, 0.1, places=6)
+
+ def test_geometry_matches_identical_wings(self):
+ """Test _geometry_matches returns True for identical Wings."""
+ # Generate Wings for step 0.
+ wings = []
+ for wing_movement in self.static_airplane_movement.wing_movements:
+ step_0_wings = wing_movement.generate_wings(num_steps=1, delta_time=0.01)
+ wings.append(step_0_wings[0])
+
+ wings_array = np.array(wings)
+
+ # Identical Wings should match.
+ result = ps._core.CoreAirplaneMovement._geometry_matches(
+ wings_step_a=wings_array,
+ wings_step_b=wings_array,
+ tolerance=1e-9,
+ )
+ self.assertTrue(result)
+
+ def test_geometry_matches_different_wings(self):
+ """Test _geometry_matches returns False for different Wings."""
+ # Generate Wings for two different time steps with movement.
+ airplane_movement = self.basic_airplane_movement
+ wings_step_0 = []
+ wings_step_5 = []
+
+ for wing_movement in airplane_movement.wing_movements:
+ all_wings = wing_movement.generate_wings(num_steps=10, delta_time=0.01)
+ wings_step_0.append(all_wings[0])
+ wings_step_5.append(all_wings[5])
+
+ wings_array_0 = np.array(wings_step_0)
+ wings_array_5 = np.array(wings_step_5)
+
+ # Different Wings should not match.
+ result = ps._core.CoreAirplaneMovement._geometry_matches(
+ wings_step_a=wings_array_0,
+ wings_step_b=wings_array_5,
+ tolerance=1e-9,
+ )
+ self.assertFalse(result)
+
+ def test_geometry_matches_different_length(self):
+ """Test _geometry_matches returns False for different length Wing arrays."""
+ # Generate Wings.
+ wings = []
+ for wing_movement in self.static_airplane_movement.wing_movements:
+ step_0_wings = wing_movement.generate_wings(num_steps=1, delta_time=0.01)
+ wings.append(step_0_wings[0])
+
+ wings_array = np.array(wings)
+ # Create a shorter array.
+ wings_array_short = wings_array[:0]
+
+ result = ps._core.CoreAirplaneMovement._geometry_matches(
+ wings_step_a=wings_array,
+ wings_step_b=wings_array_short,
+ tolerance=1e-9,
+ )
+ self.assertFalse(result)
+
+ def test_variable_geometry_optimization_applies(self):
+ """Test that variable geometry optimization applies for periodic motion."""
+ airplane_movement = self.periodic_geometry_airplane_movement
+
+ # Use delta_time = 0.01 and period = 0.1, so steps_per_period = 10.
+ # With 30 steps, we get 3 periods, so optimization should apply.
+ num_steps = 30
+ delta_time = 0.01
+
+ airplanes = airplane_movement.generate_airplanes(
+ num_steps=num_steps, delta_time=delta_time
+ )
+
+ # Verify correct number of Airplanes.
+ self.assertEqual(len(airplanes), num_steps)
+
+ # Verify all are Airplane instances.
+ for airplane in airplanes:
+ self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
+
+ def test_variable_geometry_periodicity(self):
+ """Test that variable geometry produces periodic results."""
+ airplane_movement = self.periodic_geometry_airplane_movement
+
+ # Use delta_time = 0.01 and period = 0.1, so steps_per_period = 10.
+ num_steps = 30
+ delta_time = 0.01
+ steps_per_period = 10
+
+ airplanes = airplane_movement.generate_airplanes(
+ num_steps=num_steps, delta_time=delta_time
+ )
+
+ # Check that geometry repeats at period boundaries.
+ # Compare step 0 to step 10 to step 20.
+ for period_num in range(1, 3):
+ base_step = 0
+ compare_step = period_num * steps_per_period
+
+ base_airplane = airplanes[base_step]
+ compare_airplane = airplanes[compare_step]
+
+ # Wings should have matching geometry.
+ for wing_id in range(len(base_airplane.wings)):
+ base_wing = base_airplane.wings[wing_id]
+ compare_wing = compare_airplane.wings[wing_id]
+
+ # Check Wing position.
+ npt.assert_allclose(
+ base_wing.Ler_Gs_Cgs,
+ compare_wing.Ler_Gs_Cgs,
+ atol=1e-9,
+ rtol=0.0,
+ )
+
+ # Check Wing angles.
+ npt.assert_allclose(
+ base_wing.angles_Gs_to_Wn_ixyz,
+ compare_wing.angles_Gs_to_Wn_ixyz,
+ atol=1e-9,
+ rtol=0.0,
+ )
+
+ def test_variable_geometry_Cg_updates(self):
+ """Test that Cg_GP1_CgP1 is updated correctly for deepcopied Airplanes."""
+ # Create an CoreAirplaneMovement with both geometry motion and CG motion.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_periodic_geometry_core_wing_movement_fixture()
+ ]
+
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ ampCg_GP1_CgP1=(0.05, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.2, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ num_steps = 30
+ delta_time = 0.01
+
+ airplanes = airplane_movement.generate_airplanes(
+ num_steps=num_steps, delta_time=delta_time
+ )
+
+ # Verify that Cg_GP1_CgP1 varies across steps (not all the same).
+ x_positions = [airplane.Cg_GP1_CgP1[0] for airplane in airplanes]
+ self.assertFalse(all(x == x_positions[0] for x in x_positions))
+
+ def test_fallback_when_period_not_aligned(self):
+ """Test that fallback to standard generation works when period not aligned."""
+ # Create an CoreAirplaneMovement with a wing movement that has period = 1.0.
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movements = [
+ core_wing_movement_fixtures.make_sine_spacing_Ler_core_wing_movement_fixture()
+ ]
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movements,
+ )
+
+ # Should still work (fallback to standard generation).
+ num_steps = 30
+ delta_time = 0.007 # Doesn't align with period = 1.0.
+
+ airplanes = airplane_movement.generate_airplanes(
+ num_steps=num_steps, delta_time=delta_time
+ )
+
+ self.assertEqual(len(airplanes), num_steps)
+
+ def test_no_optimization_when_single_period(self):
+ """Test that optimization doesn't apply when num_steps <= steps_per_period."""
+ airplane_movement = self.periodic_geometry_airplane_movement
+
+ # Period = 0.1, delta_time = 0.01, so steps_per_period = 10.
+ # Use num_steps = 10 (exactly one period). No benefit from optimization.
+ num_steps = 10
+ delta_time = 0.01
+
+ airplanes = airplane_movement.generate_airplanes(
+ num_steps=num_steps, delta_time=delta_time
+ )
+
+ # Should still work correctly.
+ self.assertEqual(len(airplanes), num_steps)
+ for airplane in airplanes:
+ self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
+
+
+class TestGeometryMatchesEdgeCases(unittest.TestCase):
+ """Tests for _geometry_matches edge cases and panel comparison code."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all geometry matching tests."""
+ cls.static_airplane_movement = (
+ core_airplane_movement_fixtures.make_static_core_airplane_movement_fixture()
+ )
+ # Fixture with only angle movement (no position movement).
+ cls.angles_only_airplane_movement = (
+ core_airplane_movement_fixtures.make_angles_only_core_airplane_movement_fixture()
+ )
+
+ def test_geometry_matches_wing_angles_mismatch(self):
+ """Test _geometry_matches returns False when Wing angles don't match."""
+ # Use an CoreAirplaneMovement with only angle movement (no position movement).
+ # This ensures Wing positions match but angles differ at different steps.
+ airplane_movement = self.angles_only_airplane_movement
+
+ # Generate Wings for two different time steps.
+ wings_step_0 = []
+ wings_step_5 = []
+
+ for wing_movement in airplane_movement.wing_movements:
+ all_wings = wing_movement.generate_wings(num_steps=10, delta_time=0.01)
+ wings_step_0.append(all_wings[0])
+ wings_step_5.append(all_wings[5])
+
+ wings_array_0 = np.array(wings_step_0)
+ wings_array_5 = np.array(wings_step_5)
+
+ # Verify positions are the same (angles-only movement doesn't change position).
+ for wing_0, wing_5 in zip(wings_array_0, wings_array_5):
+ npt.assert_allclose(
+ wing_0.Ler_Gs_Cgs, wing_5.Ler_Gs_Cgs, atol=1e-9, rtol=0.0
+ )
+
+ # Verify angles are different.
+ angles_differ = False
+ for wing_0, wing_5 in zip(wings_array_0, wings_array_5):
+ if not np.allclose(
+ wing_0.angles_Gs_to_Wn_ixyz,
+ wing_5.angles_Gs_to_Wn_ixyz,
+ atol=1e-9,
+ rtol=0.0,
+ ):
+ angles_differ = True
+ break
+ self.assertTrue(angles_differ, "Angles should differ between steps")
+
+ # _geometry_matches should return False due to angle mismatch.
+ result = ps._core.CoreAirplaneMovement._geometry_matches(
+ wings_step_a=wings_array_0,
+ wings_step_b=wings_array_5,
+ tolerance=1e-9,
+ )
+ self.assertFalse(result)
+
+ def test_geometry_matches_panel_shape_mismatch(self):
+ """Test _geometry_matches returns False when Panel shapes don't match."""
+ # Create CoreAirplaneMovements with different panel grid sizes.
+ airplane_movement_2 = (
+ core_airplane_movement_fixtures.make_2_chordwise_panels_core_airplane_movement_fixture()
+ )
+ airplane_movement_3 = (
+ core_airplane_movement_fixtures.make_3_chordwise_panels_core_airplane_movement_fixture()
+ )
+
+ # Generate Airplanes (which have meshed Wings with panels).
+ airplanes_2 = airplane_movement_2.generate_airplanes(
+ num_steps=1, delta_time=0.01
+ )
+ airplanes_3 = airplane_movement_3.generate_airplanes(
+ num_steps=1, delta_time=0.01
+ )
+
+ # Extract Wings from Airplanes.
+ wings_2 = airplanes_2[0].wings
+ wings_3 = airplanes_3[0].wings
+
+ wings_array_2 = np.array(wings_2)
+ wings_array_3 = np.array(wings_3)
+
+ # Verify Wings have panels.
+ self.assertIsNotNone(wings_2[0].panels)
+ self.assertIsNotNone(wings_3[0].panels)
+
+ # Verify panel shapes are different.
+ self.assertNotEqual(wings_2[0].panels.shape, wings_3[0].panels.shape)
+
+ # _geometry_matches should return False due to panel shape mismatch.
+ result = ps._core.CoreAirplaneMovement._geometry_matches(
+ wings_step_a=wings_array_2,
+ wings_step_b=wings_array_3,
+ tolerance=1e-9,
+ )
+ self.assertFalse(result)
+
+ def _get_meshed_wings(self):
+ """Helper to get two copies of meshed Wings for panel corner tests."""
+ # Use static airplane movement to generate Airplanes with meshed Wings.
+ airplane_movement = self.static_airplane_movement
+
+ # Generate 2 time steps to get independent copies.
+ airplanes = airplane_movement.generate_airplanes(num_steps=2, delta_time=0.01)
+
+ # Extract Wings from each Airplane.
+ wings_a = list(airplanes[0].wings)
+ wings_b = list(airplanes[1].wings)
+
+ return wings_a, wings_b
+
+
+class TestVariableGeometryFallback(unittest.TestCase):
+ """Tests for the variable geometry fallback code path."""
+
+ def test_fallback_when_geometry_validation_fails(self):
+ """Test that fallback to standard generation works when geometry validation
+ fails.
+
+ This test uses unittest.mock to force _geometry_matches to return False,
+ triggering the fallback path at line 353.
+ """
+ from unittest.mock import patch
+
+ # Create a periodic CoreAirplaneMovement.
+ airplane_movement = (
+ core_airplane_movement_fixtures.make_periodic_geometry_core_airplane_movement_fixture()
+ )
+
+ # Use parameters that would normally trigger optimization.
+ # Period = 0.1s, delta_time = 0.01s → steps_per_period = 10
+ # num_steps = 30 > steps_per_period, so optimization would apply.
+ num_steps = 30
+ delta_time = 0.01
+
+ # Mock _geometry_matches to return False, triggering fallback.
+ with patch.object(
+ ps._core.CoreAirplaneMovement,
+ "_geometry_matches",
+ return_value=False,
+ ):
+ airplanes = airplane_movement.generate_airplanes(
+ num_steps=num_steps, delta_time=delta_time
+ )
+
+ # Verify correct number of Airplanes generated via fallback path.
+ self.assertEqual(len(airplanes), num_steps)
+
+ # Verify all are valid Airplane instances.
+ for airplane in airplanes:
+ self.assertIsInstance(airplane, ps.geometry.airplane.Airplane)
+
+
+class TestCoreAirplaneMovementWingMovementsValidation(unittest.TestCase):
+ """Tests for wing_movements parameter validation in CoreAirplaneMovement."""
+
+ def test_wing_movements_must_be_list(self):
+ """Test that wing_movements must be a list."""
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movement = (
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ )
+
+ # Test tuple raises TypeError.
+ with self.assertRaises(TypeError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=(wing_movement,),
+ )
+
+ # Test single WingMovement (not in list) raises TypeError.
+ with self.assertRaises(TypeError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=wing_movement,
+ )
+
+ def test_wing_movements_length_must_match_airplane_wings(self):
+ """Test that wing_movements length must match base_airplane.wings length."""
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+ wing_movement = (
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ )
+
+ # base_airplane has 1 Wing, so 2 WingMovements should raise ValueError.
+ with self.assertRaises(ValueError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=[wing_movement, wing_movement],
+ )
+
+ # Empty list should raise ValueError.
+ with self.assertRaises(ValueError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=[],
+ )
+
+ def test_wing_movements_elements_must_be_wing_movements(self):
+ """Test that every element in wing_movements must be a WingMovement."""
+ base_airplane = geometry_fixtures.make_first_airplane_fixture()
+
+ # Test string raises TypeError.
+ with self.assertRaises(TypeError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=["not a wing movement"],
+ )
+
+ # Test None raises TypeError.
+ with self.assertRaises(TypeError):
+ ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=[None],
+ )
+
+
+class TestCoreAirplaneMovementImmutability(unittest.TestCase):
+ """Tests for CoreAirplaneMovement attribute immutability."""
+
+ def setUp(self):
+ """Set up test fixtures for immutability tests."""
+ self.core_airplane_movement = (
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
+ )
+
+ def test_immutable_base_airplane_property(self):
+ """Test that base_airplane property is read only."""
+ new_airplane = geometry_fixtures.make_first_airplane_fixture()
+ with self.assertRaises(AttributeError):
+ self.core_airplane_movement.base_airplane = new_airplane
+
+ def test_immutable_wing_movements_property(self):
+ """Test that wing_movements property is read only."""
+ new_wing_movements = [
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ ]
+ with self.assertRaises(AttributeError):
+ self.core_airplane_movement.wing_movements = new_wing_movements
+
+ def test_wing_movements_returns_tuple(self):
+ """Test that wing_movements property returns a tuple (not a list)."""
+ wing_movements = self.core_airplane_movement.wing_movements
+ self.assertIsInstance(wing_movements, tuple)
+
+ def test_immutable_ampCg_GP1_CgP1_property(self):
+ """Test that ampCg_GP1_CgP1 property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_airplane_movement.ampCg_GP1_CgP1 = np.array([1.0, 2.0, 3.0])
+
+ def test_immutable_ampCg_GP1_CgP1_array_read_only(self):
+ """Test that ampCg_GP1_CgP1 array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_airplane_movement.ampCg_GP1_CgP1[0] = 999.0
+
+ def test_immutable_periodCg_GP1_CgP1_property(self):
+ """Test that periodCg_GP1_CgP1 property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_airplane_movement.periodCg_GP1_CgP1 = np.array([1.0, 2.0, 3.0])
+
+ def test_immutable_periodCg_GP1_CgP1_array_read_only(self):
+ """Test that periodCg_GP1_CgP1 array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_airplane_movement.periodCg_GP1_CgP1[0] = 999.0
+
+ def test_immutable_spacingCg_GP1_CgP1_property(self):
+ """Test that spacingCg_GP1_CgP1 property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_airplane_movement.spacingCg_GP1_CgP1 = (
+ "uniform",
+ "uniform",
+ "uniform",
+ )
+
+ def test_spacingCg_GP1_CgP1_returns_tuple(self):
+ """Test that spacingCg_GP1_CgP1 property returns a tuple."""
+ spacing = self.core_airplane_movement.spacingCg_GP1_CgP1
+ self.assertIsInstance(spacing, tuple)
+
+ def test_immutable_phaseCg_GP1_CgP1_property(self):
+ """Test that phaseCg_GP1_CgP1 property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_airplane_movement.phaseCg_GP1_CgP1 = np.array([45.0, 45.0, 45.0])
+
+ def test_immutable_phaseCg_GP1_CgP1_array_read_only(self):
+ """Test that phaseCg_GP1_CgP1 array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_airplane_movement.phaseCg_GP1_CgP1[0] = 999.0
+
+
+class TestCoreAirplaneMovementCaching(unittest.TestCase):
+ """Tests for CoreAirplaneMovement caching behavior."""
+
+ def setUp(self):
+ """Set up test fixtures for caching tests."""
+ self.core_airplane_movement = (
+ core_airplane_movement_fixtures.make_multiple_periods_core_airplane_movement_fixture()
+ )
+
+ def test_all_periods_caching_returns_same_object(self):
+ """Test that repeated access to all_periods returns the same cached object."""
+ all_periods_1 = self.core_airplane_movement.all_periods
+ all_periods_2 = self.core_airplane_movement.all_periods
+ self.assertIs(all_periods_1, all_periods_2)
+
+ def test_max_period_caching_returns_same_value(self):
+ """Test that repeated access to max_period returns the same cached value."""
+ max_period_1 = self.core_airplane_movement.max_period
+ max_period_2 = self.core_airplane_movement.max_period
+ # Since floats are immutable, we check equality rather than identity.
+ self.assertEqual(max_period_1, max_period_2)
+
+
+class TestCoreAirplaneMovementAllPeriods(unittest.TestCase):
+ """Tests for CoreAirplaneMovement.all_periods property."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all all_periods tests."""
+ cls.static_airplane_movement = (
+ core_airplane_movement_fixtures.make_static_core_airplane_movement_fixture()
+ )
+ cls.Cg_airplane_movement = (
+ core_airplane_movement_fixtures.make_Cg_core_airplane_movement_fixture()
+ )
+ cls.multiple_periods_airplane_movement = (
+ core_airplane_movement_fixtures.make_multiple_periods_core_airplane_movement_fixture()
+ )
+
+ def test_all_periods_static_movement(self):
+ """Test that all_periods returns empty tuple for static movement."""
+ airplane_movement = self.static_airplane_movement
+ self.assertEqual(airplane_movement.all_periods, ())
+
+ def test_all_periods_Cg_only_movement(self):
+ """Test that all_periods includes Cg periods."""
+ airplane_movement = self.Cg_airplane_movement
+ # periodCg_GP1_CgP1 is (1.5, 1.5, 1.5), all non zero.
+ # WingMovement is static, so no geometry periods.
+ # Should return tuple with three 1.5 values.
+ self.assertEqual(airplane_movement.all_periods, (1.5, 1.5, 1.5))
+
+ def test_all_periods_returns_tuple(self):
+ """Test that all_periods returns a tuple."""
+ airplane_movement = self.multiple_periods_airplane_movement
+ self.assertIsInstance(airplane_movement.all_periods, tuple)
+
+ def test_all_periods_includes_wing_movement_periods(self):
+ """Test that all_periods includes periods from WingMovements."""
+ airplane_movement = self.multiple_periods_airplane_movement
+ all_periods = airplane_movement.all_periods
+
+ # Should include periods from both Cg and WingMovements.
+ # Cg periods: (1.0, 2.0, 3.0).
+ # WingMovements contribute additional periods.
+ self.assertIn(1.0, all_periods)
+ self.assertIn(2.0, all_periods)
+ self.assertIn(3.0, all_periods)
+
+
+class TestCoreAirplaneMovementDeepcopy(unittest.TestCase):
+ """Tests for CoreAirplaneMovement.__deepcopy__ method."""
+
+ def setUp(self):
+ """Set up test fixtures for deepcopy tests."""
+ self.core_airplane_movement = (
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
+ )
+
+ def test_deepcopy_returns_new_instance(self):
+ """Test that deepcopy returns a new CoreAirplaneMovement instance."""
+ original = self.core_airplane_movement
+ copied = copy.deepcopy(original)
+
+ self.assertIsInstance(copied, ps._core.CoreAirplaneMovement)
+ self.assertIsNot(original, copied)
+
+ def test_deepcopy_preserves_attribute_values(self):
+ """Test that deepcopy preserves all attribute values."""
+ original = self.core_airplane_movement
+ copied = copy.deepcopy(original)
+
+ # Check numpy array attributes.
+ npt.assert_array_equal(copied.ampCg_GP1_CgP1, original.ampCg_GP1_CgP1)
+ npt.assert_array_equal(copied.periodCg_GP1_CgP1, original.periodCg_GP1_CgP1)
+ npt.assert_array_equal(copied.phaseCg_GP1_CgP1, original.phaseCg_GP1_CgP1)
+
+ # Check tuple attributes.
+ self.assertEqual(copied.spacingCg_GP1_CgP1, original.spacingCg_GP1_CgP1)
+
+ # Check scalar derived properties.
+ self.assertEqual(copied.max_period, original.max_period)
+
+ def test_deepcopy_numpy_arrays_are_independent(self):
+ """Test that deepcopied numpy arrays are independent objects."""
+ original = self.core_airplane_movement
+ copied = copy.deepcopy(original)
+
+ # Verify arrays are different objects.
+ self.assertIsNot(copied.ampCg_GP1_CgP1, original.ampCg_GP1_CgP1)
+ self.assertIsNot(copied.periodCg_GP1_CgP1, original.periodCg_GP1_CgP1)
+ self.assertIsNot(copied.phaseCg_GP1_CgP1, original.phaseCg_GP1_CgP1)
+
+ def test_deepcopy_numpy_arrays_cannot_be_modified_in_place(self):
+ """Test that deepcopied numpy arrays raise ValueError on in place modification."""
+ original = self.core_airplane_movement
+ copied = copy.deepcopy(original)
+
+ # Verify that attempting to modify copied arrays raises ValueError.
+ with self.assertRaises(ValueError):
+ copied.ampCg_GP1_CgP1[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.periodCg_GP1_CgP1[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.phaseCg_GP1_CgP1[0] = 999.0
+
+ def test_deepcopy_base_airplane_is_independent(self):
+ """Test that deepcopied base_airplane is an independent object."""
+ original = self.core_airplane_movement
+ copied = copy.deepcopy(original)
+
+ # Verify base_airplane is a different object.
+ self.assertIsNot(copied.base_airplane, original.base_airplane)
+
+ # Verify attributes are equal.
+ self.assertEqual(copied.base_airplane.name, original.base_airplane.name)
+ self.assertEqual(copied.base_airplane.weight, original.base_airplane.weight)
+ npt.assert_array_equal(
+ copied.base_airplane.Cg_GP1_CgP1, original.base_airplane.Cg_GP1_CgP1
+ )
+
+ def test_deepcopy_wing_movements_are_independent(self):
+ """Test that deepcopied wing_movements are independent objects."""
+ original = self.core_airplane_movement
+ copied = copy.deepcopy(original)
+
+ # Verify wing_movements tuple is a different object.
+ self.assertIsNot(copied.wing_movements, original.wing_movements)
+
+ # Verify each WingMovement is a different object.
+ for original_wm, copied_wm in zip(
+ original.wing_movements, copied.wing_movements
+ ):
+ self.assertIsNot(copied_wm, original_wm)
+
+ def test_deepcopy_resets_caches_to_none(self):
+ """Test that deepcopy resets cached derived properties to None."""
+ original = self.core_airplane_movement
+
+ # Access cached properties to populate caches.
+ _ = original.all_periods
+ _ = original.max_period
+
+ # Verify original caches are populated.
+ self.assertIsNotNone(original._all_periods)
+ self.assertIsNotNone(original._max_period)
+
+ # Deepcopy the object.
+ copied = copy.deepcopy(original)
+
+ # Verify copied caches are reset to None.
+ self.assertIsNone(copied._all_periods)
+ self.assertIsNone(copied._max_period)
+
+ def test_deepcopy_cached_properties_can_be_recomputed(self):
+ """Test that cached properties work correctly after deepcopy."""
+ original = self.core_airplane_movement
+
+ # Get original cached values.
+ original_all_periods = original.all_periods
+ original_max_period = original.max_period
+
+ # Deepcopy the object.
+ copied = copy.deepcopy(original)
+
+ # Verify cached properties can be computed and match original.
+ self.assertEqual(copied.all_periods, original_all_periods)
+ self.assertEqual(copied.max_period, original_max_period)
+
+ def test_deepcopy_generate_airplanes_produces_same_results(self):
+ """Test that generate_airplanes produces same results after deepcopy."""
+ original = self.core_airplane_movement
+ copied = copy.deepcopy(original)
+
+ num_steps = 10
+ delta_time = 0.01
+
+ original_airplanes = original.generate_airplanes(
+ num_steps=num_steps, delta_time=delta_time
+ )
+ copied_airplanes = copied.generate_airplanes(
+ num_steps=num_steps, delta_time=delta_time
+ )
+
+ # Verify same number of Airplanes.
+ self.assertEqual(len(copied_airplanes), len(original_airplanes))
+
+ # Verify each Airplane has matching Cg_GP1_CgP1.
+ for original_ap, copied_ap in zip(original_airplanes, copied_airplanes):
+ npt.assert_array_equal(copied_ap.Cg_GP1_CgP1, original_ap.Cg_GP1_CgP1)
+ self.assertEqual(copied_ap.name, original_ap.name)
+ self.assertEqual(copied_ap.weight, original_ap.weight)
+
+ def test_deepcopy_handles_memo_correctly(self):
+ """Test that deepcopy handles the memo dict correctly for circular references."""
+ original = self.core_airplane_movement
+ memo = {}
+
+ # First deepcopy.
+ copied1 = copy.deepcopy(original, memo)
+
+ # Verify original is in memo.
+ self.assertIn(id(original), memo)
+ self.assertIs(memo[id(original)], copied1)
+
+ # Second deepcopy with same memo should return same object.
+ copied2 = copy.deepcopy(original, memo)
+ self.assertIs(copied1, copied2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_core_movement.py b/tests/unit/test_core_movement.py
new file mode 100644
index 000000000..c6fae6ebe
--- /dev/null
+++ b/tests/unit/test_core_movement.py
@@ -0,0 +1,522 @@
+"""This module contains classes to test CoreMovements."""
+
+import unittest
+
+import pterasoftware as ps
+from tests.unit.fixtures import (
+ core_airplane_movement_fixtures,
+ core_movement_fixtures,
+ core_operating_point_movement_fixtures,
+ geometry_fixtures,
+ operating_point_fixtures,
+)
+
+
+class TestCoreMovement(unittest.TestCase):
+ """This is a class with functions to test CoreMovements."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all CoreMovement tests."""
+ cls.static_core_movement = (
+ core_movement_fixtures.make_static_core_movement_fixture()
+ )
+ cls.basic_core_movement = (
+ core_movement_fixtures.make_basic_core_movement_fixture()
+ )
+
+ def test_static_property_for_static_core_movement(self):
+ """Test that static property returns True for static CoreMovement."""
+ self.assertTrue(self.static_core_movement.static)
+
+ def test_static_property_for_non_static_core_movement(self):
+ """Test that static property returns False for non static CoreMovement."""
+ self.assertFalse(self.basic_core_movement.static)
+
+ def test_max_period_for_static_core_movement(self):
+ """Test that max_period returns 0.0 for static CoreMovement."""
+ self.assertEqual(self.static_core_movement.max_period, 0.0)
+
+ def test_max_period_for_non_static_core_movement(self):
+ """Test that max_period returns correct value for non static CoreMovement."""
+ # The basic_core_movement has period of 2.0 for all motion.
+ self.assertEqual(self.basic_core_movement.max_period, 2.0)
+
+ def test_lcm_period_for_static_core_movement(self):
+ """Test that lcm_period returns 0.0 for static CoreMovement."""
+ self.assertEqual(self.static_core_movement.lcm_period, 0.0)
+
+ def test_lcm_period_for_single_period_core_movement(self):
+ """Test that lcm_period returns correct value when all periods are the same."""
+ # The basic_core_movement has period of 2.0 for all motion.
+ # LCM of identical periods should equal that period.
+ self.assertEqual(self.basic_core_movement.lcm_period, 2.0)
+
+ def test_lcm_period_with_multiple_wings_same_airplane(self):
+ """Test that lcm_period collects all periods, not just max from each
+ CoreAirplaneMovement.
+
+ This test creates a single Airplane with two Wings having different periods
+ (3.0 s and 4.0 s). The correct LCM is 12.0 s. If the implementation only uses
+ max_period from the CoreAirplaneMovement, lcm_period would incorrectly return
+ 4.0 s instead of 12.0 s.
+ """
+ # Create two Wings for the same Airplane.
+ base_wing_1 = geometry_fixtures.make_simple_tapered_wing_fixture()
+ base_wing_2 = geometry_fixtures.make_simple_tapered_wing_fixture()
+
+ base_airplane = ps.geometry.airplane.Airplane(
+ wings=[base_wing_1, base_wing_2],
+ name="Test Airplane",
+ Cg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Wing_1: tip CoreWingCrossSectionMovement has period 3.0 s.
+ wing_cross_section_movements_wing_1 = [
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_1.wing_cross_sections[0],
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ),
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_1.wing_cross_sections[1],
+ periodLp_Wcsp_Lpp=(3.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
+ ),
+ ]
+
+ wing_movement_1 = ps._core.CoreWingMovement(
+ base_wing=base_wing_1,
+ wing_cross_section_movements=wing_cross_section_movements_wing_1,
+ )
+
+ # Wing_2: tip CoreWingCrossSectionMovement has period 4.0 s.
+ wing_cross_section_movements_wing_2 = [
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_2.wing_cross_sections[0],
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ),
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_2.wing_cross_sections[1],
+ periodLp_Wcsp_Lpp=(4.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
+ ),
+ ]
+
+ wing_movement_2 = ps._core.CoreWingMovement(
+ base_wing=base_wing_2,
+ wing_cross_section_movements=wing_cross_section_movements_wing_2,
+ )
+
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=[wing_movement_1, wing_movement_2],
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ operating_point_movement = (
+ core_operating_point_movement_fixtures.make_static_core_operating_point_movement_fixture()
+ )
+
+ core_movement = ps._core.CoreMovement(
+ airplane_movements=[airplane_movement],
+ operating_point_movement=operating_point_movement,
+ delta_time=0.1,
+ num_steps=1,
+ )
+
+ # The max_period should be 4.0 (the max of 3.0 and 4.0).
+ self.assertEqual(core_movement.max_period, 4.0)
+
+ # The lcm_period should be LCM(3.0, 4.0) = 12.0, not 4.0. This test will fail
+ # if lcm_period only uses max_period from each CoreAirplaneMovement instead of
+ # collecting all individual periods.
+ self.assertEqual(core_movement.lcm_period, 12.0)
+
+ def test_lcm_period_with_multiple_cross_sections_same_wing(self):
+ """Test that lcm_period collects all periods from
+ CoreWingCrossSectionMovements.
+
+ This test creates a single Wing with three WingCrossSections having different
+ periods (root static, middle 3.0 s, tip 4.0 s). The correct LCM is 12.0 s. If
+ the implementation only uses max_period from each CoreWingMovement, lcm_period
+ would incorrectly return 4.0 s instead of 12.0 s.
+ """
+ # Create a Wing with three WingCrossSections.
+ test_airfoil = ps.geometry.airfoil.Airfoil(name="naca2412")
+
+ root_wing_cross_section = ps.geometry.wing_cross_section.WingCrossSection(
+ airfoil=test_airfoil,
+ num_spanwise_panels=4,
+ chord=2.0,
+ Lp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ middle_wing_cross_section = ps.geometry.wing_cross_section.WingCrossSection(
+ airfoil=test_airfoil,
+ num_spanwise_panels=4,
+ chord=1.5,
+ Lp_Wcsp_Lpp=(0.0, 1.5, 0.0),
+ angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ tip_wing_cross_section = ps.geometry.wing_cross_section.WingCrossSection(
+ airfoil=test_airfoil,
+ num_spanwise_panels=None,
+ chord=1.0,
+ Lp_Wcsp_Lpp=(0.0, 1.5, 0.0),
+ angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
+ )
+
+ base_wing = ps.geometry.wing.Wing(
+ wing_cross_sections=[
+ root_wing_cross_section,
+ middle_wing_cross_section,
+ tip_wing_cross_section,
+ ],
+ name="Test Wing",
+ )
+
+ base_airplane = ps.geometry.airplane.Airplane(
+ wings=[base_wing],
+ name="Test Airplane",
+ Cg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Root CoreWingCrossSectionMovement must be static.
+ # Middle has period 3.0 s, tip has period 4.0 s.
+ wing_cross_section_movements = [
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing.wing_cross_sections[0],
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ),
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing.wing_cross_sections[1],
+ periodLp_Wcsp_Lpp=(3.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
+ ),
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing.wing_cross_sections[2],
+ periodLp_Wcsp_Lpp=(4.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
+ ),
+ ]
+
+ wing_movement = ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wing_cross_section_movements,
+ )
+
+ airplane_movement = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane,
+ wing_movements=[wing_movement],
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ operating_point_movement = (
+ core_operating_point_movement_fixtures.make_static_core_operating_point_movement_fixture()
+ )
+
+ core_movement = ps._core.CoreMovement(
+ airplane_movements=[airplane_movement],
+ operating_point_movement=operating_point_movement,
+ delta_time=0.1,
+ num_steps=1,
+ )
+
+ # The max_period should be 4.0 (the max of 3.0 and 4.0).
+ self.assertEqual(core_movement.max_period, 4.0)
+
+ # The lcm_period should be LCM(3.0, 4.0) = 12.0, not 4.0. This test will fail
+ # if lcm_period only uses max_period from each CoreWingMovement instead of
+ # collecting all individual periods from CoreWingCrossSectionMovements.
+ self.assertEqual(core_movement.lcm_period, 12.0)
+
+ def test_lcm_period_with_multiple_airplanes(self):
+ """Test that lcm_period calculates LCM correctly with multiple periods."""
+ # Create CoreAirplaneMovements with different periods.
+
+ base_wing_1 = geometry_fixtures.make_simple_tapered_wing_fixture()
+ base_airplane_1 = ps.geometry.airplane.Airplane(
+ wings=[base_wing_1],
+ name="Test Airplane 1",
+ Cg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Make CoreWingCrossSectionMovements for the first Airplane's Wing's root and
+ # tip WingCrossSections. The root CoreWingCrossSectionMovement must be static.
+ # The tip CoreWingCrossSectionMovement will have a period of 2.0 s.
+ wing_cross_section_movements_1 = [
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_1.wing_cross_sections[0],
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ),
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_1.wing_cross_sections[1],
+ periodLp_Wcsp_Lpp=(2.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
+ ),
+ ]
+
+ wing_movement_1 = ps._core.CoreWingMovement(
+ base_wing=base_wing_1,
+ wing_cross_section_movements=wing_cross_section_movements_1,
+ )
+
+ airplane_movement_1 = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane_1,
+ wing_movements=[wing_movement_1],
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ base_wing_2 = geometry_fixtures.make_simple_tapered_wing_fixture()
+ base_airplane_2 = ps.geometry.airplane.Airplane(
+ wings=[base_wing_2],
+ name="Test Airplane 2",
+ Cg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ # Make CoreWingCrossSectionMovements for the second Airplane's Wing's root and
+ # tip WingCrossSections. The root CoreWingCrossSectionMovement must be static.
+ # The tip CoreWingCrossSectionMovement will have a period of 3.0 s.
+ wing_cross_section_movements_2 = [
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_2.wing_cross_sections[0],
+ periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ ),
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wing_2.wing_cross_sections[1],
+ periodLp_Wcsp_Lpp=(3.0, 0.0, 0.0),
+ ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
+ ),
+ ]
+
+ wing_movement_2 = ps._core.CoreWingMovement(
+ base_wing=base_wing_2,
+ wing_cross_section_movements=wing_cross_section_movements_2,
+ )
+
+ airplane_movement_2 = ps._core.CoreAirplaneMovement(
+ base_airplane=base_airplane_2,
+ wing_movements=[wing_movement_2],
+ ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ spacingCg_GP1_CgP1=("sine", "sine", "sine"),
+ phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
+ )
+
+ operating_point_movement = (
+ core_operating_point_movement_fixtures.make_static_core_operating_point_movement_fixture()
+ )
+
+ core_movement = ps._core.CoreMovement(
+ airplane_movements=[airplane_movement_1, airplane_movement_2],
+ operating_point_movement=operating_point_movement,
+ delta_time=0.1,
+ num_steps=1,
+ )
+
+ # The LCM of 2.0 and 3.0 should be 6.0.
+ self.assertEqual(core_movement.lcm_period, 6.0)
+
+ # The max_period should still be 3.0.
+ self.assertEqual(core_movement.max_period, 3.0)
+
+ def test_max_period_with_multiple_airplanes(self):
+ """Test that max_period returns maximum across all CoreAirplaneMovements."""
+ # Create a static and a basic CoreAirplaneMovement.
+ static_airplane_movement = (
+ core_airplane_movement_fixtures.make_static_core_airplane_movement_fixture()
+ )
+ basic_airplane_movement = (
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
+ )
+
+ operating_point_movement = (
+ core_operating_point_movement_fixtures.make_static_core_operating_point_movement_fixture()
+ )
+
+ core_movement = ps._core.CoreMovement(
+ airplane_movements=[static_airplane_movement, basic_airplane_movement],
+ operating_point_movement=operating_point_movement,
+ delta_time=0.01,
+ num_steps=1,
+ )
+
+ # The CoreMovement has one static (period 0.0) and one with period 2.0.
+ # Should return 2.0.
+ self.assertEqual(core_movement.max_period, 2.0)
+
+
+class TestCoreMovementImmutability(unittest.TestCase):
+ """Tests for CoreMovement attribute immutability."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all immutability tests."""
+ cls.basic_core_movement = (
+ core_movement_fixtures.make_basic_core_movement_fixture()
+ )
+
+ def test_immutable_airplane_movements_property(self):
+ """Test that airplane_movements property is read only."""
+ with self.assertRaises(AttributeError):
+ self.basic_core_movement.airplane_movements = []
+
+ def test_immutable_airplane_movements_tuple(self):
+ """Test that airplane_movements returns a tuple (immutable sequence)."""
+ airplane_movements = self.basic_core_movement.airplane_movements
+ self.assertIsInstance(airplane_movements, tuple)
+
+ def test_immutable_operating_point_movement_property(self):
+ """Test that operating_point_movement property is read only."""
+ operating_point_movement = (
+ core_operating_point_movement_fixtures.make_static_core_operating_point_movement_fixture()
+ )
+ with self.assertRaises(AttributeError):
+ self.basic_core_movement.operating_point_movement = operating_point_movement
+
+ def test_immutable_delta_time_property(self):
+ """Test that delta_time property is read only."""
+ with self.assertRaises(AttributeError):
+ self.basic_core_movement.delta_time = 0.05
+
+ def test_immutable_num_steps_property(self):
+ """Test that num_steps property is read only."""
+ with self.assertRaises(AttributeError):
+ self.basic_core_movement.num_steps = 100
+
+
+class TestCoreMovementWithOperatingPointMovementPeriod(unittest.TestCase):
+ """Tests for CoreMovement when CoreOperatingPointMovement has non zero period."""
+
+ def test_lcm_period_includes_operating_point_movement_period(self):
+ """Test that lcm_period includes the CoreOperatingPointMovement period."""
+ # Create a static CoreAirplaneMovement.
+ airplane_movements = [
+ core_airplane_movement_fixtures.make_static_core_airplane_movement_fixture()
+ ]
+
+ # Create a CoreOperatingPointMovement with a non zero period.
+ base_operating_point = (
+ operating_point_fixtures.make_basic_operating_point_fixture()
+ )
+ operating_point_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=1.0,
+ periodVCg__E=3.0,
+ )
+
+ # Create the CoreMovement with explicit num_steps to avoid auto calculation.
+ core_movement = ps._core.CoreMovement(
+ airplane_movements=airplane_movements,
+ operating_point_movement=operating_point_movement,
+ delta_time=0.1,
+ num_steps=1,
+ )
+
+ # The lcm_period should be 3.0 (from CoreOperatingPointMovement).
+ # Since the CoreAirplaneMovement is static (period 0.0), the only period is 3.0.
+ self.assertEqual(core_movement.lcm_period, 3.0)
+
+ def test_lcm_period_combines_airplane_and_operating_point_periods(self):
+ """Test that lcm_period combines periods from CoreAirplaneMovement and
+ CoreOperatingPointMovement."""
+ # Create a non static CoreAirplaneMovement with period 2.0.
+ airplane_movements = [
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
+ ]
+
+ # Create a CoreOperatingPointMovement with period 3.0.
+ base_operating_point = (
+ operating_point_fixtures.make_basic_operating_point_fixture()
+ )
+ operating_point_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=1.0,
+ periodVCg__E=3.0,
+ )
+
+ core_movement = ps._core.CoreMovement(
+ airplane_movements=airplane_movements,
+ operating_point_movement=operating_point_movement,
+ delta_time=0.1,
+ num_steps=1,
+ )
+
+ # The lcm_period should be LCM(2.0, 3.0) = 6.0.
+ self.assertEqual(core_movement.lcm_period, 6.0)
+
+ def test_min_period_includes_operating_point_movement_period(self):
+ """Test that min_period includes the CoreOperatingPointMovement period."""
+ # Create a non static CoreAirplaneMovement with period 2.0.
+ airplane_movements = [
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
+ ]
+
+ # Create a CoreOperatingPointMovement with a shorter period (1.5).
+ base_operating_point = (
+ operating_point_fixtures.make_basic_operating_point_fixture()
+ )
+ operating_point_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=1.0,
+ periodVCg__E=1.5,
+ )
+
+ core_movement = ps._core.CoreMovement(
+ airplane_movements=airplane_movements,
+ operating_point_movement=operating_point_movement,
+ delta_time=0.1,
+ num_steps=1,
+ )
+
+ # The min_period should be 1.5 (from CoreOperatingPointMovement, smaller than
+ # 2.0).
+ self.assertEqual(core_movement.min_period, 1.5)
+
+ def test_max_period_includes_operating_point_movement_period(self):
+ """Test that max_period includes the CoreOperatingPointMovement period."""
+ # Create a non static CoreAirplaneMovement with period 2.0.
+ airplane_movements = [
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
+ ]
+
+ # Create a CoreOperatingPointMovement with a longer period (5.0).
+ base_operating_point = (
+ operating_point_fixtures.make_basic_operating_point_fixture()
+ )
+ operating_point_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_operating_point,
+ ampVCg__E=1.0,
+ periodVCg__E=5.0,
+ )
+
+ core_movement = ps._core.CoreMovement(
+ airplane_movements=airplane_movements,
+ operating_point_movement=operating_point_movement,
+ delta_time=0.1,
+ num_steps=1,
+ )
+
+ # The max_period should be 5.0 (from CoreOperatingPointMovement, larger than
+ # 2.0).
+ self.assertEqual(core_movement.max_period, 5.0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_core_operating_point_movement.py b/tests/unit/test_core_operating_point_movement.py
new file mode 100644
index 000000000..dc3743072
--- /dev/null
+++ b/tests/unit/test_core_operating_point_movement.py
@@ -0,0 +1,667 @@
+"""This module contains classes to test CoreOperatingPointMovements."""
+
+import unittest
+
+import numpy as np
+import numpy.testing as npt
+from scipy import signal
+
+import pterasoftware as ps
+from tests.unit.fixtures import (
+ core_operating_point_movement_fixtures,
+ operating_point_fixtures,
+)
+
+
+class TestCoreOperatingPointMovement(unittest.TestCase):
+ """This is a class with functions to test CoreOperatingPointMovements."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all CoreOperatingPointMovement tests."""
+ cls.static_core_op_movement = (
+ core_operating_point_movement_fixtures.make_static_core_operating_point_movement_fixture()
+ )
+ cls.sine_spacing_core_op_movement = (
+ core_operating_point_movement_fixtures.make_sine_spacing_core_operating_point_movement_fixture()
+ )
+ cls.uniform_spacing_core_op_movement = (
+ core_operating_point_movement_fixtures.make_uniform_spacing_core_operating_point_movement_fixture()
+ )
+ cls.phase_offset_core_op_movement = (
+ core_operating_point_movement_fixtures.make_phase_offset_core_operating_point_movement_fixture()
+ )
+ cls.custom_spacing_core_op_movement = (
+ core_operating_point_movement_fixtures.make_custom_spacing_core_operating_point_movement_fixture()
+ )
+ cls.basic_core_op_movement = (
+ core_operating_point_movement_fixtures.make_basic_core_operating_point_movement_fixture()
+ )
+ cls.large_amplitude_core_op_movement = (
+ core_operating_point_movement_fixtures.make_large_amplitude_core_operating_point_movement_fixture()
+ )
+ cls.long_period_core_op_movement = (
+ core_operating_point_movement_fixtures.make_long_period_core_operating_point_movement_fixture()
+ )
+
+ def test_initialization_valid_parameters(self):
+ """Test CoreOperatingPointMovement initialization with valid parameters."""
+ # Test that basic CoreOperatingPointMovement initializes correctly.
+ core_op_movement = self.basic_core_op_movement
+ self.assertIsInstance(
+ core_op_movement,
+ ps._core.CoreOperatingPointMovement,
+ )
+ self.assertIsInstance(
+ core_op_movement.base_operating_point,
+ ps.operating_point.OperatingPoint,
+ )
+ self.assertEqual(core_op_movement.ampVCg__E, 5.0)
+ self.assertEqual(core_op_movement.periodVCg__E, 2.0)
+ self.assertEqual(core_op_movement.spacingVCg__E, "sine")
+ self.assertEqual(core_op_movement.phaseVCg__E, 0.0)
+
+ def test_initialization_default_parameters(self):
+ """Test CoreOperatingPointMovement initialization with default parameters."""
+ base_operating_point = (
+ operating_point_fixtures.make_basic_operating_point_fixture()
+ )
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_operating_point
+ )
+
+ self.assertEqual(core_op_movement.ampVCg__E, 0.0)
+ self.assertEqual(core_op_movement.periodVCg__E, 0.0)
+ self.assertEqual(core_op_movement.spacingVCg__E, "sine")
+ self.assertEqual(core_op_movement.phaseVCg__E, 0.0)
+
+ def test_base_operating_point_validation(self):
+ """Test that base_operating_point parameter is properly validated."""
+ # Test with invalid type.
+ with self.assertRaises(TypeError):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point="not an operating point"
+ )
+
+ # Test with None.
+ with self.assertRaises(TypeError):
+ ps._core.CoreOperatingPointMovement(base_operating_point=None)
+
+ # Test with valid OperatingPoint works.
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op
+ )
+ self.assertEqual(core_op_movement.base_operating_point, base_op)
+
+ def test_ampVCg__E_validation(self):
+ """Test ampVCg__E parameter validation."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Test valid non-negative values.
+ valid_amps = [0.0, 1.0, 5.0, 100.0]
+ for amp in valid_amps:
+ with self.subTest(amp=amp):
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op, ampVCg__E=amp
+ )
+ self.assertEqual(core_op_movement.ampVCg__E, amp)
+
+ # Test negative values raise error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op, ampVCg__E=-1.0
+ )
+
+ # Test invalid types raise error.
+ # noinspection PyTypeChecker
+ with self.assertRaises((TypeError, ValueError)):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op, ampVCg__E="invalid"
+ )
+
+ def test_periodVCg__E_validation(self):
+ """Test periodVCg__E parameter validation."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Test valid non-negative values.
+ valid_periods = [0.0, 1.0, 5.0, 100.0]
+ for period in valid_periods:
+ with self.subTest(period=period):
+ amp = 1.0 if period > 0 else 0.0
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=amp,
+ periodVCg__E=period,
+ )
+ self.assertEqual(core_op_movement.periodVCg__E, period)
+
+ # Test negative values raise error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=-1.0,
+ )
+
+ def test_spacingVCg__E_validation(self):
+ """Test spacingVCg__E parameter validation."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Test valid string values.
+ valid_spacings = ["sine", "uniform"]
+ for spacing in valid_spacings:
+ with self.subTest(spacing=spacing):
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op, spacingVCg__E=spacing
+ )
+ self.assertEqual(core_op_movement.spacingVCg__E, spacing)
+
+ # Test invalid string raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op, spacingVCg__E="invalid"
+ )
+
+ # Test callable is accepted.
+ def custom_func(x):
+ return np.sin(x)
+
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op, spacingVCg__E=custom_func
+ )
+ self.assertTrue(callable(core_op_movement.spacingVCg__E))
+
+ # Test non-callable, non-string raises error.
+ with self.assertRaises(TypeError):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op, spacingVCg__E=123
+ )
+
+ def test_phaseVCg__E_validation(self):
+ """Test phaseVCg__E parameter validation."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Test valid phase values within range (-180.0, 180.0].
+ valid_phases = [0.0, 90.0, 180.0, -90.0, -179.9]
+ for phase in valid_phases:
+ with self.subTest(phase=phase):
+ amp = 1.0 if phase != 0 else 0.0
+ period = 1.0 if phase != 0 else 0.0
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=amp,
+ periodVCg__E=period,
+ phaseVCg__E=phase,
+ )
+ self.assertEqual(core_op_movement.phaseVCg__E, phase)
+
+ # Test phase > 180.0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=1.0,
+ phaseVCg__E=180.1,
+ )
+
+ # Test phase <= -180.0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=1.0,
+ phaseVCg__E=-180.0,
+ )
+
+ def test_amp_period_relationship(self):
+ """Test that if ampVCg__E is 0, periodVCg__E must be 0."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Test amp=0 with period=0 works.
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=0.0,
+ periodVCg__E=0.0,
+ )
+ self.assertIsNotNone(core_op_movement)
+
+ # Test amp=0 with period!=0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=0.0,
+ periodVCg__E=1.0,
+ )
+
+ def test_amp_phase_relationship(self):
+ """Test that if ampVCg__E is 0, phaseVCg__E must be 0."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Test amp=0 with phase=0 works.
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=0.0,
+ periodVCg__E=0.0,
+ phaseVCg__E=0.0,
+ )
+ self.assertIsNotNone(core_op_movement)
+
+ # Test amp=0 with phase!=0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=0.0,
+ periodVCg__E=0.0,
+ phaseVCg__E=45.0,
+ )
+
+ def test_spacing_sine_produces_sinusoidal_motion(self):
+ """Test that sine spacing actually produces sinusoidal motion for vCg__E."""
+ num_steps = 100
+ delta_time = 0.01
+ operating_points = self.sine_spacing_core_op_movement.generate_operating_points(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract vCg__E values from generated OperatingPoints.
+ vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
+
+ # Calculate expected sine wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_vCg = 100.0 + 10.0 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated values match the expected sine wave.
+ npt.assert_allclose(vCg_values, expected_vCg, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_uniform_produces_triangular_wave(self):
+ """Test that uniform spacing actually produces triangular wave motion for vCg__E."""
+ num_steps = 100
+ delta_time = 0.01
+ operating_points = (
+ self.uniform_spacing_core_op_movement.generate_operating_points(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+ )
+
+ # Extract vCg__E values from generated OperatingPoints.
+ vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
+
+ # Calculate expected triangular wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_vCg = 100.0 + 10.0 * signal.sawtooth(
+ 2 * np.pi * times / 1.0 + np.pi / 2, 0.5
+ )
+
+ # Assert that the generated values match the expected triangular wave.
+ npt.assert_allclose(vCg_values, expected_vCg, rtol=1e-10, atol=1e-14)
+
+ def test_max_period_static_movement(self):
+ """Test that max_period returns 0.0 for static movement."""
+ core_op_movement = self.static_core_op_movement
+ self.assertEqual(core_op_movement.max_period, 0.0)
+
+ def test_max_period_with_movement(self):
+ """Test that max_period returns correct period for movement."""
+ core_op_movement = self.basic_core_op_movement
+ # periodVCg__E is 2.0, so max should be 2.0.
+ self.assertEqual(core_op_movement.max_period, 2.0)
+
+ def test_max_period_long_period(self):
+ """Test that max_period returns correct value for long period movement."""
+ core_op_movement = self.long_period_core_op_movement
+ # periodVCg__E is 10.0, so max should be 10.0.
+ self.assertEqual(core_op_movement.max_period, 10.0)
+
+ def test_generate_operating_points_parameter_validation(self):
+ """Test that generate_operating_points validates num_steps and delta_time."""
+ core_op_movement = self.basic_core_op_movement
+
+ # Test invalid num_steps.
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ core_op_movement.generate_operating_points(num_steps=0, delta_time=0.01)
+
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ core_op_movement.generate_operating_points(num_steps=-1, delta_time=0.01)
+
+ with self.assertRaises(TypeError):
+ core_op_movement.generate_operating_points(
+ num_steps="invalid", delta_time=0.01
+ )
+
+ # Test invalid delta_time.
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ core_op_movement.generate_operating_points(num_steps=10, delta_time=0.0)
+
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ core_op_movement.generate_operating_points(num_steps=10, delta_time=-0.01)
+
+ with self.assertRaises(TypeError):
+ core_op_movement.generate_operating_points(
+ num_steps=10, delta_time="invalid"
+ )
+
+ def test_generate_operating_points_returns_correct_length(self):
+ """Test that generate_operating_points returns list of correct length."""
+ core_op_movement = self.basic_core_op_movement
+
+ test_num_steps = [1, 5, 10, 25, 50, 100, 200]
+ for num_steps in test_num_steps:
+ with self.subTest(num_steps=num_steps):
+ operating_points = core_op_movement.generate_operating_points(
+ num_steps=num_steps, delta_time=0.01
+ )
+ self.assertEqual(len(operating_points), num_steps)
+
+ def test_generate_operating_points_returns_correct_types(self):
+ """Test that generate_operating_points returns OperatingPoints."""
+ core_op_movement = self.basic_core_op_movement
+ operating_points = core_op_movement.generate_operating_points(
+ num_steps=10, delta_time=0.01
+ )
+
+ # Verify all elements are OperatingPoints.
+ for op in operating_points:
+ self.assertIsInstance(op, ps.operating_point.OperatingPoint)
+
+ def test_generate_operating_points_preserves_non_changing_attributes(self):
+ """Test that generate_operating_points preserves non-changing attributes."""
+ core_op_movement = self.basic_core_op_movement
+ base_op = core_op_movement.base_operating_point
+
+ operating_points = core_op_movement.generate_operating_points(
+ num_steps=10, delta_time=0.01
+ )
+
+ # Check that non-changing attributes are preserved.
+ for op in operating_points:
+ self.assertEqual(op.rho, base_op.rho)
+ self.assertEqual(op.alpha, base_op.alpha)
+ self.assertEqual(op.beta, base_op.beta)
+ self.assertEqual(op.externalFX_W, base_op.externalFX_W)
+ self.assertEqual(op.nu, base_op.nu)
+
+ def test_generate_operating_points_static_movement(self):
+ """Test that static movement produces constant vCg__E."""
+ core_op_movement = self.static_core_op_movement
+ base_op = core_op_movement.base_operating_point
+
+ operating_points = core_op_movement.generate_operating_points(
+ num_steps=50, delta_time=0.01
+ )
+
+ # All OperatingPoints should have same vCg__E.
+ for op in operating_points:
+ self.assertEqual(op.vCg__E, base_op.vCg__E)
+
+ def test_generate_operating_points_different_delta_time(self):
+ """Test generate_operating_points with various delta_time values."""
+ core_op_movement = self.basic_core_op_movement
+
+ delta_time_list = [0.001, 0.01, 0.1, 1.0]
+ num_steps = 50
+ for delta_time in delta_time_list:
+ with self.subTest(delta_time=delta_time):
+ operating_points = core_op_movement.generate_operating_points(
+ num_steps=num_steps, delta_time=delta_time
+ )
+ self.assertEqual(len(operating_points), num_steps)
+
+ def test_phase_offset_shifts_initial_value(self):
+ """Test that phase shifts initial vCg__E correctly."""
+ core_op_movement = self.phase_offset_core_op_movement
+ operating_points = core_op_movement.generate_operating_points(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Extract vCg__E values.
+ vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
+
+ # Verify that phase offset causes non-zero initial value different from base.
+ # With 90 degree phase offset, the first value should not be at the base.
+ self.assertFalse(
+ np.isclose(
+ vCg_values[0], core_op_movement.base_operating_point.vCg__E, atol=1e-10
+ )
+ )
+
+ def test_custom_spacing_function_works(self):
+ """Test that custom spacing function works for vCg__E."""
+ core_op_movement = self.custom_spacing_core_op_movement
+ operating_points = core_op_movement.generate_operating_points(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Extract vCg__E values.
+ vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
+
+ # Verify that values vary (not constant).
+ self.assertFalse(np.allclose(vCg_values, vCg_values[0]))
+
+ # Verify that values are within expected range.
+ # For custom_harmonic with amp=15.0 and base=100.0, values should be roughly
+ # in [85.0, 115.0].
+ self.assertTrue(np.all(vCg_values >= 80.0))
+ self.assertTrue(np.all(vCg_values <= 120.0))
+
+ def test_custom_function_validation_invalid_start_value(self):
+ """Test that custom function with invalid start value raises error."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Define invalid custom function that doesn't start at 0.
+ def invalid_nonzero_start(x):
+ return np.sin(x) + 1.0
+
+ # Should raise error during generation.
+ with self.assertRaises(ValueError):
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=1.0,
+ spacingVCg__E=invalid_nonzero_start,
+ )
+ core_op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
+
+ def test_custom_function_validation_invalid_end_value(self):
+ """Test that custom function with invalid end value raises error."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Define invalid custom function that doesn't return to 0 at 2*pi.
+ def invalid_nonzero_end(x):
+ return np.sin(x) + 0.1
+
+ with self.assertRaises(ValueError):
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=1.0,
+ spacingVCg__E=invalid_nonzero_end,
+ )
+ core_op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
+
+ def test_custom_function_validation_invalid_mean(self):
+ """Test that custom function with invalid mean raises error."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Define invalid custom function with non-zero mean.
+ def invalid_nonzero_mean(x):
+ return np.sin(x) + 0.5
+
+ with self.assertRaises(ValueError):
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=1.0,
+ spacingVCg__E=invalid_nonzero_mean,
+ )
+ core_op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
+
+ def test_custom_function_validation_invalid_amplitude(self):
+ """Test that custom function with invalid amplitude raises error."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Define invalid custom function with wrong amplitude.
+ def invalid_wrong_amplitude(x):
+ return 2.0 * np.sin(x)
+
+ with self.assertRaises(ValueError):
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=1.0,
+ spacingVCg__E=invalid_wrong_amplitude,
+ )
+ core_op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
+
+ def test_custom_function_validation_not_periodic(self):
+ """Test that custom function that is not periodic raises error."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Define invalid custom function that is not periodic.
+ def invalid_not_periodic(x):
+ return np.tanh(x)
+
+ with self.assertRaises(ValueError):
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=1.0,
+ spacingVCg__E=invalid_not_periodic,
+ )
+ core_op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
+
+ def test_custom_function_validation_returns_non_finite(self):
+ """Test that custom function returning NaN or Inf raises error."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Define invalid custom function that returns NaN.
+ def invalid_non_finite(x):
+ return np.where(x < np.pi, np.sin(x), np.nan)
+
+ with self.assertRaises(ValueError):
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=1.0,
+ spacingVCg__E=invalid_non_finite,
+ )
+ core_op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
+
+ def test_custom_function_validation_wrong_shape(self):
+ """Test that custom function returning wrong shape raises error."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Define invalid custom function that returns wrong shape.
+ def invalid_wrong_shape(x):
+ return np.sin(x)[: len(x) // 2]
+
+ with self.assertRaises(ValueError):
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=1.0,
+ periodVCg__E=1.0,
+ spacingVCg__E=invalid_wrong_shape,
+ )
+ core_op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
+
+ def test_unsafe_amplitude_causes_error(self):
+ """Test that amplitude too high for base vCg__E causes error during generation."""
+ # Use low-speed operating point with vCg__E = 10.0.
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ # Create CoreOperatingPointMovement with amplitude that will drive vCg__E negative.
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=15.0,
+ periodVCg__E=1.0,
+ spacingVCg__E="sine",
+ phaseVCg__E=-90.0,
+ )
+
+ # Generating OperatingPoints should raise ValueError when vCg__E goes negative.
+ with self.assertRaises(ValueError) as context:
+ core_op_movement.generate_operating_points(num_steps=100, delta_time=0.01)
+
+ # Verify the error message is about vCg__E validation.
+ self.assertIn("vCg__E", str(context.exception))
+
+ def test_large_amplitude_movement(self):
+ """Test CoreOperatingPointMovement with large amplitude."""
+ core_op_movement = self.large_amplitude_core_op_movement
+ operating_points = core_op_movement.generate_operating_points(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Verify that all OperatingPoints have valid vCg__E values.
+ for op in operating_points:
+ self.assertGreater(op.vCg__E, 0.0)
+
+ # Extract vCg__E values.
+ vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
+
+ # Verify that values vary significantly.
+ self.assertGreater(float(np.max(vCg_values)) - float(np.min(vCg_values)), 50.0)
+
+ def test_integer_parameters_converted_to_float(self):
+ """Test that integer parameters are converted to float internally."""
+ base_op = operating_point_fixtures.make_basic_operating_point_fixture()
+
+ core_op_movement = ps._core.CoreOperatingPointMovement(
+ base_operating_point=base_op,
+ ampVCg__E=5,
+ periodVCg__E=2,
+ phaseVCg__E=90,
+ )
+
+ self.assertIsInstance(core_op_movement.ampVCg__E, float)
+ self.assertIsInstance(core_op_movement.periodVCg__E, float)
+ self.assertIsInstance(core_op_movement.phaseVCg__E, float)
+ self.assertEqual(core_op_movement.ampVCg__E, 5.0)
+ self.assertEqual(core_op_movement.periodVCg__E, 2.0)
+ self.assertEqual(core_op_movement.phaseVCg__E, 90.0)
+
+
+class TestCoreOperatingPointMovementImmutability(unittest.TestCase):
+ """Tests for CoreOperatingPointMovement attribute immutability."""
+
+ def setUp(self):
+ """Set up test fixtures for immutability tests."""
+ self.basic_core_op_movement = (
+ core_operating_point_movement_fixtures.make_basic_core_operating_point_movement_fixture()
+ )
+
+ def test_immutable_base_operating_point_property(self):
+ """Test that base_operating_point property is read only."""
+ new_op = operating_point_fixtures.make_high_speed_operating_point_fixture()
+ with self.assertRaises(AttributeError):
+ self.basic_core_op_movement.base_operating_point = new_op
+
+ def test_immutable_ampVCg__E_property(self):
+ """Test that ampVCg__E property is read only."""
+ with self.assertRaises(AttributeError):
+ self.basic_core_op_movement.ampVCg__E = 10.0
+
+ def test_immutable_periodVCg__E_property(self):
+ """Test that periodVCg__E property is read only."""
+ with self.assertRaises(AttributeError):
+ self.basic_core_op_movement.periodVCg__E = 5.0
+
+ def test_immutable_spacingVCg__E_property(self):
+ """Test that spacingVCg__E property is read only."""
+ with self.assertRaises(AttributeError):
+ self.basic_core_op_movement.spacingVCg__E = "uniform"
+
+ def test_immutable_phaseVCg__E_property(self):
+ """Test that phaseVCg__E property is read only."""
+ with self.assertRaises(AttributeError):
+ self.basic_core_op_movement.phaseVCg__E = 45.0
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_core_unsteady_problem.py b/tests/unit/test_core_unsteady_problem.py
new file mode 100644
index 000000000..193153275
--- /dev/null
+++ b/tests/unit/test_core_unsteady_problem.py
@@ -0,0 +1,224 @@
+"""This module contains classes to test CoreUnsteadyProblems."""
+
+import math
+import unittest
+
+import numpy as np
+
+import pterasoftware as ps
+
+
+class TestCoreUnsteadyProblem(unittest.TestCase):
+ """This is a class with functions to test CoreUnsteadyProblems."""
+
+ def test_initialization_static(self):
+ """Test CoreUnsteadyProblem initialization with static parameters."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=50,
+ max_wake_rows=None,
+ lcm_period=0.0,
+ )
+ self.assertFalse(core_unsteady_problem.only_final_results)
+ self.assertEqual(core_unsteady_problem.delta_time, 0.01)
+ self.assertEqual(core_unsteady_problem.num_steps, 50)
+ self.assertIsNone(core_unsteady_problem.max_wake_rows)
+
+ def test_initialization_cyclic(self):
+ """Test CoreUnsteadyProblem initialization with cyclic parameters."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=300,
+ max_wake_rows=None,
+ lcm_period=2.0,
+ )
+ self.assertFalse(core_unsteady_problem.only_final_results)
+ self.assertEqual(core_unsteady_problem.delta_time, 0.01)
+ self.assertEqual(core_unsteady_problem.num_steps, 300)
+
+ def test_first_averaging_step_static(self):
+ """Test first_averaging_step for static CoreUnsteadyProblem."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=50,
+ max_wake_rows=None,
+ lcm_period=0.0,
+ )
+ # For static (lcm_period == 0), first_averaging_step == num_steps - 1.
+ self.assertEqual(core_unsteady_problem.first_averaging_step, 49)
+
+ def test_first_averaging_step_cyclic(self):
+ """Test first_averaging_step for cyclic CoreUnsteadyProblem."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=300,
+ max_wake_rows=None,
+ lcm_period=2.0,
+ )
+ # Expected: max(0, floor(300 - 2.0 / 0.01)) = max(0, floor(100)) = 100.
+ expected = max(0, math.floor(300 - (2.0 / 0.01)))
+ self.assertEqual(core_unsteady_problem.first_averaging_step, expected)
+
+ def test_first_averaging_step_cyclic_period_exceeds_duration(self):
+ """Test first_averaging_step when lcm_period exceeds total duration."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=100,
+ max_wake_rows=None,
+ lcm_period=5.0,
+ )
+ # Expected: max(0, floor(100 - 5.0 / 0.01)) = max(0, -400) = 0.
+ self.assertEqual(core_unsteady_problem.first_averaging_step, 0)
+
+ def test_first_results_step_only_final_results_false(self):
+ """Test first_results_step when only_final_results is False."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=50,
+ max_wake_rows=None,
+ lcm_period=0.0,
+ )
+ self.assertEqual(core_unsteady_problem.first_results_step, 0)
+
+ def test_first_results_step_only_final_results_true(self):
+ """Test first_results_step when only_final_results is True."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=True,
+ delta_time=0.01,
+ num_steps=50,
+ max_wake_rows=None,
+ lcm_period=0.0,
+ )
+ # When only_final_results is True, first_results_step ==
+ # first_averaging_step.
+ self.assertEqual(
+ core_unsteady_problem.first_results_step,
+ core_unsteady_problem.first_averaging_step,
+ )
+
+ def test_only_final_results_accepts_numpy_bool(self):
+ """Test that only_final_results accepts numpy bool values."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=np.bool_(True),
+ delta_time=0.01,
+ num_steps=10,
+ max_wake_rows=None,
+ lcm_period=0.0,
+ )
+ self.assertTrue(core_unsteady_problem.only_final_results)
+ self.assertIsInstance(core_unsteady_problem.only_final_results, bool)
+
+ def test_initialization_of_load_lists(self):
+ """Test that load lists are initialized as empty."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=10,
+ max_wake_rows=None,
+ lcm_period=0.0,
+ )
+
+ # All 12 load lists should be initialized as empty lists.
+ load_list_names = [
+ "finalForces_W",
+ "finalForceCoefficients_W",
+ "finalMoments_W_CgP1",
+ "finalMomentCoefficients_W_CgP1",
+ "finalMeanForces_W",
+ "finalMeanForceCoefficients_W",
+ "finalMeanMoments_W_CgP1",
+ "finalMeanMomentCoefficients_W_CgP1",
+ "finalRmsForces_W",
+ "finalRmsForceCoefficients_W",
+ "finalRmsMoments_W_CgP1",
+ "finalRmsMomentCoefficients_W_CgP1",
+ ]
+ for name in load_list_names:
+ with self.subTest(name=name):
+ load_list = getattr(core_unsteady_problem, name)
+ self.assertIsInstance(load_list, list)
+ self.assertEqual(len(load_list), 0)
+
+ def test_max_wake_rows_none(self):
+ """Test that max_wake_rows is None when not set."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=10,
+ max_wake_rows=None,
+ lcm_period=0.0,
+ )
+ self.assertIsNone(core_unsteady_problem.max_wake_rows)
+
+ def test_max_wake_rows_positive_int(self):
+ """Test that max_wake_rows stores a positive int correctly."""
+ core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=10,
+ max_wake_rows=25,
+ lcm_period=0.0,
+ )
+ self.assertEqual(core_unsteady_problem.max_wake_rows, 25)
+
+
+class TestCoreUnsteadyProblemImmutability(unittest.TestCase):
+ """Tests for CoreUnsteadyProblem attribute immutability."""
+
+ def setUp(self):
+ """Set up test fixtures for immutability tests."""
+ self.core_unsteady_problem = ps._core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=50,
+ max_wake_rows=None,
+ lcm_period=0.0,
+ )
+
+ def test_immutable_only_final_results_property(self):
+ """Test that only_final_results property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_unsteady_problem.only_final_results = True
+
+ def test_immutable_num_steps_property(self):
+ """Test that num_steps property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_unsteady_problem.num_steps = 100
+
+ def test_immutable_delta_time_property(self):
+ """Test that delta_time property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_unsteady_problem.delta_time = 0.1
+
+ def test_immutable_first_averaging_step_property(self):
+ """Test that first_averaging_step property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_unsteady_problem.first_averaging_step = 0
+
+ def test_immutable_first_results_step_property(self):
+ """Test that first_results_step property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_unsteady_problem.first_results_step = 0
+
+ def test_immutable_max_wake_rows_property(self):
+ """Test that max_wake_rows property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_unsteady_problem.max_wake_rows = 10
+
+ def test_mutable_load_lists(self):
+ """Test that load lists remain mutable for solver population."""
+ self.core_unsteady_problem.finalForces_W.append(np.array([1.0, 2.0, 3.0]))
+ self.assertEqual(len(self.core_unsteady_problem.finalForces_W), 1)
+
+ # Clean up.
+ self.core_unsteady_problem.finalForces_W.pop()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_core_wing_cross_section_movement.py b/tests/unit/test_core_wing_cross_section_movement.py
new file mode 100644
index 000000000..e2b35d764
--- /dev/null
+++ b/tests/unit/test_core_wing_cross_section_movement.py
@@ -0,0 +1,1519 @@
+"""This module contains classes to test CoreWingCrossSectionMovements."""
+
+import copy
+import unittest
+
+import numpy as np
+import numpy.testing as npt
+from scipy import signal
+
+import pterasoftware as ps
+from tests.unit.fixtures import core_wing_cross_section_movement_fixtures
+
+
+class TestCoreWingCrossSectionMovement(unittest.TestCase):
+ """This is a class with functions to test CoreWingCrossSectionMovements."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all CoreWingCrossSectionMovement tests."""
+ # Spacing test fixtures.
+ cls.sine_spacing_Lp_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_sine_spacing_Lp_core_wing_cross_section_movement_fixture()
+ )
+ cls.uniform_spacing_Lp_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_uniform_spacing_Lp_core_wing_cross_section_movement_fixture()
+ )
+ cls.mixed_spacing_Lp_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_mixed_spacing_Lp_core_wing_cross_section_movement_fixture()
+ )
+ cls.sine_spacing_angles_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_sine_spacing_angles_core_wing_cross_section_movement_fixture()
+ )
+ cls.uniform_spacing_angles_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_uniform_spacing_angles_core_wing_cross_section_movement_fixture()
+ )
+ cls.mixed_spacing_angles_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_mixed_spacing_angles_core_wing_cross_section_movement_fixture()
+ )
+
+ # Additional test fixtures.
+ cls.static_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ )
+ cls.basic_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_basic_core_wing_cross_section_movement_fixture()
+ )
+ cls.Lp_only_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_Lp_only_core_wing_cross_section_movement_fixture()
+ )
+ cls.angles_only_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_angles_only_core_wing_cross_section_movement_fixture()
+ )
+ cls.phase_offset_Lp_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_phase_offset_Lp_core_wing_cross_section_movement_fixture()
+ )
+ cls.phase_offset_angles_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_phase_offset_angles_core_wing_cross_section_movement_fixture()
+ )
+ cls.multiple_periods_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_multiple_periods_core_wing_cross_section_movement_fixture()
+ )
+ cls.custom_spacing_Lp_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_custom_spacing_Lp_core_wing_cross_section_movement_fixture()
+ )
+ cls.custom_spacing_angles_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_custom_spacing_angles_core_wing_cross_section_movement_fixture()
+ )
+ cls.mixed_custom_and_standard_spacing_core_wcs_movement = (
+ core_wing_cross_section_movement_fixtures.make_mixed_custom_and_standard_spacing_core_wing_cross_section_movement_fixture()
+ )
+
+ def test_spacing_sine_for_Lp_Wcsp_Lpp(self):
+ """Test that sine spacing actually produces sinusoidal motion for
+ Lp_Wcsp_Lpp."""
+ num_steps = 100
+ delta_time = 0.01
+ wing_cross_sections = (
+ self.sine_spacing_Lp_core_wcs_movement.generate_wing_cross_sections(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+ )
+
+ # Extract x-positions from generated WingCrossSections.
+ x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
+
+ # Calculate expected sine wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 1.0 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated positions match the expected sine wave.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_uniform_for_Lp_Wcsp_Lpp(self):
+ """Test that uniform spacing actually produces triangular wave motion for
+ Lp_Wcsp_Lpp."""
+ num_steps = 100
+ delta_time = 0.01
+ wing_cross_sections = (
+ self.uniform_spacing_Lp_core_wcs_movement.generate_wing_cross_sections(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+ )
+
+ # Extract x-positions from generated WingCrossSections.
+ x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
+
+ # Calculate expected triangular wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 1.0 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
+
+ # Assert that the generated positions match the expected triangular wave.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_mixed_for_Lp_Wcsp_Lpp(self):
+ """Test that mixed spacing types work correctly for Lp_Wcsp_Lpp."""
+ num_steps = 100
+ delta_time = 0.01
+ wing_cross_sections = (
+ self.mixed_spacing_Lp_core_wcs_movement.generate_wing_cross_sections(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+ )
+
+ # Extract positions from generated WingCrossSections.
+ x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
+ y_positions = np.array([wcs.Lp_Wcsp_Lpp[1] for wcs in wing_cross_sections])
+ z_positions = np.array([wcs.Lp_Wcsp_Lpp[2] for wcs in wing_cross_sections])
+
+ # Calculate expected values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 0.5 + 1.0 * np.sin(2 * np.pi * times / 1.0)
+ expected_y = 2.0 + 1.5 * signal.sawtooth(
+ 2 * np.pi * times / 1.0 + np.pi / 2, 0.5
+ )
+ expected_z = 0.2 + 0.5 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated positions match the expected values.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_sine_for_angles_Wcsp_to_Wcs_ixyz(self):
+ """Test that sine spacing actually produces sinusoidal motion for
+ angles_Wcsp_to_Wcs_ixyz."""
+ num_steps = 100
+ delta_time = 0.01
+ wing_cross_sections = (
+ self.sine_spacing_angles_core_wcs_movement.generate_wing_cross_sections(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+ )
+
+ # Extract angles from generated WingCrossSections.
+ angles_z = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
+ )
+
+ # Calculate expected sine wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_angles = 10.0 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated angles match the expected sine wave.
+ npt.assert_allclose(angles_z, expected_angles, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_uniform_for_angles_Wcsp_to_Wcs_ixyz(self):
+ """Test that uniform spacing actually produces triangular wave motion for
+ angles_Wcsp_to_Wcs_ixyz."""
+ num_steps = 100
+ delta_time = 0.01
+ wing_cross_sections = (
+ self.uniform_spacing_angles_core_wcs_movement.generate_wing_cross_sections(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+ )
+
+ # Extract angles from generated WingCrossSections.
+ angles_z = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
+ )
+
+ # Calculate expected triangular wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_angles = 10.0 * signal.sawtooth(
+ 2 * np.pi * times / 1.0 + np.pi / 2, 0.5
+ )
+
+ # Assert that the generated angles match the expected triangular wave.
+ npt.assert_allclose(angles_z, expected_angles, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_mixed_for_angles_Wcsp_to_Wcs_ixyz(self):
+ """Test that mixed spacing types work correctly for angles_Wcsp_to_Wcs_ixyz."""
+ num_steps = 100
+ delta_time = 0.01
+ wing_cross_sections = (
+ self.mixed_spacing_angles_core_wcs_movement.generate_wing_cross_sections(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+ )
+
+ # Extract angles from generated WingCrossSections.
+ angles_z = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
+ )
+ angles_y = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[1] for wcs in wing_cross_sections]
+ )
+ angles_x = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[2] for wcs in wing_cross_sections]
+ )
+
+ # Calculate expected values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_angles_z = 10.0 * np.sin(2 * np.pi * times / 1.0)
+ expected_angles_y = 20.0 * signal.sawtooth(
+ 2 * np.pi * times / 1.0 + np.pi / 2, 0.5
+ )
+ expected_angles_x = 5.0 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated angles match the expected values.
+ npt.assert_allclose(angles_z, expected_angles_z, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(angles_y, expected_angles_y, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(angles_x, expected_angles_x, rtol=1e-10, atol=1e-14)
+
+ def test_initialization_valid_parameters(self):
+ """Test CoreWingCrossSectionMovement initialization with valid parameters."""
+ # Test that basic CoreWingCrossSectionMovement initializes correctly.
+ core_wcs_movement = self.basic_core_wcs_movement
+ self.assertIsInstance(
+ core_wcs_movement,
+ ps._core.CoreWingCrossSectionMovement,
+ )
+ self.assertIsInstance(
+ core_wcs_movement.base_wing_cross_section,
+ ps.geometry.wing_cross_section.WingCrossSection,
+ )
+ npt.assert_array_equal(
+ core_wcs_movement.ampLp_Wcsp_Lpp, np.array([0.4, 0.3, 0.15])
+ )
+ npt.assert_array_equal(
+ core_wcs_movement.periodLp_Wcsp_Lpp, np.array([2.0, 2.0, 2.0])
+ )
+ self.assertEqual(core_wcs_movement.spacingLp_Wcsp_Lpp, ("sine", "sine", "sine"))
+ npt.assert_array_equal(
+ core_wcs_movement.phaseLp_Wcsp_Lpp, np.array([0.0, 0.0, 0.0])
+ )
+ npt.assert_array_equal(
+ core_wcs_movement.ampAngles_Wcsp_to_Wcs_ixyz, np.array([15.0, 10.0, 5.0])
+ )
+ npt.assert_array_equal(
+ core_wcs_movement.periodAngles_Wcsp_to_Wcs_ixyz, np.array([2.0, 2.0, 2.0])
+ )
+ self.assertEqual(
+ core_wcs_movement.spacingAngles_Wcsp_to_Wcs_ixyz, ("sine", "sine", "sine")
+ )
+ npt.assert_array_equal(
+ core_wcs_movement.phaseAngles_Wcsp_to_Wcs_ixyz, np.array([0.0, 0.0, 0.0])
+ )
+
+ def test_base_wing_cross_section_validation(self):
+ """Test that base_wing_cross_section parameter validation works correctly."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ # Test non-WingCrossSection raises error.
+ with self.assertRaises(TypeError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section="not a wing cross section"
+ )
+
+ # Test None raises error.
+ with self.assertRaises(TypeError):
+ ps._core.CoreWingCrossSectionMovement(base_wing_cross_section=None)
+
+ # Test valid WingCrossSection works.
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs
+ )
+ self.assertEqual(core_wcs_movement.base_wing_cross_section, base_wcs)
+
+ def test_ampLp_Wcsp_Lpp_validation(self):
+ """Test ampLp_Wcsp_Lpp parameter validation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test valid values.
+ valid_amps = [
+ (0.0, 0.0, 0.0),
+ (1.0, 2.0, 3.0),
+ [0.5, 1.5, 2.5],
+ np.array([0.1, 0.2, 0.3]),
+ ]
+ for amp in valid_amps:
+ with self.subTest(amp=amp):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs, ampLp_Wcsp_Lpp=amp
+ )
+ npt.assert_array_equal(core_wcs_movement.ampLp_Wcsp_Lpp, amp)
+
+ # Test negative values raise error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs, ampLp_Wcsp_Lpp=(-1.0, 0.0, 0.0)
+ )
+
+ # Test invalid types raise error.
+ # noinspection PyTypeChecker
+ with self.assertRaises((TypeError, ValueError)):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs, ampLp_Wcsp_Lpp="invalid"
+ )
+
+ def test_periodLp_Wcsp_Lpp_validation(self):
+ """Test periodLp_Wcsp_Lpp parameter validation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test valid values.
+ valid_periods = [(0.0, 0.0, 0.0), (1.0, 2.0, 3.0), [0.5, 1.5, 2.5]]
+ for period in valid_periods:
+ with self.subTest(period=period):
+ # Need matching amps for non-zero periods.
+ amp = tuple(1.0 if p > 0 else 0.0 for p in period)
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=amp,
+ periodLp_Wcsp_Lpp=period,
+ )
+ npt.assert_array_equal(core_wcs_movement.periodLp_Wcsp_Lpp, period)
+
+ # Test negative values raise error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
+ periodLp_Wcsp_Lpp=(-1.0, 1.0, 1.0),
+ )
+
+ def test_spacingLp_Wcsp_Lpp_validation(self):
+ """Test spacingLp_Wcsp_Lpp parameter validation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test valid string values.
+ valid_spacings = [
+ ("sine", "sine", "sine"),
+ ("uniform", "uniform", "uniform"),
+ ("sine", "uniform", "sine"),
+ ]
+ for spacing in valid_spacings:
+ with self.subTest(spacing=spacing):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs, spacingLp_Wcsp_Lpp=spacing
+ )
+ self.assertEqual(core_wcs_movement.spacingLp_Wcsp_Lpp, spacing)
+
+ # Test invalid string raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ spacingLp_Wcsp_Lpp=("invalid", "sine", "sine"),
+ )
+
+ def test_phaseLp_Wcsp_Lpp_validation(self):
+ """Test phaseLp_Wcsp_Lpp parameter validation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test valid phase values within range (-180.0, 180.0].
+ valid_phases = [
+ (0.0, 0.0, 0.0),
+ (90.0, 180.0, -90.0),
+ (179.9, 0.0, -179.9),
+ ]
+ for phase in valid_phases:
+ with self.subTest(phase=phase):
+ # Need non-zero amps for non-zero phases.
+ amp = tuple(1.0 if p != 0 else 0.0 for p in phase)
+ period = tuple(1.0 if p != 0 else 0.0 for p in phase)
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=amp,
+ periodLp_Wcsp_Lpp=period,
+ phaseLp_Wcsp_Lpp=phase,
+ )
+ npt.assert_array_equal(core_wcs_movement.phaseLp_Wcsp_Lpp, phase)
+
+ # Test phase > 180.0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
+ periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
+ phaseLp_Wcsp_Lpp=(180.1, 0.0, 0.0),
+ )
+
+ # Test phase <= -180.0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
+ periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
+ phaseLp_Wcsp_Lpp=(-180.0, 0.0, 0.0),
+ )
+
+ def test_ampAngles_Wcsp_to_Wcs_ixyz_validation(self):
+ """Test ampAngles_Wcsp_to_Wcs_ixyz parameter validation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test valid amplitude values within range [0.0, 180.0].
+ valid_amps = [
+ (0.0, 0.0, 0.0),
+ (45.0, 90.0, 135.0),
+ (179.9, 0.0, 90.0),
+ (180.0, 0.0, 0.0),
+ ]
+ for amp in valid_amps:
+ with self.subTest(amp=amp):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs, ampAngles_Wcsp_to_Wcs_ixyz=amp
+ )
+ npt.assert_array_equal(
+ core_wcs_movement.ampAngles_Wcsp_to_Wcs_ixyz, amp
+ )
+
+ # Test amplitude > 180.0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(180.1, 0.0, 0.0),
+ )
+
+ # Test negative amplitude raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(-1.0, 0.0, 0.0),
+ )
+
+ def test_periodAngles_Wcsp_to_Wcs_ixyz_validation(self):
+ """Test periodAngles_Wcsp_to_Wcs_ixyz parameter validation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test valid periods.
+ valid_periods = [(0.0, 0.0, 0.0), (1.0, 2.0, 3.0)]
+ for period in valid_periods:
+ with self.subTest(period=period):
+ amp = tuple(10.0 if p > 0 else 0.0 for p in period)
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=amp,
+ periodAngles_Wcsp_to_Wcs_ixyz=period,
+ )
+ npt.assert_array_equal(
+ core_wcs_movement.periodAngles_Wcsp_to_Wcs_ixyz, period
+ )
+
+ # Test negative period raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 10.0, 10.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(-1.0, 1.0, 1.0),
+ )
+
+ def test_spacingAngles_Wcsp_to_Wcs_ixyz_validation(self):
+ """Test spacingAngles_Wcsp_to_Wcs_ixyz parameter validation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test valid string values.
+ valid_spacings = [
+ ("sine", "sine", "sine"),
+ ("uniform", "uniform", "uniform"),
+ ("sine", "uniform", "sine"),
+ ]
+ for spacing in valid_spacings:
+ with self.subTest(spacing=spacing):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ spacingAngles_Wcsp_to_Wcs_ixyz=spacing,
+ )
+ self.assertEqual(
+ core_wcs_movement.spacingAngles_Wcsp_to_Wcs_ixyz, spacing
+ )
+
+ # Test invalid string raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ spacingAngles_Wcsp_to_Wcs_ixyz=("invalid", "sine", "sine"),
+ )
+
+ def test_phaseAngles_Wcsp_to_Wcs_ixyz_validation(self):
+ """Test phaseAngles_Wcsp_to_Wcs_ixyz parameter validation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test valid phase values within range (-180.0, 180.0].
+ valid_phases = [(0.0, 0.0, 0.0), (90.0, 180.0, -90.0), (179.9, 0.0, -179.9)]
+ for phase in valid_phases:
+ with self.subTest(phase=phase):
+ amp = tuple(10.0 if p != 0 else 0.0 for p in phase)
+ period = tuple(1.0 if p != 0 else 0.0 for p in phase)
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=amp,
+ periodAngles_Wcsp_to_Wcs_ixyz=period,
+ phaseAngles_Wcsp_to_Wcs_ixyz=phase,
+ )
+ npt.assert_array_equal(
+ core_wcs_movement.phaseAngles_Wcsp_to_Wcs_ixyz, phase
+ )
+
+ # Test phase > 180.0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 10.0, 10.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 1.0),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(180.1, 0.0, 0.0),
+ )
+
+ def test_amp_period_relationship_Lp(self):
+ """Test that if ampLp_Wcsp_Lpp element is 0, corresponding period must be 0."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test amp=0 with period=0 works.
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
+ periodLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
+ )
+ self.assertIsNotNone(core_wcs_movement)
+
+ # Test amp=0 with period!=0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 1.0, 0.0),
+ )
+
+ def test_amp_phase_relationship_Lp(self):
+ """Test that if ampLp_Wcsp_Lpp element is 0, corresponding phase must be 0."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test amp=0 with phase=0 works.
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
+ periodLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
+ phaseLp_Wcsp_Lpp=(0.0, -90.0, 0.0),
+ )
+ self.assertIsNotNone(core_wcs_movement)
+
+ # Test amp=0 with phase!=0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
+ periodLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
+ phaseLp_Wcsp_Lpp=(45.0, -90.0, 0.0),
+ )
+
+ def test_amp_period_relationship_angles(self):
+ """Test that if ampAngles_Wcsp_to_Wcs_ixyz element is 0, corresponding period must be 0."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test amp=0 with period=0 works.
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 10.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 1.0, 0.0),
+ )
+ self.assertIsNotNone(core_wcs_movement)
+
+ # Test amp=0 with period!=0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 10.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 0.0),
+ )
+
+ def test_amp_phase_relationship_angles(self):
+ """Test that if ampAngles_Wcsp_to_Wcs_ixyz element is 0, corresponding phase must be 0."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test amp=0 with phase=0 works.
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 10.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 1.0, 0.0),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, -90.0, 0.0),
+ )
+ self.assertIsNotNone(core_wcs_movement)
+
+ # Test amp=0 with phase!=0 raises error.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 10.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 1.0, 0.0),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(45.0, -90.0, 0.0),
+ )
+
+ def test_max_period_static_movement(self):
+ """Test that max_period returns 0.0 for static movement."""
+ core_wcs_movement = self.static_core_wcs_movement
+ self.assertEqual(core_wcs_movement.max_period, 0.0)
+
+ def test_max_period_Lp_only(self):
+ """Test that max_period returns correct period for Lp-only movement."""
+ core_wcs_movement = self.Lp_only_core_wcs_movement
+ # periodLp_Wcsp_Lpp is (1.5, 1.5, 1.5), so max should be 1.5.
+ self.assertEqual(core_wcs_movement.max_period, 1.5)
+
+ def test_max_period_angles_only(self):
+ """Test that max_period returns correct period for angles-only movement."""
+ core_wcs_movement = self.angles_only_core_wcs_movement
+ # periodAngles_Wcsp_to_Wcs_ixyz is (1.5, 1.5, 1.5), so max should be 1.5.
+ self.assertEqual(core_wcs_movement.max_period, 1.5)
+
+ def test_max_period_mixed(self):
+ """Test that max_period returns maximum of all periods for mixed movement."""
+ core_wcs_movement = self.multiple_periods_core_wcs_movement
+ # periodLp_Wcsp_Lpp is (1.0, 2.0, 3.0).
+ # periodAngles_Wcsp_to_Wcs_ixyz is (0.5, 1.5, 2.5).
+ # Maximum should be 3.0.
+ self.assertEqual(core_wcs_movement.max_period, 3.0)
+
+ def test_max_period_multiple_dimensions(self):
+ """Test max_period with multiple dimensions having different periods."""
+ core_wcs_movement = self.basic_core_wcs_movement
+ # Both periodLp_Wcsp_Lpp and periodAngles_Wcsp_to_Wcs_ixyz are (2.0, 2.0, 2.0).
+ # Maximum should be 2.0.
+ self.assertEqual(core_wcs_movement.max_period, 2.0)
+
+ def test_all_periods_static_movement(self):
+ """Test that all_periods returns empty tuple for static movement."""
+ wing_cross_section_movement = self.static_core_wcs_movement
+ self.assertEqual(wing_cross_section_movement.all_periods, ())
+
+ def test_all_periods_Lp_only(self):
+ """Test that all_periods returns correct periods for Lp only movement."""
+ wing_cross_section_movement = self.Lp_only_core_wcs_movement
+ # periodLp_Wcsp_Lpp is (1.5, 1.5, 1.5), all non zero.
+ # periodAngles_Wcsp_to_Wcs_ixyz is (0.0, 0.0, 0.0).
+ # Should return tuple with three 1.5 values.
+ self.assertEqual(wing_cross_section_movement.all_periods, (1.5, 1.5, 1.5))
+
+ def test_all_periods_angles_only(self):
+ """Test that all_periods returns correct periods for angles only movement."""
+ wing_cross_section_movement = self.angles_only_core_wcs_movement
+ # periodLp_Wcsp_Lpp is (0.0, 0.0, 0.0).
+ # periodAngles_Wcsp_to_Wcs_ixyz is (1.5, 1.5, 1.5), all non zero.
+ # Should return tuple with three 1.5 values.
+ self.assertEqual(wing_cross_section_movement.all_periods, (1.5, 1.5, 1.5))
+
+ def test_all_periods_mixed(self):
+ """Test that all_periods returns all non zero periods for mixed movement."""
+ wing_cross_section_movement = self.multiple_periods_core_wcs_movement
+ # periodLp_Wcsp_Lpp is (1.0, 2.0, 3.0).
+ # periodAngles_Wcsp_to_Wcs_ixyz is (0.5, 1.5, 2.5).
+ # Should return tuple with all six values.
+ expected = (1.0, 2.0, 3.0, 0.5, 1.5, 2.5)
+ self.assertEqual(wing_cross_section_movement.all_periods, expected)
+
+ def test_all_periods_contains_duplicates(self):
+ """Test that all_periods contains duplicate periods if they appear multiple
+ times.
+ """
+ wing_cross_section_movement = self.basic_core_wcs_movement
+ # Both periodLp_Wcsp_Lpp and periodAngles_Wcsp_to_Wcs_ixyz are (2.0, 2.0, 2.0).
+ # Should return tuple with six 2.0 values (not deduplicated).
+ expected = (2.0, 2.0, 2.0, 2.0, 2.0, 2.0)
+ self.assertEqual(wing_cross_section_movement.all_periods, expected)
+
+ def test_all_periods_partial_movement(self):
+ """Test all_periods with only some dimensions having non zero periods."""
+ wing_cross_section_movement = self.sine_spacing_Lp_core_wcs_movement
+ # periodLp_Wcsp_Lpp is (1.0, 0.0, 0.0), only first element is non zero.
+ # periodAngles_Wcsp_to_Wcs_ixyz is (0.0, 0.0, 0.0).
+ # Should return tuple with one 1.0 value.
+ self.assertEqual(wing_cross_section_movement.all_periods, (1.0,))
+
+ def test_generate_wing_cross_sections_parameter_validation(self):
+ """Test that generate_wing_cross_sections validates num_steps and delta_time."""
+ core_wcs_movement = self.basic_core_wcs_movement
+
+ # Test invalid num_steps.
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ core_wcs_movement.generate_wing_cross_sections(num_steps=0, delta_time=0.01)
+
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=-1, delta_time=0.01
+ )
+
+ with self.assertRaises(TypeError):
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps="invalid", delta_time=0.01
+ )
+
+ # Test invalid delta_time.
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ core_wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=0.0)
+
+ # noinspection PyTypeChecker
+ with self.assertRaises((ValueError, TypeError)):
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=-0.01
+ )
+
+ with self.assertRaises(TypeError):
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time="invalid"
+ )
+
+ def test_generate_wing_cross_sections_returns_correct_length(self):
+ """Test that generate_wing_cross_sections returns list of correct length."""
+ core_wcs_movement = self.basic_core_wcs_movement
+
+ test_num_steps = [1, 5, 10, 50, 100]
+ for num_steps in test_num_steps:
+ with self.subTest(num_steps=num_steps):
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=num_steps, delta_time=0.01
+ )
+ self.assertEqual(len(wing_cross_sections), num_steps)
+
+ def test_generate_wing_cross_sections_returns_correct_types(self):
+ """Test that generate_wing_cross_sections returns WingCrossSections."""
+ core_wcs_movement = self.basic_core_wcs_movement
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=0.01
+ )
+
+ # Verify all elements are WingCrossSections.
+ for wcs in wing_cross_sections:
+ self.assertIsInstance(wcs, ps.geometry.wing_cross_section.WingCrossSection)
+
+ def test_generate_wing_cross_sections_preserves_non_changing_attributes(self):
+ """Test that generate_wing_cross_sections preserves non-changing attributes."""
+ core_wcs_movement = self.basic_core_wcs_movement
+ base_wcs = core_wcs_movement.base_wing_cross_section
+
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=0.01
+ )
+
+ # Check that non-changing attributes are preserved.
+ for wcs in wing_cross_sections:
+ self.assertEqual(wcs.airfoil, base_wcs.airfoil)
+ self.assertEqual(wcs.chord, base_wcs.chord)
+ self.assertEqual(wcs.num_spanwise_panels, base_wcs.num_spanwise_panels)
+ self.assertEqual(
+ wcs.control_surface_symmetry_type,
+ base_wcs.control_surface_symmetry_type,
+ )
+ self.assertEqual(
+ wcs.control_surface_hinge_point, base_wcs.control_surface_hinge_point
+ )
+ self.assertEqual(
+ wcs.control_surface_deflection, base_wcs.control_surface_deflection
+ )
+ self.assertEqual(wcs.spanwise_spacing, base_wcs.spanwise_spacing)
+
+ def test_generate_wing_cross_sections_static_movement(self):
+ """Test that static movement produces constant positions and angles."""
+ core_wcs_movement = self.static_core_wcs_movement
+ base_wcs = core_wcs_movement.base_wing_cross_section
+
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=50, delta_time=0.01
+ )
+
+ # All WingCrossSections should have same Lp_Wcsp_Lpp and angles_Wcsp_to_Wcs_ixyz.
+ for wcs in wing_cross_sections:
+ npt.assert_array_equal(wcs.Lp_Wcsp_Lpp, base_wcs.Lp_Wcsp_Lpp)
+ npt.assert_array_equal(
+ wcs.angles_Wcsp_to_Wcs_ixyz, base_wcs.angles_Wcsp_to_Wcs_ixyz
+ )
+
+ def test_phase_offset_Lp(self):
+ """Test that phase shifts initial position correctly for Lp_Wcsp_Lpp."""
+ core_wcs_movement = self.phase_offset_Lp_core_wcs_movement
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Extract positions.
+ x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
+ y_positions = np.array([wcs.Lp_Wcsp_Lpp[1] for wcs in wing_cross_sections])
+ z_positions = np.array([wcs.Lp_Wcsp_Lpp[2] for wcs in wing_cross_sections])
+
+ # Verify that phase offset causes non-zero initial values.
+ # With phase offsets, the first values should not all be at the base position.
+ self.assertFalse(np.allclose(x_positions[0], 0.0, atol=1e-10))
+ self.assertFalse(np.allclose(y_positions[0], 0.0, atol=1e-10))
+ self.assertFalse(np.allclose(z_positions[0], 0.0, atol=1e-10))
+
+ def test_phase_offset_angles(self):
+ """Test that phase shifts initial angles correctly for angles_Wcsp_to_Wcs_ixyz."""
+ core_wcs_movement = self.phase_offset_angles_core_wcs_movement
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Extract angles.
+ angles_z = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
+ )
+ angles_y = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[1] for wcs in wing_cross_sections]
+ )
+ angles_x = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[2] for wcs in wing_cross_sections]
+ )
+
+ # Verify that phase offset causes non-zero initial values.
+ # With phase offsets, the first values should not all be at the base angles.
+ self.assertFalse(np.allclose(angles_z[0], 0.0, atol=1e-10))
+ self.assertFalse(np.allclose(angles_y[0], 0.0, atol=1e-10))
+ self.assertFalse(np.allclose(angles_x[0], 0.0, atol=1e-10))
+
+ def test_single_dimension_movement_Lp(self):
+ """Test that only one dimension of Lp_Wcsp_Lpp moves."""
+ core_wcs_movement = self.sine_spacing_Lp_core_wcs_movement
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=50, delta_time=0.01
+ )
+
+ # Extract positions.
+ x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
+ y_positions = np.array([wcs.Lp_Wcsp_Lpp[1] for wcs in wing_cross_sections])
+ z_positions = np.array([wcs.Lp_Wcsp_Lpp[2] for wcs in wing_cross_sections])
+
+ # Only x should vary, y and z should be constant.
+ self.assertFalse(np.allclose(x_positions, x_positions[0]))
+ npt.assert_array_equal(y_positions, y_positions[0])
+ npt.assert_array_equal(z_positions, z_positions[0])
+
+ def test_single_dimension_movement_angles(self):
+ """Test that only one dimension of angles_Wcsp_to_Wcs_ixyz moves."""
+ core_wcs_movement = self.sine_spacing_angles_core_wcs_movement
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=50, delta_time=0.01
+ )
+
+ # Extract angles.
+ angles_z = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
+ )
+ angles_y = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[1] for wcs in wing_cross_sections]
+ )
+ angles_x = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[2] for wcs in wing_cross_sections]
+ )
+
+ # Only z should vary, y and x should be constant.
+ self.assertFalse(np.allclose(angles_z, angles_z[0]))
+ npt.assert_array_equal(angles_y, angles_y[0])
+ npt.assert_array_equal(angles_x, angles_x[0])
+
+ def test_boundary_amplitude_angles(self):
+ """Test amplitude at boundary value (180.0 degrees)."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test amplitude at 180.0 works.
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(180.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
+ )
+ self.assertEqual(core_wcs_movement.ampAngles_Wcsp_to_Wcs_ixyz[0], 180.0)
+
+ def test_boundary_phase_values(self):
+ """Test phase at boundary values (-179.9, 0.0, and 180.0)."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Test phase = 0.0 works.
+ core_wcs_movement1 = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ )
+ self.assertEqual(core_wcs_movement1.phaseLp_Wcsp_Lpp[0], 0.0)
+
+ # Test phase = 180.0 works (upper boundary, inclusive).
+ core_wcs_movement2 = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ phaseLp_Wcsp_Lpp=(180.0, 0.0, 0.0),
+ )
+ self.assertEqual(core_wcs_movement2.phaseLp_Wcsp_Lpp[0], 180.0)
+
+ # Test phase = -179.9 works (near lower boundary).
+ core_wcs_movement3 = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ phaseLp_Wcsp_Lpp=(-179.9, 0.0, 0.0),
+ )
+ self.assertEqual(core_wcs_movement3.phaseLp_Wcsp_Lpp[0], -179.9)
+
+ def test_custom_spacing_function_Lp(self):
+ """Test that custom spacing function works for Lp_Wcsp_Lpp."""
+ core_wcs_movement = self.custom_spacing_Lp_core_wcs_movement
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Extract x-positions.
+ x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
+
+ # Verify that values vary (not constant).
+ self.assertFalse(np.allclose(x_positions, x_positions[0]))
+
+ # Verify that values are within expected range.
+ # For custom_harmonic with amp=1.0, values should be in [-1.0, 1.0].
+ self.assertTrue(np.all(x_positions >= -1.1))
+ self.assertTrue(np.all(x_positions <= 1.1))
+
+ def test_custom_spacing_function_angles(self):
+ """Test that custom spacing function works for angles_Wcsp_to_Wcs_ixyz."""
+ core_wcs_movement = self.custom_spacing_angles_core_wcs_movement
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Extract z-angles.
+ angles_z = np.array(
+ [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
+ )
+
+ # Verify that values vary (not constant).
+ self.assertFalse(np.allclose(angles_z, angles_z[0]))
+
+ # Verify that values are within expected range.
+ # For custom_triangle with amp=10.0, values should be in [-10.0, 10.0].
+ self.assertTrue(np.all(angles_z >= -11.0))
+ self.assertTrue(np.all(angles_z <= 11.0))
+
+ def test_custom_spacing_function_mixed_with_standard(self):
+ """Test that custom and standard spacing functions can be mixed."""
+ core_wcs_movement = self.mixed_custom_and_standard_spacing_core_wcs_movement
+ wing_cross_sections = core_wcs_movement.generate_wing_cross_sections(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Verify that WingCrossSections are generated successfully.
+ self.assertEqual(len(wing_cross_sections), 100)
+ for wcs in wing_cross_sections:
+ self.assertIsInstance(wcs, ps.geometry.wing_cross_section.WingCrossSection)
+
+ def test_custom_function_validation_invalid_start_value(self):
+ """Test that custom function with invalid start value raises error."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Define invalid custom function that doesn't start at 0.
+ def invalid_nonzero_start(x):
+ return np.sin(x) + 1.0
+
+ # Should raise error during initialization or generation.
+ with self.assertRaises(ValueError):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=(invalid_nonzero_start, "sine", "sine"),
+ )
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=0.01
+ )
+
+ def test_custom_function_validation_invalid_end_value(self):
+ """Test that custom function with invalid end value raises error."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Define invalid custom function that doesn't return to 0 at 2*pi.
+ def invalid_nonzero_end(x):
+ return np.sin(x) + 0.1
+
+ with self.assertRaises(ValueError):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=(invalid_nonzero_end, "sine", "sine"),
+ )
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=0.01
+ )
+
+ def test_custom_function_validation_invalid_mean(self):
+ """Test that custom function with invalid mean raises error."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Define invalid custom function with non-zero mean.
+ def invalid_nonzero_mean(x):
+ return np.sin(x) + 0.5
+
+ with self.assertRaises(ValueError):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=(invalid_nonzero_mean, "sine", "sine"),
+ )
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=0.01
+ )
+
+ def test_custom_function_validation_invalid_amplitude(self):
+ """Test that custom function with invalid amplitude raises error."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Define invalid custom function with wrong amplitude.
+ def invalid_wrong_amplitude(x):
+ return 2.0 * np.sin(x)
+
+ with self.assertRaises(ValueError):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=(invalid_wrong_amplitude, "sine", "sine"),
+ )
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=0.01
+ )
+
+ def test_custom_function_validation_not_periodic(self):
+ """Test that custom function that is not periodic raises error."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Define invalid custom function that is not periodic.
+ def invalid_not_periodic(x):
+ return np.tanh(x)
+
+ with self.assertRaises(ValueError):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=(invalid_not_periodic, "sine", "sine"),
+ )
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=0.01
+ )
+
+ def test_custom_function_validation_returns_non_finite(self):
+ """Test that custom function returning NaN or Inf raises error."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Define invalid custom function that returns NaN.
+ def invalid_non_finite(x):
+ return np.where(x < np.pi, np.sin(x), np.nan)
+
+ with self.assertRaises(ValueError):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=(invalid_non_finite, "sine", "sine"),
+ )
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=0.01
+ )
+
+ def test_custom_function_validation_wrong_shape(self):
+ """Test that custom function returning wrong shape raises error."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Define invalid custom function that returns wrong shape.
+ def invalid_wrong_shape(x):
+ return np.sin(x)[: len(x) // 2]
+
+ with self.assertRaises(ValueError):
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
+ spacingLp_Wcsp_Lpp=(invalid_wrong_shape, "sine", "sine"),
+ )
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=10, delta_time=0.01
+ )
+
+ def test_unsafe_amplitude_causes_error_Lp(self):
+ """Test that amplitude too high for base Lp value causes error during generation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ # Use root fixture with Lp_Wcsp_Lpp = [0.0, 0.0, 0.0].
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create CoreWingCrossSectionMovement with amplitude that will drive the second element in
+ # Lp_Wcsp_Lpp negative, which is never allowed by WingCrossSection.
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
+ periodLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
+ spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
+ phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
+ )
+
+ # Generating WingCrossSections should raise ValueError when Lp goes negative.
+ with self.assertRaises(ValueError) as context:
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Verify the error message is about Lp_Wcsp_Lpp validation.
+ self.assertIn("Lp_Wcsp_Lpp", str(context.exception))
+
+ def test_unsafe_amplitude_causes_error_angles(self):
+ """Test that amplitude too high for base angle value causes error during generation."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ # Use root fixture with angles = [0.0, 0.0, 0.0].
+ base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
+
+ # Create CoreWingCrossSectionMovement with amplitude that will drive angles out of valid range.
+ # Valid range for angles is (-180, 180], so amplitude 181 with base 0 will exceed.
+ core_wcs_movement = ps._core.CoreWingCrossSectionMovement(
+ base_wing_cross_section=base_wcs,
+ ampAngles_Wcsp_to_Wcs_ixyz=(179.0, 0.0, 0.0),
+ periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
+ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
+ phaseAngles_Wcsp_to_Wcs_ixyz=(90.0, 0.0, 0.0),
+ )
+
+ # Generating WingCrossSections should raise ValueError when angles exceed range.
+ with self.assertRaises(ValueError) as context:
+ core_wcs_movement.generate_wing_cross_sections(
+ num_steps=100, delta_time=0.01
+ )
+
+ # Verify the error message is about angles_Wcsp_to_Wcs_ixyz validation.
+ self.assertIn("angles_Wcsp_to_Wcs_ixyz", str(context.exception))
+
+
+class TestCoreWingCrossSectionMovementImmutability(unittest.TestCase):
+ """Tests for CoreWingCrossSectionMovement attribute immutability."""
+
+ def setUp(self):
+ """Set up test fixtures for immutability tests."""
+ self.core_wing_cross_section_movement = (
+ core_wing_cross_section_movement_fixtures.make_basic_core_wing_cross_section_movement_fixture()
+ )
+
+ def test_immutable_base_wing_cross_section_property(self):
+ """Test that base_wing_cross_section property is read only."""
+ from tests.unit.fixtures import geometry_fixtures
+
+ new_wing_cross_section = (
+ geometry_fixtures.make_root_wing_cross_section_fixture()
+ )
+ with self.assertRaises(AttributeError):
+ self.core_wing_cross_section_movement.base_wing_cross_section = (
+ new_wing_cross_section
+ )
+
+ def test_immutable_ampLp_Wcsp_Lpp_property(self):
+ """Test that ampLp_Wcsp_Lpp property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_cross_section_movement.ampLp_Wcsp_Lpp = np.array(
+ [1.0, 2.0, 3.0]
+ )
+
+ def test_immutable_ampLp_Wcsp_Lpp_array_read_only(self):
+ """Test that ampLp_Wcsp_Lpp array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_cross_section_movement.ampLp_Wcsp_Lpp[0] = 999.0
+
+ def test_immutable_periodLp_Wcsp_Lpp_property(self):
+ """Test that periodLp_Wcsp_Lpp property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_cross_section_movement.periodLp_Wcsp_Lpp = np.array(
+ [1.0, 2.0, 3.0]
+ )
+
+ def test_immutable_periodLp_Wcsp_Lpp_array_read_only(self):
+ """Test that periodLp_Wcsp_Lpp array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_cross_section_movement.periodLp_Wcsp_Lpp[0] = 999.0
+
+ def test_immutable_spacingLp_Wcsp_Lpp_property(self):
+ """Test that spacingLp_Wcsp_Lpp property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_cross_section_movement.spacingLp_Wcsp_Lpp = (
+ "uniform",
+ "uniform",
+ "uniform",
+ )
+
+ def test_immutable_phaseLp_Wcsp_Lpp_property(self):
+ """Test that phaseLp_Wcsp_Lpp property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_cross_section_movement.phaseLp_Wcsp_Lpp = np.array(
+ [45.0, 45.0, 45.0]
+ )
+
+ def test_immutable_phaseLp_Wcsp_Lpp_array_read_only(self):
+ """Test that phaseLp_Wcsp_Lpp array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_cross_section_movement.phaseLp_Wcsp_Lpp[0] = 999.0
+
+ def test_immutable_ampAngles_Wcsp_to_Wcs_ixyz_property(self):
+ """Test that ampAngles_Wcsp_to_Wcs_ixyz property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_cross_section_movement.ampAngles_Wcsp_to_Wcs_ixyz = np.array(
+ [1.0, 2.0, 3.0]
+ )
+
+ def test_immutable_ampAngles_Wcsp_to_Wcs_ixyz_array_read_only(self):
+ """Test that ampAngles_Wcsp_to_Wcs_ixyz array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_cross_section_movement.ampAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
+
+ def test_immutable_periodAngles_Wcsp_to_Wcs_ixyz_property(self):
+ """Test that periodAngles_Wcsp_to_Wcs_ixyz property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_cross_section_movement.periodAngles_Wcsp_to_Wcs_ixyz = (
+ np.array([1.0, 2.0, 3.0])
+ )
+
+ def test_immutable_periodAngles_Wcsp_to_Wcs_ixyz_array_read_only(self):
+ """Test that periodAngles_Wcsp_to_Wcs_ixyz array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_cross_section_movement.periodAngles_Wcsp_to_Wcs_ixyz[0] = (
+ 999.0
+ )
+
+ def test_immutable_spacingAngles_Wcsp_to_Wcs_ixyz_property(self):
+ """Test that spacingAngles_Wcsp_to_Wcs_ixyz property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_cross_section_movement.spacingAngles_Wcsp_to_Wcs_ixyz = (
+ "uniform",
+ "uniform",
+ "uniform",
+ )
+
+ def test_immutable_phaseAngles_Wcsp_to_Wcs_ixyz_property(self):
+ """Test that phaseAngles_Wcsp_to_Wcs_ixyz property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_cross_section_movement.phaseAngles_Wcsp_to_Wcs_ixyz = (
+ np.array([45.0, 45.0, 45.0])
+ )
+
+ def test_immutable_phaseAngles_Wcsp_to_Wcs_ixyz_array_read_only(self):
+ """Test that phaseAngles_Wcsp_to_Wcs_ixyz array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_cross_section_movement.phaseAngles_Wcsp_to_Wcs_ixyz[0] = (
+ 999.0
+ )
+
+
+class TestCoreWingCrossSectionMovementCaching(unittest.TestCase):
+ """Tests for CoreWingCrossSectionMovement caching behavior."""
+
+ def setUp(self):
+ """Set up test fixtures for caching tests."""
+ self.core_wing_cross_section_movement = (
+ core_wing_cross_section_movement_fixtures.make_basic_core_wing_cross_section_movement_fixture()
+ )
+
+ def test_all_periods_caching_returns_same_object(self):
+ """Test that repeated access to all_periods returns the same cached object."""
+ all_periods_1 = self.core_wing_cross_section_movement.all_periods
+ all_periods_2 = self.core_wing_cross_section_movement.all_periods
+ self.assertIs(all_periods_1, all_periods_2)
+
+ def test_max_period_caching_returns_same_value(self):
+ """Test that repeated access to max_period returns the same cached value."""
+ max_period_1 = self.core_wing_cross_section_movement.max_period
+ max_period_2 = self.core_wing_cross_section_movement.max_period
+ # Since floats are immutable, we check equality rather than identity.
+ self.assertEqual(max_period_1, max_period_2)
+
+
+class TestCoreWingCrossSectionMovementDeepcopy(unittest.TestCase):
+ """Tests for CoreWingCrossSectionMovement deepcopy behavior."""
+
+ def setUp(self):
+ """Set up test fixtures for deepcopy tests."""
+ self.core_wing_cross_section_movement = (
+ core_wing_cross_section_movement_fixtures.make_basic_core_wing_cross_section_movement_fixture()
+ )
+
+ def test_deepcopy_returns_new_instance(self):
+ """Test that deepcopy returns a new CoreWingCrossSectionMovement instance."""
+ original = self.core_wing_cross_section_movement
+ copied = copy.deepcopy(original)
+
+ self.assertIsInstance(copied, ps._core.CoreWingCrossSectionMovement)
+ self.assertIsNot(original, copied)
+
+ def test_deepcopy_preserves_attribute_values(self):
+ """Test that deepcopy preserves all attribute values."""
+ original = self.core_wing_cross_section_movement
+ copied = copy.deepcopy(original)
+
+ # Check numpy array attributes.
+ npt.assert_array_equal(copied.ampLp_Wcsp_Lpp, original.ampLp_Wcsp_Lpp)
+ npt.assert_array_equal(copied.periodLp_Wcsp_Lpp, original.periodLp_Wcsp_Lpp)
+ npt.assert_array_equal(copied.phaseLp_Wcsp_Lpp, original.phaseLp_Wcsp_Lpp)
+ npt.assert_array_equal(
+ copied.ampAngles_Wcsp_to_Wcs_ixyz, original.ampAngles_Wcsp_to_Wcs_ixyz
+ )
+ npt.assert_array_equal(
+ copied.periodAngles_Wcsp_to_Wcs_ixyz, original.periodAngles_Wcsp_to_Wcs_ixyz
+ )
+ npt.assert_array_equal(
+ copied.phaseAngles_Wcsp_to_Wcs_ixyz, original.phaseAngles_Wcsp_to_Wcs_ixyz
+ )
+
+ # Check tuple attributes.
+ self.assertEqual(copied.spacingLp_Wcsp_Lpp, original.spacingLp_Wcsp_Lpp)
+ self.assertEqual(
+ copied.spacingAngles_Wcsp_to_Wcs_ixyz,
+ original.spacingAngles_Wcsp_to_Wcs_ixyz,
+ )
+
+ def test_deepcopy_numpy_arrays_are_independent(self):
+ """Test that deepcopied numpy arrays are independent objects."""
+ original = self.core_wing_cross_section_movement
+ copied = copy.deepcopy(original)
+
+ # Verify arrays are different objects.
+ self.assertIsNot(copied.ampLp_Wcsp_Lpp, original.ampLp_Wcsp_Lpp)
+ self.assertIsNot(copied.periodLp_Wcsp_Lpp, original.periodLp_Wcsp_Lpp)
+ self.assertIsNot(copied.phaseLp_Wcsp_Lpp, original.phaseLp_Wcsp_Lpp)
+ self.assertIsNot(
+ copied.ampAngles_Wcsp_to_Wcs_ixyz, original.ampAngles_Wcsp_to_Wcs_ixyz
+ )
+ self.assertIsNot(
+ copied.periodAngles_Wcsp_to_Wcs_ixyz, original.periodAngles_Wcsp_to_Wcs_ixyz
+ )
+ self.assertIsNot(
+ copied.phaseAngles_Wcsp_to_Wcs_ixyz, original.phaseAngles_Wcsp_to_Wcs_ixyz
+ )
+
+ def test_deepcopy_numpy_arrays_cannot_be_modified_in_place(self):
+ """Test that deepcopied numpy arrays raise ValueError on in place modification."""
+ original = self.core_wing_cross_section_movement
+ copied = copy.deepcopy(original)
+
+ # Verify that attempting to modify copied arrays raises ValueError.
+ with self.assertRaises(ValueError):
+ copied.ampLp_Wcsp_Lpp[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.periodLp_Wcsp_Lpp[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.phaseLp_Wcsp_Lpp[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.ampAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.periodAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.phaseAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
+
+ def test_deepcopy_base_wing_cross_section_is_independent(self):
+ """Test that deepcopied base_wing_cross_section is an independent object."""
+ original = self.core_wing_cross_section_movement
+ copied = copy.deepcopy(original)
+
+ # Verify base_wing_cross_section is a different object.
+ self.assertIsNot(
+ copied.base_wing_cross_section, original.base_wing_cross_section
+ )
+
+ # Verify attributes are equal.
+ self.assertEqual(
+ copied.base_wing_cross_section.chord,
+ original.base_wing_cross_section.chord,
+ )
+ npt.assert_array_equal(
+ copied.base_wing_cross_section.Lp_Wcsp_Lpp,
+ original.base_wing_cross_section.Lp_Wcsp_Lpp,
+ )
+ npt.assert_array_equal(
+ copied.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz,
+ original.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz,
+ )
+
+ def test_deepcopy_resets_caches_to_none(self):
+ """Test that deepcopy resets cached derived properties to None."""
+ original = self.core_wing_cross_section_movement
+
+ # Access cached properties to populate caches.
+ _ = original.all_periods
+ _ = original.max_period
+
+ # Verify original caches are populated.
+ self.assertIsNotNone(original._all_periods)
+ self.assertIsNotNone(original._max_period)
+
+ # Deepcopy the object.
+ copied = copy.deepcopy(original)
+
+ # Verify copied caches are reset to None.
+ self.assertIsNone(copied._all_periods)
+ self.assertIsNone(copied._max_period)
+
+ def test_deepcopy_cached_properties_can_be_recomputed(self):
+ """Test that cached properties work correctly after deepcopy."""
+ original = self.core_wing_cross_section_movement
+
+ # Get original cached values.
+ original_all_periods = original.all_periods
+ original_max_period = original.max_period
+
+ # Deepcopy the object.
+ copied = copy.deepcopy(original)
+
+ # Verify cached properties can be computed and match original.
+ self.assertEqual(copied.all_periods, original_all_periods)
+ self.assertEqual(copied.max_period, original_max_period)
+
+ def test_deepcopy_generate_wing_cross_sections_produces_same_results(self):
+ """Test that generate_wing_cross_sections produces same results after deepcopy."""
+ original = self.core_wing_cross_section_movement
+ copied = copy.deepcopy(original)
+
+ num_steps = 50
+ delta_time = 0.01
+
+ original_wcs_list = original.generate_wing_cross_sections(
+ num_steps=num_steps, delta_time=delta_time
+ )
+ copied_wcs_list = copied.generate_wing_cross_sections(
+ num_steps=num_steps, delta_time=delta_time
+ )
+
+ # Verify same number of WingCrossSections.
+ self.assertEqual(len(copied_wcs_list), len(original_wcs_list))
+
+ # Verify each WingCrossSection has matching attributes.
+ for original_wcs, copied_wcs in zip(original_wcs_list, copied_wcs_list):
+ npt.assert_array_equal(copied_wcs.Lp_Wcsp_Lpp, original_wcs.Lp_Wcsp_Lpp)
+ npt.assert_array_equal(
+ copied_wcs.angles_Wcsp_to_Wcs_ixyz, original_wcs.angles_Wcsp_to_Wcs_ixyz
+ )
+ self.assertEqual(copied_wcs.chord, original_wcs.chord)
+
+ def test_deepcopy_handles_memo_correctly(self):
+ """Test that deepcopy handles the memo dict correctly for circular references."""
+ original = self.core_wing_cross_section_movement
+ memo = {}
+
+ # First deepcopy.
+ copied1 = copy.deepcopy(original, memo)
+
+ # Verify original is in memo.
+ self.assertIn(id(original), memo)
+ self.assertIs(memo[id(original)], copied1)
+
+ # Second deepcopy with same memo should return same object.
+ copied2 = copy.deepcopy(original, memo)
+ self.assertIs(copied1, copied2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_core_wing_movement.py b/tests/unit/test_core_wing_movement.py
new file mode 100644
index 000000000..7b8a105b2
--- /dev/null
+++ b/tests/unit/test_core_wing_movement.py
@@ -0,0 +1,1198 @@
+"""This module contains classes to test CoreWingMovements."""
+
+import unittest
+
+import numpy as np
+import numpy.testing as npt
+from scipy import signal
+
+import pterasoftware as ps
+from tests.unit.fixtures import (
+ core_wing_cross_section_movement_fixtures,
+ core_wing_movement_fixtures,
+ geometry_fixtures,
+)
+
+
+class TestCoreWingMovement(unittest.TestCase):
+ """This is a class with functions to test CoreWingMovements."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all CoreWingMovement tests."""
+ # Spacing test fixtures for Ler_Gs_Cgs.
+ cls.sine_spacing_Ler_wing_movement = (
+ core_wing_movement_fixtures.make_sine_spacing_Ler_core_wing_movement_fixture()
+ )
+ cls.uniform_spacing_Ler_wing_movement = (
+ core_wing_movement_fixtures.make_uniform_spacing_Ler_core_wing_movement_fixture()
+ )
+ cls.mixed_spacing_Ler_wing_movement = (
+ core_wing_movement_fixtures.make_mixed_spacing_Ler_core_wing_movement_fixture()
+ )
+
+ # Spacing test fixtures for angles_Gs_to_Wn_ixyz.
+ cls.sine_spacing_angles_wing_movement = (
+ core_wing_movement_fixtures.make_sine_spacing_angles_core_wing_movement_fixture()
+ )
+ cls.uniform_spacing_angles_wing_movement = (
+ core_wing_movement_fixtures.make_uniform_spacing_angles_core_wing_movement_fixture()
+ )
+ cls.mixed_spacing_angles_wing_movement = (
+ core_wing_movement_fixtures.make_mixed_spacing_angles_core_wing_movement_fixture()
+ )
+
+ # Additional test fixtures.
+ cls.static_wing_movement = (
+ core_wing_movement_fixtures.make_static_core_wing_movement_fixture()
+ )
+ cls.basic_wing_movement = (
+ core_wing_movement_fixtures.make_basic_core_wing_movement_fixture()
+ )
+ cls.Ler_only_wing_movement = (
+ core_wing_movement_fixtures.make_Ler_only_core_wing_movement_fixture()
+ )
+ cls.angles_only_wing_movement = (
+ core_wing_movement_fixtures.make_angles_only_core_wing_movement_fixture()
+ )
+ cls.phase_offset_Ler_wing_movement = (
+ core_wing_movement_fixtures.make_phase_offset_Ler_core_wing_movement_fixture()
+ )
+ cls.phase_offset_angles_wing_movement = (
+ core_wing_movement_fixtures.make_phase_offset_angles_core_wing_movement_fixture()
+ )
+ cls.multiple_periods_wing_movement = (
+ core_wing_movement_fixtures.make_multiple_periods_core_wing_movement_fixture()
+ )
+ cls.custom_spacing_Ler_wing_movement = (
+ core_wing_movement_fixtures.make_custom_spacing_Ler_core_wing_movement_fixture()
+ )
+ cls.custom_spacing_angles_wing_movement = (
+ core_wing_movement_fixtures.make_custom_spacing_angles_core_wing_movement_fixture()
+ )
+ cls.rotation_point_offset_wing_movement = (
+ core_wing_movement_fixtures.make_rotation_point_offset_core_wing_movement_fixture()
+ )
+
+ def test_spacing_sine_for_Ler_Gs_Cgs(self):
+ """Test that sine spacing actually produces sinusoidal motion for
+ Ler_Gs_Cgs."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.sine_spacing_Ler_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract x-positions from generated Wings.
+ x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
+
+ # Calculate expected sine wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 0.2 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated positions match the expected sine wave.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_uniform_for_Ler_Gs_Cgs(self):
+ """Test that uniform spacing actually produces triangular wave motion for
+ Ler_Gs_Cgs."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.uniform_spacing_Ler_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract x-positions from generated Wings.
+ x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
+
+ # Calculate expected triangular wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 0.2 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
+
+ # Assert that the generated positions match the expected triangular wave.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_mixed_for_Ler_Gs_Cgs(self):
+ """Test that mixed spacing types work correctly for Ler_Gs_Cgs."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.mixed_spacing_Ler_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract positions from generated Wings.
+ x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
+ y_positions = np.array([wing.Ler_Gs_Cgs[1] for wing in wings])
+ z_positions = np.array([wing.Ler_Gs_Cgs[2] for wing in wings])
+
+ # Calculate expected values for each dimension.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 0.2 * np.sin(2 * np.pi * times / 1.0)
+ expected_y = 0.15 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
+ expected_z = 0.1 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated positions match the expected values.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_sine_for_angles_Gs_to_Wn_ixyz(self):
+ """Test that sine spacing actually produces sinusoidal motion for
+ angles_Gs_to_Wn_ixyz."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.sine_spacing_angles_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract x-angles from generated Wings.
+ x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
+
+ # Calculate expected sine wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 10.0 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated angles match the expected sine wave.
+ npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_uniform_for_angles_Gs_to_Wn_ixyz(self):
+ """Test that uniform spacing actually produces triangular wave motion for
+ angles_Gs_to_Wn_ixyz."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.uniform_spacing_angles_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract x-angles from generated Wings.
+ x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
+
+ # Calculate expected triangular wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 10.0 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
+
+ # Assert that the generated angles match the expected triangular wave.
+ npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_spacing_mixed_for_angles_Gs_to_Wn_ixyz(self):
+ """Test that mixed spacing types work correctly for
+ angles_Gs_to_Wn_ixyz."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.mixed_spacing_angles_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract angles from generated Wings.
+ x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
+ y_angles = np.array([wing.angles_Gs_to_Wn_ixyz[1] for wing in wings])
+ z_angles = np.array([wing.angles_Gs_to_Wn_ixyz[2] for wing in wings])
+
+ # Calculate expected values for each dimension.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 10.0 * np.sin(2 * np.pi * times / 1.0)
+ expected_y = 15.0 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
+ expected_z = 8.0 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that the generated angles match the expected values.
+ npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(y_angles, expected_y, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(z_angles, expected_z, rtol=1e-10, atol=1e-14)
+
+ def test_static_wing_movement_produces_constant_wings(self):
+ """Test that static CoreWingMovement produces Wings with constant parameters."""
+ num_steps = 50
+ delta_time = 0.02
+ wings = self.static_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract parameters from all Wings.
+ Lers_G_Cg = np.array([wing.Ler_Gs_Cgs for wing in wings])
+ angles_Gs_to_Wn_ixyzs = np.array([wing.angles_Gs_to_Wn_ixyz for wing in wings])
+
+ # Assert that all Wings have the same parameters.
+ npt.assert_allclose(
+ Lers_G_Cg,
+ np.tile(wings[0].Ler_Gs_Cgs, (num_steps, 1)),
+ rtol=1e-10,
+ atol=1e-14,
+ )
+ npt.assert_allclose(
+ angles_Gs_to_Wn_ixyzs,
+ np.tile(wings[0].angles_Gs_to_Wn_ixyz, (num_steps, 1)),
+ rtol=1e-10,
+ atol=1e-14,
+ )
+
+ def test_generate_wings_returns_correct_number(self):
+ """Test that generate_wings returns the correct number of Wings."""
+ num_steps = 75
+ delta_time = 0.015
+ wings = self.basic_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ self.assertEqual(len(wings), num_steps)
+
+ def test_generate_wings_preserves_wing_properties(self):
+ """Test that generate_wings preserves non-changing Wing properties."""
+ num_steps = 30
+ delta_time = 0.02
+ wings = self.basic_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Check that all Wings have the same non-changing properties.
+ base_wing = self.basic_wing_movement.base_wing
+ for wing in wings:
+ self.assertEqual(wing.name, base_wing.name)
+ self.assertEqual(wing.symmetric, base_wing.symmetric)
+ self.assertEqual(wing.mirror_only, base_wing.mirror_only)
+ npt.assert_array_equal(wing.symmetryNormal_G, base_wing.symmetryNormal_G)
+ npt.assert_array_equal(
+ wing.symmetryPoint_G_Cg, base_wing.symmetryPoint_G_Cg
+ )
+ self.assertEqual(wing.num_chordwise_panels, base_wing.num_chordwise_panels)
+ self.assertEqual(wing.chordwise_spacing, base_wing.chordwise_spacing)
+ self.assertEqual(
+ len(wing.wing_cross_sections), len(base_wing.wing_cross_sections)
+ )
+
+ def test_phase_offset_Ler_produces_shifted_motion(self):
+ """Test that phase offset for Ler_Gs_Cgs produces phase-shifted
+ motion."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.phase_offset_Ler_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract positions from generated Wings.
+ x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
+ y_positions = np.array([wing.Ler_Gs_Cgs[1] for wing in wings])
+ z_positions = np.array([wing.Ler_Gs_Cgs[2] for wing in wings])
+
+ # Calculate expected phase-shifted sine waves.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 0.1 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(90.0))
+ expected_y = 0.08 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(-45.0))
+ expected_z = 0.06 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(60.0))
+
+ # Assert that the generated positions match the expected phase-shifted waves.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
+
+ def test_phase_offset_angles_produces_shifted_motion(self):
+ """Test that phase offset for angles_Gs_to_Wn_ixyz produces
+ phase-shifted motion."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.phase_offset_angles_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract angles from generated Wings.
+ x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
+ y_angles = np.array([wing.angles_Gs_to_Wn_ixyz[1] for wing in wings])
+ z_angles = np.array([wing.angles_Gs_to_Wn_ixyz[2] for wing in wings])
+
+ # Calculate expected phase-shifted sine waves.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 10.0 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(45.0))
+ expected_y = 12.0 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(90.0))
+ expected_z = 8.0 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(-30.0))
+
+ # Assert that the generated angles match the expected phase-shifted waves.
+ npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(y_angles, expected_y, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(z_angles, expected_z, rtol=1e-10, atol=1e-14)
+
+ def test_max_period_static_movement(self):
+ """Test that max_period returns 0.0 for static CoreWingMovement."""
+ max_period = self.static_wing_movement.max_period
+ self.assertEqual(max_period, 0.0)
+
+ def test_max_period_Ler_only_movement(self):
+ """Test that max_period correctly identifies the maximum period for
+ Ler-only CoreWingMovement."""
+ max_period = self.Ler_only_wing_movement.max_period
+ self.assertEqual(max_period, 1.5)
+
+ def test_max_period_angles_only_movement(self):
+ """Test that max_period correctly identifies the maximum period for
+ angles-only CoreWingMovement."""
+ max_period = self.angles_only_wing_movement.max_period
+ self.assertEqual(max_period, 1.5)
+
+ def test_max_period_multiple_periods_movement(self):
+ """Test that max_period correctly identifies the maximum period when
+ different dimensions have different periods."""
+ max_period = self.multiple_periods_wing_movement.max_period
+
+ # The maximum should be from either the CoreWingMovement's own motion or from
+ # WingCrossSectionMovements.
+ expected_max = max(3.0, 2.5, 2.0)
+ self.assertEqual(max_period, expected_max)
+
+ def test_initialization_with_valid_parameters(self):
+ """Test CoreWingMovement initialization with valid parameters."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ wing_movement = ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.1, 0.05, 0.02),
+ periodLer_Gs_Cgs=(1.0, 1.0, 1.0),
+ spacingLer_Gs_Cgs=("sine", "uniform", "sine"),
+ phaseLer_Gs_Cgs=(0.0, 45.0, -30.0),
+ ampAngles_Gs_to_Wn_ixyz=(5.0, 3.0, 2.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 1.0),
+ spacingAngles_Gs_to_Wn_ixyz=("uniform", "sine", "uniform"),
+ phaseAngles_Gs_to_Wn_ixyz=(30.0, 0.0, -45.0),
+ )
+
+ self.assertIsInstance(wing_movement, ps._core.CoreWingMovement)
+ self.assertEqual(wing_movement.base_wing, base_wing)
+ self.assertEqual(
+ len(wing_movement.wing_cross_section_movements),
+ len(base_wing.wing_cross_sections),
+ )
+ npt.assert_array_equal(wing_movement.ampLer_Gs_Cgs, np.array([0.1, 0.05, 0.02]))
+ npt.assert_array_equal(
+ wing_movement.periodLer_Gs_Cgs, np.array([1.0, 1.0, 1.0])
+ )
+ self.assertEqual(wing_movement.spacingLer_Gs_Cgs, ("sine", "uniform", "sine"))
+ npt.assert_array_equal(
+ wing_movement.phaseLer_Gs_Cgs, np.array([0.0, 45.0, -30.0])
+ )
+ npt.assert_array_equal(
+ wing_movement.ampAngles_Gs_to_Wn_ixyz, np.array([5.0, 3.0, 2.0])
+ )
+ npt.assert_array_equal(
+ wing_movement.periodAngles_Gs_to_Wn_ixyz, np.array([1.0, 1.0, 1.0])
+ )
+ self.assertEqual(
+ wing_movement.spacingAngles_Gs_to_Wn_ixyz,
+ ("uniform", "sine", "uniform"),
+ )
+ npt.assert_array_equal(
+ wing_movement.phaseAngles_Gs_to_Wn_ixyz, np.array([30.0, 0.0, -45.0])
+ )
+
+ def test_initialization_invalid_base_wing(self):
+ """Test that CoreWingMovement initialization fails with invalid base_wing."""
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ ]
+
+ with self.assertRaises(TypeError):
+ ps._core.CoreWingMovement(
+ base_wing="not_a_wing",
+ wing_cross_section_movements=wcs_movements,
+ )
+
+ def test_initialization_invalid_wing_cross_section_movements_type(self):
+ """Test that CoreWingMovement initialization fails with invalid
+ wing_cross_section_movements type."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+
+ with self.assertRaises(TypeError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements="not_a_list",
+ )
+
+ def test_initialization_invalid_wing_cross_section_movements_length(self):
+ """Test that CoreWingMovement initialization fails when
+ wing_cross_section_movements length doesn't match base_wing."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ ]
+
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ )
+
+ def test_initialization_ampLer_Gs_Cgs_validation(self):
+ """Test ampLer_Gs_Cgs parameter validation."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ # Test with negative amplitude.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(-0.1, 0.0, 0.0),
+ periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
+ )
+
+ def test_initialization_periodLer_Gs_Cgs_validation(self):
+ """Test periodLer_Gs_Cgs parameter validation."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ # Test with zero amplitude but non-zero period.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
+ )
+
+ def test_initialization_phaseLer_Gs_Cgs_validation(self):
+ """Test phaseLer_Gs_Cgs parameter validation."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ # Test with phase out of valid range.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.1, 0.0, 0.0),
+ periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
+ phaseLer_Gs_Cgs=(181.0, 0.0, 0.0),
+ )
+
+ # Test with zero amplitude but non-zero phase.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
+ phaseLer_Gs_Cgs=(45.0, 0.0, 0.0),
+ )
+
+ def test_initialization_ampAngles_Gs_to_Wn_ixyz_validation(self):
+ """Test ampAngles_Gs_to_Wn_ixyz parameter validation."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ # Test with amplitude > 180 degrees.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampAngles_Gs_to_Wn_ixyz=(180.1, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ )
+
+ # Test with negative amplitude.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampAngles_Gs_to_Wn_ixyz=(-10.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ )
+
+ def test_initialization_periodAngles_Gs_to_Wn_ixyz_validation(self):
+ """Test periodAngles_Gs_to_Wn_ixyz parameter validation."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ # Test with zero amplitude but non-zero period.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ )
+
+ def test_initialization_phaseAngles_Gs_to_Wn_ixyz_validation(self):
+ """Test phaseAngles_Gs_to_Wn_ixyz parameter validation."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ # Test with phase out of valid range.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ phaseAngles_Gs_to_Wn_ixyz=(181.0, 0.0, 0.0),
+ )
+
+ # Test with zero amplitude but non-zero phase.
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
+ phaseAngles_Gs_to_Wn_ixyz=(45.0, 0.0, 0.0),
+ )
+
+ def test_custom_spacing_Ler_produces_expected_motion(self):
+ """Test that custom spacing function for Ler_Gs_Cgs produces
+ expected motion."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.custom_spacing_Ler_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract x-positions from generated Wings.
+ x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
+
+ # Calculate expected custom harmonic wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ x_rad = 2 * np.pi * times / 1.0
+ expected_x = (
+ 0.15
+ * (3.0 / (2.0 * np.sqrt(2.0)))
+ * (np.sin(x_rad) + (1.0 / 3.0) * np.sin(3.0 * x_rad))
+ )
+
+ # Assert that the generated positions match the expected custom wave.
+ npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_custom_spacing_angles_produces_expected_motion(self):
+ """Test that custom spacing function for angles_Gs_to_Wn_ixyz
+ produces expected motion."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.custom_spacing_angles_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract x-angles from generated Wings.
+ x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
+
+ # Calculate expected custom harmonic wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ x_rad = 2 * np.pi * times / 1.0
+ expected_x = (
+ 10.0
+ * (3.0 / (2.0 * np.sqrt(2.0)))
+ * (np.sin(x_rad) + (1.0 / 3.0) * np.sin(3.0 * x_rad))
+ )
+
+ # Assert that the generated angles match the expected custom wave.
+ npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_rotation_point_offset_zero_matches_default(self):
+ """Test that zero rotation point offset produces identical results to default
+ behavior."""
+ num_steps = 50
+ delta_time = 0.02
+
+ # Create two CoreWingMovements: one with explicit zero offset, one without.
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ]
+
+ movement_default = ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ )
+
+ movement_zero_offset = ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=[
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
+ ],
+ ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
+ periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
+ rotationPointOffset_Gs_Ler=(0.0, 0.0, 0.0),
+ )
+
+ wings_default = movement_default.generate_wings(num_steps, delta_time)
+ wings_zero = movement_zero_offset.generate_wings(num_steps, delta_time)
+
+ for i in range(num_steps):
+ npt.assert_allclose(
+ wings_default[i].Ler_Gs_Cgs,
+ wings_zero[i].Ler_Gs_Cgs,
+ rtol=1e-10,
+ atol=1e-14,
+ )
+ npt.assert_allclose(
+ wings_default[i].angles_Gs_to_Wn_ixyz,
+ wings_zero[i].angles_Gs_to_Wn_ixyz,
+ rtol=1e-10,
+ atol=1e-14,
+ )
+
+ def test_rotation_point_offset_produces_position_adjustment(self):
+ """Test that non zero rotation point offset causes position changes when
+ angles oscillate."""
+ num_steps = 100
+ delta_time = 0.01
+ wings = self.rotation_point_offset_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # With rotation about a point offset in y (0.5m in y direction from Ler),
+ # z positions should vary when rotating about x axis.
+ # The offset is perpendicular to the rotation axis, so position changes occur.
+ z_positions = np.array([wing.Ler_Gs_Cgs[2] for wing in wings])
+ y_positions = np.array([wing.Ler_Gs_Cgs[1] for wing in wings])
+
+ # Verify that z positions are not all zero (they should oscillate).
+ self.assertFalse(np.allclose(z_positions, 0.0))
+
+ # For rotation about x axis with offset P = (0, 0.5, 0), the position
+ # adjustment is (I - R) @ P where R is the rotation matrix about x.
+ # The active rotation matrix for angle theta about x is:
+ # R = [[1, 0, 0], [0, cos(theta), -sin(theta)], [0, sin(theta), cos(theta)]]
+ # So (I - R) @ [0, 0.5, 0] = [0, 0.5*(1 - cos(theta)), -0.5*sin(theta)]
+ # Thus y_adj = 0.5*(1 - cos(theta)) and z_adj = -0.5*sin(theta)
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ angles_x_rad = np.deg2rad(10.0 * np.sin(2 * np.pi * times / 1.0))
+ expected_y = 0.5 * (1.0 - np.cos(angles_x_rad))
+ expected_z = -0.5 * np.sin(angles_x_rad)
+
+ npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
+ npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
+
+ def test_rotation_point_offset_preserves_angles(self):
+ """Test that rotation point offset does not affect the Wing angles."""
+ num_steps = 50
+ delta_time = 0.02
+ wings = self.rotation_point_offset_wing_movement.generate_wings(
+ num_steps=num_steps,
+ delta_time=delta_time,
+ )
+
+ # Extract angles from generated Wings.
+ x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
+
+ # Calculate expected sine wave values.
+ times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
+ expected_x = 10.0 * np.sin(2 * np.pi * times / 1.0)
+
+ # Assert that angles are unaffected by rotation point offset.
+ npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
+
+ def test_rotation_point_offset_initialization(self):
+ """Test that rotationPointOffset_Gs_Ler is correctly initialized."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ wing_movement = ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ rotationPointOffset_Gs_Ler=(0.25, 0.1, -0.05),
+ )
+
+ npt.assert_array_equal(
+ wing_movement.rotationPointOffset_Gs_Ler, np.array([0.25, 0.1, -0.05])
+ )
+
+ def test_rotation_point_offset_validation_wrong_size(self):
+ """Test that invalid rotationPointOffset_Gs_Ler size raises error."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ with self.assertRaises(ValueError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ rotationPointOffset_Gs_Ler=(0.1, 0.2),
+ )
+
+ def test_rotation_point_offset_validation_non_numeric(self):
+ """Test that non numeric rotationPointOffset_Gs_Ler raises error."""
+ base_wing = geometry_fixtures.make_type_1_wing_fixture()
+ wcs_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ for _ in base_wing.wing_cross_sections
+ ]
+
+ with self.assertRaises(TypeError):
+ ps._core.CoreWingMovement(
+ base_wing=base_wing,
+ wing_cross_section_movements=wcs_movements,
+ rotationPointOffset_Gs_Ler=("a", "b", "c"),
+ )
+
+ def test_all_periods_static_movement(self):
+ """Test that all_periods returns empty tuple for static CoreWingMovement."""
+ wing_movement = self.static_wing_movement
+ self.assertEqual(wing_movement.all_periods, ())
+
+ def test_all_periods_Ler_only(self):
+ """Test that all_periods returns correct periods for Ler only movement."""
+ wing_movement = self.Ler_only_wing_movement
+ # periodLer_Gs_Cgs is (1.5, 1.5, 1.5), all non zero.
+ # periodAngles_Gs_to_Wn_ixyz is (0.0, 0.0, 0.0).
+ # WingCrossSectionMovements are static (all zeros).
+ # Should return tuple with three 1.5 values.
+ self.assertEqual(wing_movement.all_periods, (1.5, 1.5, 1.5))
+
+ def test_all_periods_angles_only(self):
+ """Test that all_periods returns correct periods for angles only movement."""
+ wing_movement = self.angles_only_wing_movement
+ # periodLer_Gs_Cgs is (0.0, 0.0, 0.0).
+ # periodAngles_Gs_to_Wn_ixyz is (1.5, 1.5, 1.5), all non zero.
+ # WingCrossSectionMovements are static (all zeros).
+ # Should return tuple with three 1.5 values.
+ self.assertEqual(wing_movement.all_periods, (1.5, 1.5, 1.5))
+
+ def test_all_periods_mixed(self):
+ """Test that all_periods returns all non zero periods for mixed movement."""
+ wing_movement = self.multiple_periods_wing_movement
+ # periodLer_Gs_Cgs is (1.0, 2.0, 3.0).
+ # periodAngles_Gs_to_Wn_ixyz is (0.5, 1.5, 2.5).
+ # WingCrossSectionMovements include one with multiple periods.
+ # Should return tuple with all non zero values from WingCrossSectionMovements first,
+ # then CoreWingMovement's own periods.
+ all_periods = wing_movement.all_periods
+
+ # Verify CoreWingMovement's own periods are included.
+ self.assertIn(1.0, all_periods)
+ self.assertIn(2.0, all_periods)
+ self.assertIn(3.0, all_periods)
+ self.assertIn(0.5, all_periods)
+ self.assertIn(1.5, all_periods)
+ self.assertIn(2.5, all_periods)
+
+ def test_all_periods_contains_duplicates(self):
+ """Test that all_periods contains duplicate periods if they appear multiple
+ times.
+ """
+ wing_movement = self.basic_wing_movement
+ all_periods = wing_movement.all_periods
+
+ # Both periodLer_Gs_Cgs and periodAngles_Gs_to_Wn_ixyz are (2.0, 2.0, 2.0).
+ # This contributes six 2.0 values. Plus WingCrossSectionMovement periods.
+ # Count how many times 2.0 appears (should be at least 6 from CoreWingMovement).
+ count_2_0 = all_periods.count(2.0)
+ self.assertGreaterEqual(count_2_0, 6)
+
+ def test_all_periods_partial_movement(self):
+ """Test all_periods with only some dimensions having non zero periods."""
+ wing_movement = self.sine_spacing_Ler_wing_movement
+ # periodLer_Gs_Cgs is (1.0, 0.0, 0.0), only first element is non zero.
+ # periodAngles_Gs_to_Wn_ixyz is (0.0, 0.0, 0.0).
+ # WingCrossSectionMovements are static.
+ # Should return tuple with one 1.0 value.
+ self.assertEqual(wing_movement.all_periods, (1.0,))
+
+
+class TestCoreWingMovementImmutability(unittest.TestCase):
+ """Tests for CoreWingMovement attribute immutability."""
+
+ def setUp(self):
+ """Set up test fixtures for immutability tests."""
+ self.core_wing_movement = (
+ core_wing_movement_fixtures.make_basic_core_wing_movement_fixture()
+ )
+
+ def test_immutable_base_wing_property(self):
+ """Test that base_wing property is read only."""
+ new_wing = geometry_fixtures.make_type_1_wing_fixture()
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.base_wing = new_wing
+
+ def test_immutable_wing_cross_section_movements_property(self):
+ """Test that wing_cross_section_movements property is read only."""
+ new_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture()
+ ]
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.wing_cross_section_movements = new_movements
+
+ def test_immutable_ampLer_Gs_Cgs_property(self):
+ """Test that ampLer_Gs_Cgs property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.ampLer_Gs_Cgs = np.array([1.0, 2.0, 3.0])
+
+ def test_immutable_ampLer_Gs_Cgs_array_read_only(self):
+ """Test that ampLer_Gs_Cgs array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_movement.ampLer_Gs_Cgs[0] = 999.0
+
+ def test_immutable_periodLer_Gs_Cgs_property(self):
+ """Test that periodLer_Gs_Cgs property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.periodLer_Gs_Cgs = np.array([1.0, 2.0, 3.0])
+
+ def test_immutable_periodLer_Gs_Cgs_array_read_only(self):
+ """Test that periodLer_Gs_Cgs array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_movement.periodLer_Gs_Cgs[0] = 999.0
+
+ def test_immutable_spacingLer_Gs_Cgs_property(self):
+ """Test that spacingLer_Gs_Cgs property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.spacingLer_Gs_Cgs = (
+ "uniform",
+ "uniform",
+ "uniform",
+ )
+
+ def test_immutable_phaseLer_Gs_Cgs_property(self):
+ """Test that phaseLer_Gs_Cgs property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.phaseLer_Gs_Cgs = np.array([45.0, 45.0, 45.0])
+
+ def test_immutable_phaseLer_Gs_Cgs_array_read_only(self):
+ """Test that phaseLer_Gs_Cgs array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_movement.phaseLer_Gs_Cgs[0] = 999.0
+
+ def test_immutable_ampAngles_Gs_to_Wn_ixyz_property(self):
+ """Test that ampAngles_Gs_to_Wn_ixyz property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.ampAngles_Gs_to_Wn_ixyz = np.array([1.0, 2.0, 3.0])
+
+ def test_immutable_ampAngles_Gs_to_Wn_ixyz_array_read_only(self):
+ """Test that ampAngles_Gs_to_Wn_ixyz array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_movement.ampAngles_Gs_to_Wn_ixyz[0] = 999.0
+
+ def test_immutable_periodAngles_Gs_to_Wn_ixyz_property(self):
+ """Test that periodAngles_Gs_to_Wn_ixyz property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.periodAngles_Gs_to_Wn_ixyz = np.array(
+ [1.0, 2.0, 3.0]
+ )
+
+ def test_immutable_periodAngles_Gs_to_Wn_ixyz_array_read_only(self):
+ """Test that periodAngles_Gs_to_Wn_ixyz array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_movement.periodAngles_Gs_to_Wn_ixyz[0] = 999.0
+
+ def test_immutable_spacingAngles_Gs_to_Wn_ixyz_property(self):
+ """Test that spacingAngles_Gs_to_Wn_ixyz property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.spacingAngles_Gs_to_Wn_ixyz = (
+ "uniform",
+ "uniform",
+ "uniform",
+ )
+
+ def test_immutable_phaseAngles_Gs_to_Wn_ixyz_property(self):
+ """Test that phaseAngles_Gs_to_Wn_ixyz property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.phaseAngles_Gs_to_Wn_ixyz = np.array(
+ [45.0, 45.0, 45.0]
+ )
+
+ def test_immutable_phaseAngles_Gs_to_Wn_ixyz_array_read_only(self):
+ """Test that phaseAngles_Gs_to_Wn_ixyz array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_movement.phaseAngles_Gs_to_Wn_ixyz[0] = 999.0
+
+ def test_immutable_rotationPointOffset_Gs_Ler_property(self):
+ """Test that rotationPointOffset_Gs_Ler property is read only."""
+ with self.assertRaises(AttributeError):
+ self.core_wing_movement.rotationPointOffset_Gs_Ler = np.array(
+ [1.0, 2.0, 3.0]
+ )
+
+ def test_immutable_rotationPointOffset_Gs_Ler_array_read_only(self):
+ """Test that rotationPointOffset_Gs_Ler array cannot be modified in place."""
+ with self.assertRaises(ValueError):
+ self.core_wing_movement.rotationPointOffset_Gs_Ler[0] = 999.0
+
+
+class TestCoreWingMovementCaching(unittest.TestCase):
+ """Tests for CoreWingMovement caching behavior."""
+
+ def setUp(self):
+ """Set up test fixtures for caching tests."""
+ self.core_wing_movement = (
+ core_wing_movement_fixtures.make_basic_core_wing_movement_fixture()
+ )
+
+ def test_all_periods_caching_returns_same_object(self):
+ """Test that repeated access to all_periods returns the same cached object."""
+ all_periods_1 = self.core_wing_movement.all_periods
+ all_periods_2 = self.core_wing_movement.all_periods
+ self.assertIs(all_periods_1, all_periods_2)
+
+ def test_max_period_caching_returns_same_value(self):
+ """Test that repeated access to max_period returns the same cached value."""
+ max_period_1 = self.core_wing_movement.max_period
+ max_period_2 = self.core_wing_movement.max_period
+ # Since floats are immutable, we check equality rather than identity.
+ self.assertEqual(max_period_1, max_period_2)
+
+
+class TestCoreWingMovementDeepcopy(unittest.TestCase):
+ """Tests for CoreWingMovement deepcopy behavior."""
+
+ def setUp(self):
+ """Set up test fixtures for deepcopy tests."""
+ self.core_wing_movement = (
+ core_wing_movement_fixtures.make_basic_core_wing_movement_fixture()
+ )
+
+ def test_deepcopy_returns_new_instance(self):
+ """Test that deepcopy returns a new CoreWingMovement instance."""
+ import copy
+
+ original = self.core_wing_movement
+ copied = copy.deepcopy(original)
+
+ self.assertIsInstance(copied, ps._core.CoreWingMovement)
+ self.assertIsNot(original, copied)
+
+ def test_deepcopy_preserves_attribute_values(self):
+ """Test that deepcopy preserves all attribute values."""
+ import copy
+
+ original = self.core_wing_movement
+ copied = copy.deepcopy(original)
+
+ # Check numpy array attributes.
+ npt.assert_array_equal(copied.ampLer_Gs_Cgs, original.ampLer_Gs_Cgs)
+ npt.assert_array_equal(copied.periodLer_Gs_Cgs, original.periodLer_Gs_Cgs)
+ npt.assert_array_equal(copied.phaseLer_Gs_Cgs, original.phaseLer_Gs_Cgs)
+ npt.assert_array_equal(
+ copied.ampAngles_Gs_to_Wn_ixyz, original.ampAngles_Gs_to_Wn_ixyz
+ )
+ npt.assert_array_equal(
+ copied.periodAngles_Gs_to_Wn_ixyz, original.periodAngles_Gs_to_Wn_ixyz
+ )
+ npt.assert_array_equal(
+ copied.phaseAngles_Gs_to_Wn_ixyz, original.phaseAngles_Gs_to_Wn_ixyz
+ )
+ npt.assert_array_equal(
+ copied.rotationPointOffset_Gs_Ler, original.rotationPointOffset_Gs_Ler
+ )
+
+ # Check tuple attributes.
+ self.assertEqual(copied.spacingLer_Gs_Cgs, original.spacingLer_Gs_Cgs)
+ self.assertEqual(
+ copied.spacingAngles_Gs_to_Wn_ixyz,
+ original.spacingAngles_Gs_to_Wn_ixyz,
+ )
+
+ def test_deepcopy_numpy_arrays_are_independent(self):
+ """Test that deepcopied numpy arrays are independent objects."""
+ import copy
+
+ original = self.core_wing_movement
+ copied = copy.deepcopy(original)
+
+ # Verify arrays are different objects.
+ self.assertIsNot(copied.ampLer_Gs_Cgs, original.ampLer_Gs_Cgs)
+ self.assertIsNot(copied.periodLer_Gs_Cgs, original.periodLer_Gs_Cgs)
+ self.assertIsNot(copied.phaseLer_Gs_Cgs, original.phaseLer_Gs_Cgs)
+ self.assertIsNot(
+ copied.ampAngles_Gs_to_Wn_ixyz, original.ampAngles_Gs_to_Wn_ixyz
+ )
+ self.assertIsNot(
+ copied.periodAngles_Gs_to_Wn_ixyz, original.periodAngles_Gs_to_Wn_ixyz
+ )
+ self.assertIsNot(
+ copied.phaseAngles_Gs_to_Wn_ixyz, original.phaseAngles_Gs_to_Wn_ixyz
+ )
+ self.assertIsNot(
+ copied.rotationPointOffset_Gs_Ler, original.rotationPointOffset_Gs_Ler
+ )
+
+ def test_deepcopy_numpy_arrays_cannot_be_modified_in_place(self):
+ """Test that deepcopied numpy arrays raise ValueError on in place modification."""
+ import copy
+
+ original = self.core_wing_movement
+ copied = copy.deepcopy(original)
+
+ # Verify that attempting to modify copied arrays raises ValueError.
+ with self.assertRaises(ValueError):
+ copied.ampLer_Gs_Cgs[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.periodLer_Gs_Cgs[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.phaseLer_Gs_Cgs[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.ampAngles_Gs_to_Wn_ixyz[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.periodAngles_Gs_to_Wn_ixyz[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.phaseAngles_Gs_to_Wn_ixyz[0] = 999.0
+
+ with self.assertRaises(ValueError):
+ copied.rotationPointOffset_Gs_Ler[0] = 999.0
+
+ def test_deepcopy_base_wing_is_independent(self):
+ """Test that deepcopied base_wing is an independent object."""
+ import copy
+
+ original = self.core_wing_movement
+ copied = copy.deepcopy(original)
+
+ # Verify base_wing is a different object.
+ self.assertIsNot(copied.base_wing, original.base_wing)
+
+ # Verify attributes are equal.
+ self.assertEqual(copied.base_wing.name, original.base_wing.name)
+ npt.assert_array_equal(
+ copied.base_wing.Ler_Gs_Cgs, original.base_wing.Ler_Gs_Cgs
+ )
+ npt.assert_array_equal(
+ copied.base_wing.angles_Gs_to_Wn_ixyz,
+ original.base_wing.angles_Gs_to_Wn_ixyz,
+ )
+
+ def test_deepcopy_wing_cross_section_movements_are_independent(self):
+ """Test that deepcopied wing_cross_section_movements are independent objects."""
+ import copy
+
+ original = self.core_wing_movement
+ copied = copy.deepcopy(original)
+
+ # Verify wing_cross_section_movements tuple is different.
+ self.assertIsNot(
+ copied.wing_cross_section_movements, original.wing_cross_section_movements
+ )
+
+ # Verify each WingCrossSectionMovement is a different object.
+ for i in range(len(original.wing_cross_section_movements)):
+ self.assertIsNot(
+ copied.wing_cross_section_movements[i],
+ original.wing_cross_section_movements[i],
+ )
+
+ def test_deepcopy_resets_caches_to_none(self):
+ """Test that deepcopy resets cached derived properties to None."""
+ import copy
+
+ original = self.core_wing_movement
+
+ # Access cached properties to populate caches.
+ _ = original.all_periods
+ _ = original.max_period
+
+ # Verify original caches are populated.
+ self.assertIsNotNone(original._all_periods)
+ self.assertIsNotNone(original._max_period)
+
+ # Deepcopy the object.
+ copied = copy.deepcopy(original)
+
+ # Verify copied caches are reset to None.
+ self.assertIsNone(copied._all_periods)
+ self.assertIsNone(copied._max_period)
+
+ def test_deepcopy_cached_properties_can_be_recomputed(self):
+ """Test that cached properties work correctly after deepcopy."""
+ import copy
+
+ original = self.core_wing_movement
+
+ # Get original cached values.
+ original_all_periods = original.all_periods
+ original_max_period = original.max_period
+
+ # Deepcopy the object.
+ copied = copy.deepcopy(original)
+
+ # Verify cached properties can be computed and match original.
+ self.assertEqual(copied.all_periods, original_all_periods)
+ self.assertEqual(copied.max_period, original_max_period)
+
+ def test_deepcopy_generate_wings_produces_same_results(self):
+ """Test that generate_wings produces same results after deepcopy."""
+ import copy
+
+ original = self.core_wing_movement
+ copied = copy.deepcopy(original)
+
+ num_steps = 50
+ delta_time = 0.01
+
+ original_wings = original.generate_wings(
+ num_steps=num_steps, delta_time=delta_time
+ )
+ copied_wings = copied.generate_wings(num_steps=num_steps, delta_time=delta_time)
+
+ # Verify same number of Wings.
+ self.assertEqual(len(copied_wings), len(original_wings))
+
+ # Verify each Wing has matching attributes.
+ for original_wing, copied_wing in zip(original_wings, copied_wings):
+ npt.assert_array_equal(copied_wing.Ler_Gs_Cgs, original_wing.Ler_Gs_Cgs)
+ npt.assert_array_equal(
+ copied_wing.angles_Gs_to_Wn_ixyz, original_wing.angles_Gs_to_Wn_ixyz
+ )
+ self.assertEqual(copied_wing.name, original_wing.name)
+
+ def test_deepcopy_handles_memo_correctly(self):
+ """Test that deepcopy handles the memo dict correctly for circular references."""
+ import copy
+
+ original = self.core_wing_movement
+ memo = {}
+
+ # First deepcopy.
+ copied1 = copy.deepcopy(original, memo)
+
+ # Verify original is in memo.
+ self.assertIn(id(original), memo)
+ self.assertIs(memo[id(original)], copied1)
+
+ # Second deepcopy with same memo should return same object.
+ copied2 = copy.deepcopy(original, memo)
+ self.assertIs(copied1, copied2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_movement.py b/tests/unit/test_movement.py
index 365b8719a..fb77ab136 100644
--- a/tests/unit/test_movement.py
+++ b/tests/unit/test_movement.py
@@ -326,314 +326,6 @@ def test_num_steps_validation(self):
num_steps=invalid_value,
)
- def test_static_property_for_static_movement(self):
- """Test that static property returns True for static Movement."""
- movement = self.static_movement
- self.assertTrue(movement.static)
-
- def test_static_property_for_non_static_movement(self):
- """Test that static property returns False for non static Movement."""
- movement = self.basic_movement
- self.assertFalse(movement.static)
-
- def test_max_period_for_static_movement(self):
- """Test that max_period returns 0.0 for static Movement."""
- movement = self.static_movement
- self.assertEqual(movement.max_period, 0.0)
-
- def test_max_period_for_non_static_movement(self):
- """Test that max_period returns correct value for non static Movement."""
- movement = self.basic_movement
- # The basic_movement has period of 2.0 for all motion.
- self.assertEqual(movement.max_period, 2.0)
-
- def test_lcm_period_for_static_movement(self):
- """Test that lcm_period returns 0.0 for static Movement."""
- movement = self.static_movement
- self.assertEqual(movement.lcm_period, 0.0)
-
- def test_lcm_period_for_single_period_movement(self):
- """Test that lcm_period returns correct value when all periods are the same."""
- movement = self.basic_movement
- # The basic_movement has period of 2.0 for all motion.
- # LCM of identical periods should equal that period.
- self.assertEqual(movement.lcm_period, 2.0)
-
- def test_lcm_period_with_multiple_wings_same_airplane(self):
- """Test that lcm_period collects all periods, not just max from each
- AirplaneMovement.
-
- This test creates a single Airplane with two Wings having different periods
- (3.0 s and 4.0 s). The correct LCM is 12.0 s. If the implementation only uses
- max_period from the AirplaneMovement, lcm_period would incorrectly return 4.0 s
- instead of 12.0 s.
- """
- # Create two Wings for the same Airplane.
- base_wing_1 = geometry_fixtures.make_simple_tapered_wing_fixture()
- base_wing_2 = geometry_fixtures.make_simple_tapered_wing_fixture()
-
- base_airplane = ps.geometry.airplane.Airplane(
- wings=[base_wing_1, base_wing_2],
- name="Test Airplane",
- Cg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
-
- # Wing_1: tip WingCrossSectionMovement has period 3.0 s.
- wcs_movements_wing_1 = [
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_1.wing_cross_sections[0],
- periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ),
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_1.wing_cross_sections[1],
- periodLp_Wcsp_Lpp=(3.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
- ),
- ]
-
- wing_movement_1 = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing_1,
- wing_cross_section_movements=wcs_movements_wing_1,
- )
-
- # Wing_2: tip WingCrossSectionMovement has period 4.0 s.
- wcs_movements_wing_2 = [
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_2.wing_cross_sections[0],
- periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ),
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_2.wing_cross_sections[1],
- periodLp_Wcsp_Lpp=(4.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
- ),
- ]
-
- wing_movement_2 = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing_2,
- wing_cross_section_movements=wcs_movements_wing_2,
- )
-
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=[wing_movement_1, wing_movement_2],
- )
-
- operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=operating_point_fixtures.make_basic_operating_point_fixture()
- )
-
- # Use num_steps=1 instead of num_cycles=1 to speed up this test. The lcm_period
- # property is calculated from the Movement parameters (periods), not from the
- # generated Airplanes, so we only need to generate one Airplane to test the
- # period calculation logic.
- movement = ps.movements.movement.Movement(
- airplane_movements=[airplane_movement],
- operating_point_movement=operating_point_movement,
- delta_time=0.1,
- num_steps=1,
- )
-
- # The max_period should be 4.0 (the max of 3.0 and 4.0).
- self.assertEqual(movement.max_period, 4.0)
-
- # The lcm_period should be LCM(3.0, 4.0) = 12.0, Not 4.0. This test will Fail if
- # lcm_period only uses max_period from each AirplaneMovement instead of
- # collecting all individual periods.
- self.assertEqual(movement.lcm_period, 12.0)
-
- def test_lcm_period_with_multiple_cross_sections_same_wing(self):
- """Test that lcm_period collects all periods from WingCrossSectionMovements.
-
- This test creates a single Wing with three WingCrossSections having different
- periods (root static, middle 3.0 s, tip 4.0 s). The correct LCM is 12.0 s. If
- the implementation only uses max_period from each WingMovement, lcm_period
- would incorrectly return 4.0 s instead of 12.0 s.
- """
- # Create a Wing with three WingCrossSections.
- test_airfoil = ps.geometry.airfoil.Airfoil(name="naca2412")
-
- root_wcs = ps.geometry.wing_cross_section.WingCrossSection(
- airfoil=test_airfoil,
- num_spanwise_panels=4,
- chord=2.0,
- Lp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
-
- middle_wcs = ps.geometry.wing_cross_section.WingCrossSection(
- airfoil=test_airfoil,
- num_spanwise_panels=4,
- chord=1.5,
- Lp_Wcsp_Lpp=(0.0, 1.5, 0.0),
- angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
-
- tip_wcs = ps.geometry.wing_cross_section.WingCrossSection(
- airfoil=test_airfoil,
- num_spanwise_panels=None,
- chord=1.0,
- Lp_Wcsp_Lpp=(0.0, 1.5, 0.0),
- angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
- )
-
- base_wing = ps.geometry.wing.Wing(
- wing_cross_sections=[root_wcs, middle_wcs, tip_wcs],
- name="Test Wing",
- )
-
- base_airplane = ps.geometry.airplane.Airplane(
- wings=[base_wing],
- name="Test Airplane",
- Cg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
-
- # Root WingCrossSectionMovement must be static.
- # Middle has period 3.0 s, tip has period 4.0 s.
- wcs_movements = [
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing.wing_cross_sections[0],
- periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ),
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing.wing_cross_sections[1],
- periodLp_Wcsp_Lpp=(3.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
- ),
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing.wing_cross_sections[2],
- periodLp_Wcsp_Lpp=(4.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
- ),
- ]
-
- wing_movement = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- )
-
- airplane_movement = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane,
- wing_movements=[wing_movement],
- )
-
- operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=operating_point_fixtures.make_basic_operating_point_fixture()
- )
-
- # Use num_steps=1 instead of num_cycles=1 to speed up this test. The lcm_period
- # property is calculated from the Movement parameters (periods), not from the
- # generated Airplanes, so we only need to generate one Airplane to test the
- # period calculation logic.
- movement = ps.movements.movement.Movement(
- airplane_movements=[airplane_movement],
- operating_point_movement=operating_point_movement,
- delta_time=0.1,
- num_steps=1,
- )
-
- # The max_period should be 4.0 (the max of 3.0 and 4.0).
- self.assertEqual(movement.max_period, 4.0)
-
- # The lcm_period should be LCM(3.0, 4.0) = 12.0, not 4.0. This test will fail if
- # lcm_period only uses max_period from each WingMovement instead of collecting
- # all individual periods from WingCrossSectionMovements.
- self.assertEqual(movement.lcm_period, 12.0)
-
- def test_lcm_period_with_multiple_airplanes(self):
- """Test that lcm_period calculates LCM correctly with multiple periods."""
- # Create AirplaneMovements with different periods
-
- base_wing_1 = geometry_fixtures.make_simple_tapered_wing_fixture()
- base_airplane_1 = ps.geometry.airplane.Airplane(
- wings=[base_wing_1],
- name="Test Airplane 1",
- Cg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
-
- # Make WingCrossSectionMovements for the first Airplane's Wing's root and
- # tip WingCrossSections. The root WingCrossSectionMovement must be static.
- # The tip WingCrossSectionMovement will have a period of 2.0 s.
- wcs_movements_1 = [
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_1.wing_cross_sections[0],
- periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ),
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_1.wing_cross_sections[1],
- periodLp_Wcsp_Lpp=(2.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
- ),
- ]
-
- wing_movement_1 = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing_1,
- wing_cross_section_movements=wcs_movements_1,
- )
-
- airplane_movement_1 = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane_1,
- wing_movements=[wing_movement_1],
- )
-
- base_wing_2 = geometry_fixtures.make_simple_tapered_wing_fixture()
- base_airplane_2 = ps.geometry.airplane.Airplane(
- wings=[base_wing_2],
- name="Test Airplane 2",
- Cg_GP1_CgP1=(0.0, 0.0, 0.0),
- )
-
- # Make WingCrossSectionMovements for the second Airplane's Wing's root and
- # tip WingCrossSections. The root WingCrossSectionMovement must be static.
- # The tip WingCrossSectionMovement will have a period of 3.0 s.
- wcs_movements_2 = [
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_2.wing_cross_sections[0],
- periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- ),
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wing_2.wing_cross_sections[1],
- periodLp_Wcsp_Lpp=(3.0, 0.0, 0.0),
- ampLp_Wcsp_Lpp=(0.1, 0.0, 0.0),
- ),
- ]
-
- wing_movement_2 = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing_2,
- wing_cross_section_movements=wcs_movements_2,
- )
-
- airplane_movement_2 = ps.movements.airplane_movement.AirplaneMovement(
- base_airplane=base_airplane_2,
- wing_movements=[wing_movement_2],
- )
-
- operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=operating_point_fixtures.make_basic_operating_point_fixture()
- )
-
- # Use num_steps=1 instead of num_cycles=1 to speed up this test. The lcm_period
- # property is calculated from the Movement parameters (periods), not from the
- # generated Airplanes, so we only need to generate one Airplane to test the
- # period calculation logic.
- movement = ps.movements.movement.Movement(
- airplane_movements=[airplane_movement_1, airplane_movement_2],
- operating_point_movement=operating_point_movement,
- delta_time=0.1,
- num_steps=1,
- )
-
- # The LCM of 2.0 and 3.0 should be 6.0.
- self.assertEqual(movement.lcm_period, 6.0)
-
- # The max_period should still be 3.0.
- self.assertEqual(movement.max_period, 3.0)
-
def test_delta_time_automatic_calculation(self):
"""Test that delta_time is automatically calculated when not provided."""
airplane_movements = [
@@ -708,14 +400,6 @@ def test_multiple_airplanes(self):
for airplane_list in movement.airplanes:
self.assertEqual(len(airplane_list), movement.num_steps)
- def test_max_period_with_multiple_airplanes(self):
- """Test that max_period returns maximum across all AirplaneMovements."""
- movement = self.movement_with_multiple_airplanes
-
- # The movement has one static (period 0.0) and one with period 2.0.
- # Should return 2.0.
- self.assertEqual(movement.max_period, 2.0)
-
def test_delta_time_averaging_with_multiple_airplanes(self):
"""Test that delta_time is averaged across multiple Airplanes when auto-calculated."""
airplane_movements = [
@@ -1664,8 +1348,8 @@ class TestOptimizeDeltaTimeNonStatic(unittest.TestCase):
def test_returns_positive_float(self):
"""Test that _optimize_delta_time_non_static returns a positive float."""
+ from pterasoftware._core import lcm_multiple
from pterasoftware.movements.movement import (
- _lcm_multiple,
_optimize_delta_time_non_static,
)
@@ -1680,7 +1364,7 @@ def test_returns_positive_float(self):
all_periods = []
for airplane_movement in airplane_movements:
all_periods.extend(airplane_movement.all_periods)
- lcm_period = _lcm_multiple(all_periods)
+ lcm_period = lcm_multiple(all_periods)
# Use a larger initial_delta_time to reduce the brute force search range.
initial_delta_time = 0.1
@@ -1697,8 +1381,8 @@ def test_returns_positive_float(self):
def test_result_divides_lcm_period_evenly(self):
"""Test that _optimize_delta_time_non_static result divides LCM period evenly."""
+ from pterasoftware._core import lcm_multiple
from pterasoftware.movements.movement import (
- _lcm_multiple,
_optimize_delta_time_non_static,
)
@@ -1713,7 +1397,7 @@ def test_result_divides_lcm_period_evenly(self):
all_periods = []
for airplane_movement in airplane_movements:
all_periods.extend(airplane_movement.all_periods)
- lcm_period = _lcm_multiple(all_periods)
+ lcm_period = lcm_multiple(all_periods)
initial_delta_time = 0.1
@@ -1932,29 +1616,6 @@ def setUpClass(cls):
cls.static_movement = movement_fixtures.make_static_movement_fixture()
cls.basic_movement = movement_fixtures.make_basic_movement_fixture()
- def test_immutable_airplane_movements_property(self):
- """Test that airplane_movements property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_movement.airplane_movements = []
-
- def test_immutable_airplane_movements_tuple(self):
- """Test that airplane_movements returns a tuple (immutable sequence)."""
- airplane_movements = self.basic_movement.airplane_movements
- self.assertIsInstance(airplane_movements, tuple)
-
- def test_immutable_operating_point_movement_property(self):
- """Test that operating_point_movement property is read only."""
- operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=operating_point_fixtures.make_basic_operating_point_fixture()
- )
- with self.assertRaises(AttributeError):
- self.basic_movement.operating_point_movement = operating_point_movement
-
- def test_immutable_delta_time_property(self):
- """Test that delta_time property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_movement.delta_time = 0.05
-
def test_immutable_num_cycles_property(self):
"""Test that num_cycles property is read only."""
with self.assertRaises(AttributeError):
@@ -1965,11 +1626,6 @@ def test_immutable_num_chords_property(self):
with self.assertRaises(AttributeError):
self.static_movement.num_chords = 10
- def test_immutable_num_steps_property(self):
- """Test that num_steps property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_movement.num_steps = 100
-
def test_immutable_airplanes_property(self):
"""Test that airplanes property is read only."""
with self.assertRaises(AttributeError):
@@ -1993,105 +1649,6 @@ def test_immutable_operating_points_tuple(self):
self.assertIsInstance(operating_points, tuple)
-class TestMovementCaching(unittest.TestCase):
- """Tests for Movement caching behavior."""
-
- def test_lcm_period_cache_is_populated_after_access(self):
- """Test that _lcm_period cache is populated after first access."""
- # Create a fresh Movement to test cache population.
- airplane_movements = [
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- ]
- operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=operating_point_fixtures.make_basic_operating_point_fixture()
- )
-
- movement = ps.movements.movement.Movement(
- airplane_movements=airplane_movements,
- operating_point_movement=operating_point_movement,
- num_steps=1,
- )
-
- # Cache should be None initially.
- self.assertIsNone(movement._lcm_period)
-
- # Access the property.
- _ = movement.lcm_period
-
- # Cache should now be populated.
- self.assertIsNotNone(movement._lcm_period)
-
- def test_max_period_cache_is_populated_after_init(self):
- """Test that _max_period cache is populated during __init__ because the static
- property is accessed during __init__ to determine num_steps calculation, and
- static depends on max_period.
- """
- # Create a fresh Movement.
- airplane_movements = [
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- ]
- operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=operating_point_fixtures.make_basic_operating_point_fixture()
- )
-
- movement = ps.movements.movement.Movement(
- airplane_movements=airplane_movements,
- operating_point_movement=operating_point_movement,
- num_steps=1,
- )
-
- # max_period is accessed during __init__ via the static property
- # (which checks self.max_period == 0), so the cache should already be populated.
- self.assertIsNotNone(movement._max_period)
-
- def test_static_cache_is_populated_after_init(self):
- """Test that _static cache is populated during __init__ because it is accessed
- to determine num_steps calculation.
- """
- # Create a fresh Movement.
- airplane_movements = [
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- ]
- operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=operating_point_fixtures.make_basic_operating_point_fixture()
- )
-
- movement = ps.movements.movement.Movement(
- airplane_movements=airplane_movements,
- operating_point_movement=operating_point_movement,
- num_steps=1,
- )
-
- # static is accessed during __init__ to determine num_steps calculation,
- # so the cache should already be populated.
- self.assertIsNotNone(movement._static)
-
- def test_min_period_cache_is_populated_after_access(self):
- """Test that _min_period cache is populated after first access."""
- # Create a fresh Movement to test cache population.
- airplane_movements = [
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- ]
- operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=operating_point_fixtures.make_basic_operating_point_fixture()
- )
-
- movement = ps.movements.movement.Movement(
- airplane_movements=airplane_movements,
- operating_point_movement=operating_point_movement,
- num_steps=1,
- )
-
- # Cache should be None initially.
- self.assertIsNone(movement._min_period)
-
- # Access the property.
- _ = movement.min_period
-
- # Cache should now be populated.
- self.assertIsNotNone(movement._min_period)
-
-
class TestMovementDeepcopy(unittest.TestCase):
"""Tests for Movement deepcopy behavior."""
@@ -2203,117 +1760,6 @@ def test_deepcopy_static_movement(self):
self.assertEqual(copied.max_period, 0.0)
-class TestLcmFunctions(unittest.TestCase):
- """Tests for _lcm and _lcm_multiple module level functions."""
-
- def test_lcm_of_two_positive_numbers(self):
- """Test _lcm returns correct LCM for two positive numbers."""
- result = ps.movements.movement._lcm(2.0, 3.0)
- self.assertEqual(result, 6.0)
-
- def test_lcm_of_same_numbers(self):
- """Test _lcm returns the number when both inputs are the same."""
- result = ps.movements.movement._lcm(4.0, 4.0)
- self.assertEqual(result, 4.0)
-
- def test_lcm_with_first_zero(self):
- """Test _lcm returns 0.0 when first input is zero."""
- result = ps.movements.movement._lcm(0.0, 5.0)
- self.assertEqual(result, 0.0)
-
- def test_lcm_with_second_zero(self):
- """Test _lcm returns 0.0 when second input is zero."""
- result = ps.movements.movement._lcm(5.0, 0.0)
- self.assertEqual(result, 0.0)
-
- def test_lcm_with_both_zero(self):
- """Test _lcm returns 0.0 when both inputs are zero."""
- result = ps.movements.movement._lcm(0.0, 0.0)
- self.assertEqual(result, 0.0)
-
- def test_lcm_of_one_and_any_number(self):
- """Test _lcm of 1.0 and any number returns that number."""
- result = ps.movements.movement._lcm(1.0, 7.0)
- self.assertEqual(result, 7.0)
-
- result = ps.movements.movement._lcm(7.0, 1.0)
- self.assertEqual(result, 7.0)
-
- def test_lcm_of_multiples(self):
- """Test _lcm of a number and its multiple returns the larger number."""
- result = ps.movements.movement._lcm(3.0, 9.0)
- self.assertEqual(result, 9.0)
-
- result = ps.movements.movement._lcm(9.0, 3.0)
- self.assertEqual(result, 9.0)
-
- def test_lcm_multiple_empty_list(self):
- """Test _lcm_multiple returns 0.0 for empty list."""
- result = ps.movements.movement._lcm_multiple([])
- self.assertEqual(result, 0.0)
-
- def test_lcm_multiple_all_zeros(self):
- """Test _lcm_multiple returns 0.0 when all periods are zero."""
- result = ps.movements.movement._lcm_multiple([0.0, 0.0, 0.0])
- self.assertEqual(result, 0.0)
-
- def test_lcm_multiple_single_nonzero(self):
- """Test _lcm_multiple returns the value for a single non zero period."""
- result = ps.movements.movement._lcm_multiple([5.0])
- self.assertEqual(result, 5.0)
-
- def test_lcm_multiple_single_zero(self):
- """Test _lcm_multiple returns 0.0 for a single zero period."""
- result = ps.movements.movement._lcm_multiple([0.0])
- self.assertEqual(result, 0.0)
-
- def test_lcm_multiple_mixed_with_zeros(self):
- """Test _lcm_multiple correctly ignores zeros in the list."""
- result = ps.movements.movement._lcm_multiple([0.0, 2.0, 0.0, 3.0, 0.0])
- self.assertEqual(result, 6.0)
-
- def test_lcm_multiple_three_periods(self):
- """Test _lcm_multiple returns correct LCM for three periods."""
- result = ps.movements.movement._lcm_multiple([2.0, 3.0, 4.0])
- self.assertEqual(result, 12.0)
-
- def test_lcm_multiple_many_same_periods(self):
- """Test _lcm_multiple returns the period when all are the same."""
- result = ps.movements.movement._lcm_multiple([5.0, 5.0, 5.0, 5.0])
- self.assertEqual(result, 5.0)
-
- def test_lcm_multiple_coprime_periods(self):
- """Test _lcm_multiple of coprime numbers returns their product."""
- # 2, 3, and 5 are coprime, so LCM = 2 * 3 * 5 = 30.
- result = ps.movements.movement._lcm_multiple([2.0, 3.0, 5.0])
- self.assertEqual(result, 30.0)
-
- def test_lcm_non_integer_periods(self):
- """Test _lcm returns correct LCM for non integer periods."""
- # LCM(1.5, 2.5) = 7.5 (both divide 7.5 evenly: 7.5/1.5=5, 7.5/2.5=3).
- result = ps.movements.movement._lcm(1.5, 2.5)
- self.assertAlmostEqual(result, 7.5, places=6)
-
- def test_lcm_multiple_non_integer_periods(self):
- """Test _lcm_multiple returns correct LCM for non integer periods."""
- # LCM(1.5, 2.0, 2.5) = 30.0.
- # 30.0/1.5=20, 30.0/2.0=15, 30.0/2.5=12.
- result = ps.movements.movement._lcm_multiple([1.5, 2.0, 2.5])
- self.assertAlmostEqual(result, 30.0, places=6)
-
- def test_lcm_small_periods(self):
- """Test _lcm handles small periods correctly without precision issues."""
- # LCM(0.001, 0.002) = 0.002.
- result = ps.movements.movement._lcm(0.001, 0.002)
- self.assertAlmostEqual(result, 0.002, places=9)
-
- def test_lcm_multiple_small_periods(self):
- """Test _lcm_multiple handles small periods correctly."""
- # LCM(0.01, 0.02, 0.03) = 0.06.
- result = ps.movements.movement._lcm_multiple([0.01, 0.02, 0.03])
- self.assertAlmostEqual(result, 0.06, places=9)
-
-
class TestAnalyticallyOptimizeDeltaTimeEdgeCases(unittest.TestCase):
"""Tests for edge cases in the _analytically_optimize_delta_time function."""
@@ -2841,128 +2287,5 @@ class MockResult:
)
-class TestMovementWithOperatingPointMovementPeriod(unittest.TestCase):
- """Tests for Movement when OperatingPointMovement has non zero period."""
-
- def test_lcm_period_includes_operating_point_movement_period(self):
- """Test that lcm_period includes the OperatingPointMovement period."""
- # Create a static AirplaneMovement.
- airplane_movements = [
- airplane_movement_fixtures.make_static_airplane_movement_fixture()
- ]
-
- # Create an OperatingPointMovement with a non zero period.
- base_operating_point = (
- operating_point_fixtures.make_basic_operating_point_fixture()
- )
- operating_point_movement = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=1.0,
- periodVCg__E=3.0,
- )
- )
-
- # Create the Movement with explicit num_steps to avoid auto calculation.
- movement = ps.movements.movement.Movement(
- airplane_movements=airplane_movements,
- operating_point_movement=operating_point_movement,
- delta_time=0.1,
- num_steps=1,
- )
-
- # The lcm_period should be 3.0 (from OperatingPointMovement).
- # Since the AirplaneMovement is static (period 0.0), the only period is 3.0.
- self.assertEqual(movement.lcm_period, 3.0)
-
- def test_lcm_period_combines_airplane_and_operating_point_periods(self):
- """Test that lcm_period combines periods from AirplaneMovement and
- OperatingPointMovement."""
- # Create a non static AirplaneMovement with period 2.0.
- airplane_movements = [
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- ]
-
- # Create an OperatingPointMovement with period 3.0.
- base_operating_point = (
- operating_point_fixtures.make_basic_operating_point_fixture()
- )
- operating_point_movement = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=1.0,
- periodVCg__E=3.0,
- )
- )
-
- movement = ps.movements.movement.Movement(
- airplane_movements=airplane_movements,
- operating_point_movement=operating_point_movement,
- delta_time=0.1,
- num_steps=1,
- )
-
- # The lcm_period should be LCM(2.0, 3.0) = 6.0.
- self.assertEqual(movement.lcm_period, 6.0)
-
- def test_min_period_includes_operating_point_movement_period(self):
- """Test that min_period includes the OperatingPointMovement period."""
- # Create a non static AirplaneMovement with period 2.0.
- airplane_movements = [
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- ]
-
- # Create an OperatingPointMovement with a shorter period (1.5).
- base_operating_point = (
- operating_point_fixtures.make_basic_operating_point_fixture()
- )
- operating_point_movement = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=1.0,
- periodVCg__E=1.5,
- )
- )
-
- movement = ps.movements.movement.Movement(
- airplane_movements=airplane_movements,
- operating_point_movement=operating_point_movement,
- delta_time=0.1,
- num_steps=1,
- )
-
- # The min_period should be 1.5 (from OperatingPointMovement, smaller than 2.0).
- self.assertEqual(movement.min_period, 1.5)
-
- def test_max_period_includes_operating_point_movement_period(self):
- """Test that max_period includes the OperatingPointMovement period."""
- # Create a non static AirplaneMovement with period 2.0.
- airplane_movements = [
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
- ]
-
- # Create an OperatingPointMovement with a longer period (5.0).
- base_operating_point = (
- operating_point_fixtures.make_basic_operating_point_fixture()
- )
- operating_point_movement = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point,
- ampVCg__E=1.0,
- periodVCg__E=5.0,
- )
- )
-
- movement = ps.movements.movement.Movement(
- airplane_movements=airplane_movements,
- operating_point_movement=operating_point_movement,
- delta_time=0.1,
- num_steps=1,
- )
-
- # The max_period should be 5.0 (from OperatingPointMovement, larger than 2.0).
- self.assertEqual(movement.max_period, 5.0)
-
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/test_movements_functions.py b/tests/unit/test_movements_functions.py
deleted file mode 100644
index ef433b63a..000000000
--- a/tests/unit/test_movements_functions.py
+++ /dev/null
@@ -1,744 +0,0 @@
-"""This module contains a class to test movements functions."""
-
-import unittest
-
-import numpy as np
-import numpy.testing as npt
-from scipy import signal
-
-# noinspection PyProtectedMember
-from pterasoftware.movements import _functions
-from tests.unit.fixtures import movements_functions_fixtures
-
-
-class TestMovementsFunctions(unittest.TestCase):
- """This is a class with functions to test movements functions."""
-
- @classmethod
- def setUpClass(cls):
- """Set up test fixtures once for all movements function tests."""
- # Parameter fixtures.
- (
- cls.scalar_amps,
- cls.scalar_periods,
- cls.scalar_phases,
- cls.scalar_bases,
- ) = movements_functions_fixtures.make_scalar_parameters_fixture()
-
- (
- cls.array_amps,
- cls.array_periods,
- cls.array_phases,
- cls.array_bases,
- ) = movements_functions_fixtures.make_array_parameters_fixture()
-
- (
- cls.static_amps,
- cls.static_periods,
- cls.static_phases,
- cls.static_bases,
- ) = movements_functions_fixtures.make_static_parameters_fixture()
-
- (
- cls.mixed_static_amps,
- cls.mixed_static_periods,
- cls.mixed_static_phases,
- cls.mixed_static_bases,
- ) = movements_functions_fixtures.make_mixed_static_parameters_fixture()
-
- (
- cls.phase_offset_amps,
- cls.phase_offset_periods,
- cls.phase_offset_phases,
- cls.phase_offset_bases,
- ) = movements_functions_fixtures.make_phase_offset_parameters_fixture()
-
- (
- cls.large_amplitude_amps,
- cls.large_amplitude_periods,
- cls.large_amplitude_phases,
- cls.large_amplitude_bases,
- ) = movements_functions_fixtures.make_large_amplitude_parameters_fixture()
-
- (
- cls.small_period_amps,
- cls.small_period_periods,
- cls.small_period_phases,
- cls.small_period_bases,
- ) = movements_functions_fixtures.make_small_period_parameters_fixture()
-
- (
- cls.negative_phase_amps,
- cls.negative_phase_periods,
- cls.negative_phase_phases,
- cls.negative_phase_bases,
- ) = movements_functions_fixtures.make_negative_phase_parameters_fixture()
-
- (
- cls.max_phase_amps,
- cls.max_phase_periods,
- cls.max_phase_phases,
- cls.max_phase_bases,
- ) = movements_functions_fixtures.make_max_phase_parameters_fixture()
-
- (
- cls.min_phase_amps,
- cls.min_phase_periods,
- cls.min_phase_phases,
- cls.min_phase_bases,
- ) = movements_functions_fixtures.make_min_phase_parameters_fixture()
-
- # Time step fixtures.
- cls.num_steps, cls.delta_time = (
- movements_functions_fixtures.make_num_steps_and_delta_time_fixture()
- )
- cls.small_num_steps = (
- movements_functions_fixtures.make_small_num_steps_fixture()
- )
- cls.large_num_steps = (
- movements_functions_fixtures.make_large_num_steps_fixture()
- )
-
- # Custom function fixtures.
- cls.valid_custom_sine = staticmethod(
- movements_functions_fixtures.make_valid_custom_sine_function_fixture()
- )
- cls.valid_custom_triangle = staticmethod(
- movements_functions_fixtures.make_valid_custom_triangle_function_fixture()
- )
- cls.valid_custom_harmonic = staticmethod(
- movements_functions_fixtures.make_valid_custom_harmonic_function_fixture()
- )
- cls.invalid_custom_wrong_start = staticmethod(
- movements_functions_fixtures.make_invalid_custom_function_wrong_start_fixture()
- )
- cls.invalid_custom_wrong_end = staticmethod(
- movements_functions_fixtures.make_invalid_custom_function_wrong_end_fixture()
- )
- cls.invalid_custom_wrong_amplitude = staticmethod(
- movements_functions_fixtures.make_invalid_custom_function_wrong_amplitude_fixture()
- )
- cls.invalid_custom_not_periodic = staticmethod(
- movements_functions_fixtures.make_invalid_custom_function_not_periodic_fixture()
- )
- cls.invalid_custom_returns_nan = staticmethod(
- movements_functions_fixtures.make_invalid_custom_function_returns_nan_fixture()
- )
- cls.invalid_custom_returns_inf = staticmethod(
- movements_functions_fixtures.make_invalid_custom_function_returns_inf_fixture()
- )
- cls.invalid_custom_wrong_shape = staticmethod(
- movements_functions_fixtures.make_invalid_custom_function_wrong_shape_fixture()
- )
-
- def test_oscillating_sinspaces_scalar_parameters(self):
- """Test oscillating_sinspaces with scalar parameters."""
- result = _functions.oscillating_sinspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- phases=self.scalar_phases,
- bases=self.scalar_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output shape.
- self.assertEqual(result.shape, (self.num_steps,))
-
- # Verify output values match expected sine wave.
- times = np.linspace(
- 0, self.num_steps * self.delta_time, self.num_steps, endpoint=False
- )
- expected = (
- self.scalar_amps
- * np.sin(
- 2 * np.pi * times / self.scalar_periods + np.deg2rad(self.scalar_phases)
- )
- + self.scalar_bases
- )
-
- npt.assert_allclose(result, expected, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_sinspaces_array_parameters(self):
- """Test oscillating_sinspaces with array parameters."""
- result = _functions.oscillating_sinspaces(
- amps=self.array_amps,
- periods=self.array_periods,
- phases=self.array_phases,
- bases=self.array_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output shape.
- expected_shape = (3, self.num_steps)
- self.assertEqual(result.shape, expected_shape)
-
- # Verify output values for each element.
- times = np.linspace(
- 0, self.num_steps * self.delta_time, self.num_steps, endpoint=False
- )
- for i in range(3):
- expected = (
- self.array_amps[i]
- * np.sin(
- 2 * np.pi * times / self.array_periods[i]
- + np.deg2rad(self.array_phases[i])
- )
- + self.array_bases[i]
- )
- npt.assert_allclose(result[i, :], expected, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_sinspaces_static_parameters(self):
- """Test oscillating_sinspaces with static parameters."""
- result = _functions.oscillating_sinspaces(
- amps=self.static_amps,
- periods=self.static_periods,
- phases=self.static_phases,
- bases=self.static_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output is constant and equal to base.
- expected = np.full(self.num_steps, self.static_bases, dtype=float)
- npt.assert_allclose(result, expected, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_sinspaces_mixed_static_parameters(self):
- """Test oscillating_sinspaces with mixed static and dynamic parameters."""
- result = _functions.oscillating_sinspaces(
- amps=self.mixed_static_amps,
- periods=self.mixed_static_periods,
- phases=self.mixed_static_phases,
- bases=self.mixed_static_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output shape.
- expected_shape = (3, self.num_steps)
- self.assertEqual(result.shape, expected_shape)
-
- # Verify that static element (index 1) is constant.
- expected_static = np.full(
- self.num_steps, self.mixed_static_bases[1], dtype=float
- )
- npt.assert_allclose(result[1, :], expected_static, rtol=1e-10, atol=1e-14)
-
- # Verify that dynamic elements are oscillating.
- self.assertFalse(np.allclose(result[0, :], result[0, 0]))
- self.assertFalse(np.allclose(result[2, :], result[2, 0]))
-
- def test_oscillating_sinspaces_phase_offset(self):
- """Test oscillating_sinspaces with phase offset."""
- result = _functions.oscillating_sinspaces(
- amps=self.phase_offset_amps,
- periods=self.phase_offset_periods,
- phases=self.phase_offset_phases,
- bases=self.phase_offset_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify that phase offset shifts the waveform.
- # At t=0, sine with 90 degree phase should equal the amplitude.
- npt.assert_allclose(result[0], self.phase_offset_amps, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_sinspaces_large_amplitude(self):
- """Test oscillating_sinspaces with large amplitude."""
- result = _functions.oscillating_sinspaces(
- amps=self.large_amplitude_amps,
- periods=self.large_amplitude_periods,
- phases=self.large_amplitude_phases,
- bases=self.large_amplitude_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output range.
- expected_min = self.large_amplitude_bases - self.large_amplitude_amps
- expected_max = self.large_amplitude_bases + self.large_amplitude_amps
- self.assertGreaterEqual(result.min(), expected_min - 1e-10)
- self.assertLessEqual(result.max(), expected_max + 1e-10)
-
- def test_oscillating_sinspaces_small_period(self):
- """Test oscillating_sinspaces with small period."""
- result = _functions.oscillating_sinspaces(
- amps=self.small_period_amps,
- periods=self.small_period_periods,
- phases=self.small_period_phases,
- bases=self.small_period_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify that small period results in multiple oscillations.
- # Count zero crossings as a proxy for oscillations.
- zero_crossings = np.sum(np.diff(np.sign(result - self.small_period_bases)) != 0)
- self.assertGreater(zero_crossings, 10)
-
- def test_oscillating_sinspaces_small_num_steps(self):
- """Test oscillating_sinspaces with small num_steps."""
- result = _functions.oscillating_sinspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- phases=self.scalar_phases,
- bases=self.scalar_bases,
- num_steps=self.small_num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output shape.
- self.assertEqual(result.shape, (self.small_num_steps,))
-
- def test_oscillating_sinspaces_large_num_steps(self):
- """Test oscillating_sinspaces with large num_steps."""
- result = _functions.oscillating_sinspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- phases=self.scalar_phases,
- bases=self.scalar_bases,
- num_steps=self.large_num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output shape.
- self.assertEqual(result.shape, (self.large_num_steps,))
-
- def test_oscillating_sinspaces_negative_phase(self):
- """Test oscillating_sinspaces with negative phase."""
- result = _functions.oscillating_sinspaces(
- amps=self.negative_phase_amps,
- periods=self.negative_phase_periods,
- phases=self.negative_phase_phases,
- bases=self.negative_phase_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify that negative phase shifts the waveform backward.
- # At t=0, sine with -90 degree phase should equal negative amplitude.
- npt.assert_allclose(
- result[0], -self.negative_phase_amps, rtol=1e-10, atol=1e-14
- )
-
- def test_oscillating_sinspaces_max_phase(self):
- """Test oscillating_sinspaces with maximum phase."""
- result = _functions.oscillating_sinspaces(
- amps=self.max_phase_amps,
- periods=self.max_phase_periods,
- phases=self.max_phase_phases,
- bases=self.max_phase_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify that max phase (180 degrees) inverts the waveform.
- # At t=0, sine with 180 degree phase should be approximately 0.
- npt.assert_allclose(result[0], 0.0, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_sinspaces_min_phase(self):
- """Test oscillating_sinspaces with minimum phase."""
- result = _functions.oscillating_sinspaces(
- amps=self.min_phase_amps,
- periods=self.min_phase_periods,
- phases=self.min_phase_phases,
- bases=self.min_phase_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify result is computed without error.
- self.assertEqual(result.shape, (self.num_steps,))
-
- def test_oscillating_linspaces_scalar_parameters(self):
- """Test oscillating_linspaces with scalar parameters."""
- result = _functions.oscillating_linspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- phases=self.scalar_phases,
- bases=self.scalar_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output shape.
- self.assertEqual(result.shape, (self.num_steps,))
-
- # Verify output values match expected triangular wave.
- times = np.linspace(
- 0, self.num_steps * self.delta_time, self.num_steps, endpoint=False
- )
- expected = (
- self.scalar_amps
- * signal.sawtooth(
- 2 * np.pi * times / self.scalar_periods
- + (np.pi / 2)
- + np.deg2rad(self.scalar_phases),
- 0.5,
- )
- + self.scalar_bases
- )
-
- npt.assert_allclose(result, expected, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_linspaces_array_parameters(self):
- """Test oscillating_linspaces with array parameters."""
- result = _functions.oscillating_linspaces(
- amps=self.array_amps,
- periods=self.array_periods,
- phases=self.array_phases,
- bases=self.array_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output shape.
- expected_shape = (3, self.num_steps)
- self.assertEqual(result.shape, expected_shape)
-
- # Verify output values for each element.
- times = np.linspace(
- 0, self.num_steps * self.delta_time, self.num_steps, endpoint=False
- )
- for i in range(3):
- expected = (
- self.array_amps[i]
- * signal.sawtooth(
- 2 * np.pi * times / self.array_periods[i]
- + (np.pi / 2)
- + np.deg2rad(self.array_phases[i]),
- 0.5,
- )
- + self.array_bases[i]
- )
- npt.assert_allclose(result[i, :], expected, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_linspaces_static_parameters(self):
- """Test oscillating_linspaces with static parameters."""
- result = _functions.oscillating_linspaces(
- amps=self.static_amps,
- periods=self.static_periods,
- phases=self.static_phases,
- bases=self.static_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output is constant and equal to base.
- expected = np.full(self.num_steps, self.static_bases, dtype=float)
- npt.assert_allclose(result, expected, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_linspaces_mixed_static_parameters(self):
- """Test oscillating_linspaces with mixed static and dynamic parameters."""
- result = _functions.oscillating_linspaces(
- amps=self.mixed_static_amps,
- periods=self.mixed_static_periods,
- phases=self.mixed_static_phases,
- bases=self.mixed_static_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output shape.
- expected_shape = (3, self.num_steps)
- self.assertEqual(result.shape, expected_shape)
-
- # Verify that static element (index 1) is constant.
- expected_static = np.full(
- self.num_steps, self.mixed_static_bases[1], dtype=float
- )
- npt.assert_allclose(result[1, :], expected_static, rtol=1e-10, atol=1e-14)
-
- # Verify that dynamic elements are oscillating.
- self.assertFalse(np.allclose(result[0, :], result[0, 0]))
- self.assertFalse(np.allclose(result[2, :], result[2, 0]))
-
- def test_oscillating_linspaces_phase_offset(self):
- """Test oscillating_linspaces with phase offset."""
- result = _functions.oscillating_linspaces(
- amps=self.phase_offset_amps,
- periods=self.phase_offset_periods,
- phases=self.phase_offset_phases,
- bases=self.phase_offset_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify that phase offset shifts the waveform.
- # At t=0, triangular wave with 90 degree phase should be near maximum.
- self.assertGreater(result[0], 0.5 * self.phase_offset_amps)
-
- def test_oscillating_linspaces_large_amplitude(self):
- """Test oscillating_linspaces with large amplitude."""
- result = _functions.oscillating_linspaces(
- amps=self.large_amplitude_amps,
- periods=self.large_amplitude_periods,
- phases=self.large_amplitude_phases,
- bases=self.large_amplitude_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify output range.
- expected_min = self.large_amplitude_bases - self.large_amplitude_amps
- expected_max = self.large_amplitude_bases + self.large_amplitude_amps
- self.assertGreaterEqual(result.min(), expected_min - 1e-10)
- self.assertLessEqual(result.max(), expected_max + 1e-10)
-
- def test_oscillating_customspaces_scalar_parameters_with_sine(self):
- """Test oscillating_customspaces with scalar parameters and valid sine
- function."""
- result = _functions.oscillating_customspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- bases=self.scalar_bases,
- phases=self.scalar_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.valid_custom_sine,
- )
-
- # Verify output shape.
- self.assertEqual(result.shape, (self.num_steps,))
-
- # Verify result matches oscillating_sinspaces.
- expected = _functions.oscillating_sinspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- phases=self.scalar_phases,
- bases=self.scalar_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
- npt.assert_allclose(result, expected, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_customspaces_array_parameters_with_triangle(self):
- """Test oscillating_customspaces with array parameters and valid triangle
- function."""
- result = _functions.oscillating_customspaces(
- amps=self.array_amps,
- periods=self.array_periods,
- bases=self.array_bases,
- phases=self.array_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.valid_custom_triangle,
- )
-
- # Verify output shape.
- expected_shape = (3, self.num_steps)
- self.assertEqual(result.shape, expected_shape)
-
- def test_oscillating_customspaces_with_harmonic(self):
- """Test oscillating_customspaces with valid harmonic function."""
- result = _functions.oscillating_customspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- bases=self.scalar_bases,
- phases=self.scalar_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.valid_custom_harmonic,
- )
-
- # Verify output shape.
- self.assertEqual(result.shape, (self.num_steps,))
-
- def test_oscillating_customspaces_static_parameters(self):
- """Test oscillating_customspaces with static parameters."""
- result = _functions.oscillating_customspaces(
- amps=self.static_amps,
- periods=self.static_periods,
- bases=self.static_bases,
- phases=self.static_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.valid_custom_sine,
- )
-
- # Verify output is constant and equal to base.
- expected = np.full(self.num_steps, self.static_bases, dtype=float)
- npt.assert_allclose(result, expected, rtol=1e-10, atol=1e-14)
-
- def test_oscillating_customspaces_invalid_function_wrong_start(self):
- """Test oscillating_customspaces with invalid function that doesn't start at 0."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_customspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- bases=self.scalar_bases,
- phases=self.scalar_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.invalid_custom_wrong_start,
- )
-
- self.assertIn("must start at 0", str(context.exception))
-
- def test_oscillating_customspaces_invalid_function_wrong_end(self):
- """Test oscillating_customspaces with invalid function that doesn't return to 0
- after one period."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_customspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- bases=self.scalar_bases,
- phases=self.scalar_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.invalid_custom_wrong_end,
- )
-
- self.assertIn("must return to 0 after one period", str(context.exception))
-
- def test_oscillating_customspaces_invalid_function_wrong_amplitude(self):
- """Test oscillating_customspaces with invalid function with amplitude not equal
- to 1."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_customspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- bases=self.scalar_bases,
- phases=self.scalar_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.invalid_custom_wrong_amplitude,
- )
-
- self.assertIn("must have amplitude of 1", str(context.exception))
-
- def test_oscillating_customspaces_invalid_function_not_periodic(self):
- """Test oscillating_customspaces with invalid function that is not periodic."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_customspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- bases=self.scalar_bases,
- phases=self.scalar_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.invalid_custom_not_periodic,
- )
-
- self.assertIn("must be periodic", str(context.exception))
-
- def test_oscillating_customspaces_invalid_function_returns_nan(self):
- """Test oscillating_customspaces with invalid function that returns NaN."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_customspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- bases=self.scalar_bases,
- phases=self.scalar_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.invalid_custom_returns_nan,
- )
-
- self.assertIn("finite values only", str(context.exception))
-
- def test_oscillating_customspaces_invalid_function_returns_inf(self):
- """Test oscillating_customspaces with invalid function that returns Inf."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_customspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- bases=self.scalar_bases,
- phases=self.scalar_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.invalid_custom_returns_inf,
- )
-
- self.assertIn("finite values only", str(context.exception))
-
- def test_oscillating_customspaces_invalid_function_wrong_shape(self):
- """Test oscillating_customspaces with invalid function that returns wrong
- shape."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_customspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- bases=self.scalar_bases,
- phases=self.scalar_phases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- custom_function=self.invalid_custom_wrong_shape,
- )
-
- self.assertIn("same shape", str(context.exception))
-
- def test_oscillating_sinspaces_and_linspaces_different_outputs(self):
- """Test that oscillating_sinspaces and oscillating_linspaces produce different
- outputs for the same parameters."""
- sinspaces_result = _functions.oscillating_sinspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- phases=self.scalar_phases,
- bases=self.scalar_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- linspaces_result = _functions.oscillating_linspaces(
- amps=self.scalar_amps,
- periods=self.scalar_periods,
- phases=self.scalar_phases,
- bases=self.scalar_bases,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- # Verify that the outputs are different (except at specific points).
- self.assertFalse(np.allclose(sinspaces_result, linspaces_result))
-
- def test_oscillating_functions_validation_invalid_amps_periods_mismatch(self):
- """Test that oscillating functions raise error when amps is zero but periods is
- not."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_sinspaces(
- amps=0.0,
- periods=1.0,
- phases=0.0,
- bases=0.0,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- self.assertIn("If an element in amps is 0.0", str(context.exception))
-
- def test_oscillating_functions_validation_invalid_phase_for_static(self):
- """Test that oscillating functions raise error when amps and periods are zero
- but phases is not."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_sinspaces(
- amps=0.0,
- periods=0.0,
- phases=90.0,
- bases=0.0,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- self.assertIn(
- "corresponding element in phases must also be 0.0", str(context.exception)
- )
-
- def test_oscillating_functions_validation_mismatched_array_shapes(self):
- """Test that oscillating functions raise error with mismatched array shapes."""
- with self.assertRaises(ValueError) as context:
- _functions.oscillating_sinspaces(
- amps=np.array([1.0, 2.0], dtype=float),
- periods=np.array([1.0, 2.0, 3.0], dtype=float),
- phases=0.0,
- bases=0.0,
- num_steps=self.num_steps,
- delta_time=self.delta_time,
- )
-
- self.assertIn("must have the same shape", str(context.exception))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/unit/test_operating_point_movement.py b/tests/unit/test_operating_point_movement.py
index c557925a5..5444ae946 100644
--- a/tests/unit/test_operating_point_movement.py
+++ b/tests/unit/test_operating_point_movement.py
@@ -2,10 +2,6 @@
import unittest
-import numpy as np
-import numpy.testing as npt
-from scipy import signal
-
import pterasoftware as ps
from tests.unit.fixtures import (
operating_point_fixtures,
@@ -16,655 +12,50 @@
class TestOperatingPointMovement(unittest.TestCase):
"""This is a class with functions to test OperatingPointMovements."""
- @classmethod
- def setUpClass(cls):
- """Set up test fixtures once for all OperatingPointMovement tests."""
- cls.static_op_movement = (
- operating_point_movement_fixtures.make_static_operating_point_movement_fixture()
- )
- cls.sine_spacing_op_movement = (
- operating_point_movement_fixtures.make_sine_spacing_operating_point_movement_fixture()
- )
- cls.uniform_spacing_op_movement = (
- operating_point_movement_fixtures.make_uniform_spacing_operating_point_movement_fixture()
- )
- cls.phase_offset_op_movement = (
- operating_point_movement_fixtures.make_phase_offset_operating_point_movement_fixture()
- )
- cls.custom_spacing_op_movement = (
- operating_point_movement_fixtures.make_custom_spacing_operating_point_movement_fixture()
- )
- cls.basic_op_movement = (
- operating_point_movement_fixtures.make_basic_operating_point_movement_fixture()
- )
- cls.large_amplitude_op_movement = (
- operating_point_movement_fixtures.make_large_amplitude_operating_point_movement_fixture()
- )
- cls.long_period_op_movement = (
- operating_point_movement_fixtures.make_long_period_operating_point_movement_fixture()
- )
-
- def test_initialization_valid_parameters(self):
- """Test OperatingPointMovement initialization with valid parameters."""
- # Test that basic OperatingPointMovement initializes correctly.
- op_movement = self.basic_op_movement
- self.assertIsInstance(
- op_movement,
- ps.movements.operating_point_movement.OperatingPointMovement,
- )
- self.assertIsInstance(
- op_movement.base_operating_point,
- ps.operating_point.OperatingPoint,
+ def test_is_subclass_of_core(self):
+ """Test that OperatingPointMovement is a subclass of
+ CoreOperatingPointMovement.
+ """
+ self.assertTrue(
+ issubclass(
+ ps.movements.operating_point_movement.OperatingPointMovement,
+ ps._core.CoreOperatingPointMovement,
+ )
)
- self.assertEqual(op_movement.ampVCg__E, 5.0)
- self.assertEqual(op_movement.periodVCg__E, 2.0)
- self.assertEqual(op_movement.spacingVCg__E, "sine")
- self.assertEqual(op_movement.phaseVCg__E, 0.0)
- def test_initialization_default_parameters(self):
- """Test OperatingPointMovement initialization with default parameters."""
+ def test_instantiation_returns_correct_type(self):
+ """Test that OperatingPointMovement instantiation returns an
+ OperatingPointMovement.
+ """
base_operating_point = (
operating_point_fixtures.make_basic_operating_point_fixture()
)
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_operating_point
- )
-
- self.assertEqual(op_movement.ampVCg__E, 0.0)
- self.assertEqual(op_movement.periodVCg__E, 0.0)
- self.assertEqual(op_movement.spacingVCg__E, "sine")
- self.assertEqual(op_movement.phaseVCg__E, 0.0)
-
- def test_base_operating_point_validation(self):
- """Test that base_operating_point parameter is properly validated."""
- # Test with invalid type.
- with self.assertRaises(TypeError):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point="not an operating point"
- )
-
- # Test with None.
- with self.assertRaises(TypeError):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=None
- )
-
- # Test with valid OperatingPoint works.
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op
- )
- self.assertEqual(op_movement.base_operating_point, base_op)
-
- def test_ampVCg__E_validation(self):
- """Test ampVCg__E parameter validation."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Test valid non-negative values.
- valid_amps = [0.0, 1.0, 5.0, 100.0]
- for amp in valid_amps:
- with self.subTest(amp=amp):
- op_movement = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op, ampVCg__E=amp
- )
- )
- self.assertEqual(op_movement.ampVCg__E, amp)
-
- # Test negative values raise error.
- with self.assertRaises(ValueError):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op, ampVCg__E=-1.0
- )
-
- # Test invalid types raise error.
- # noinspection PyTypeChecker
- with self.assertRaises((TypeError, ValueError)):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op, ampVCg__E="invalid"
- )
-
- def test_periodVCg__E_validation(self):
- """Test periodVCg__E parameter validation."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Test valid non-negative values.
- valid_periods = [0.0, 1.0, 5.0, 100.0]
- for period in valid_periods:
- with self.subTest(period=period):
- amp = 1.0 if period > 0 else 0.0
- op_movement = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=amp,
- periodVCg__E=period,
- )
- )
- self.assertEqual(op_movement.periodVCg__E, period)
-
- # Test negative values raise error.
- with self.assertRaises(ValueError):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=-1.0,
- )
-
- def test_spacingVCg__E_validation(self):
- """Test spacingVCg__E parameter validation."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Test valid string values.
- valid_spacings = ["sine", "uniform"]
- for spacing in valid_spacings:
- with self.subTest(spacing=spacing):
- op_movement = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op, spacingVCg__E=spacing
- )
- )
- self.assertEqual(op_movement.spacingVCg__E, spacing)
-
- # Test invalid string raises error.
- with self.assertRaises(ValueError):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op, spacingVCg__E="invalid"
- )
-
- # Test callable is accepted.
- def custom_func(x):
- return np.sin(x)
-
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op, spacingVCg__E=custom_func
- )
- self.assertTrue(callable(op_movement.spacingVCg__E))
-
- # Test non-callable, non-string raises error.
- with self.assertRaises(TypeError):
+ operating_point_movement = (
ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op, spacingVCg__E=123
+ base_operating_point=base_operating_point,
)
-
- def test_phaseVCg__E_validation(self):
- """Test phaseVCg__E parameter validation."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Test valid phase values within range (-180.0, 180.0].
- valid_phases = [0.0, 90.0, 180.0, -90.0, -179.9]
- for phase in valid_phases:
- with self.subTest(phase=phase):
- amp = 1.0 if phase != 0 else 0.0
- period = 1.0 if phase != 0 else 0.0
- op_movement = (
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=amp,
- periodVCg__E=period,
- phaseVCg__E=phase,
- )
- )
- self.assertEqual(op_movement.phaseVCg__E, phase)
-
- # Test phase > 180.0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=1.0,
- phaseVCg__E=180.1,
- )
-
- # Test phase <= -180.0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=1.0,
- phaseVCg__E=-180.0,
- )
-
- def test_amp_period_relationship(self):
- """Test that if ampVCg__E is 0, periodVCg__E must be 0."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Test amp=0 with period=0 works.
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=0.0,
- periodVCg__E=0.0,
)
- self.assertIsNotNone(op_movement)
-
- # Test amp=0 with period!=0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=0.0,
- periodVCg__E=1.0,
- )
-
- def test_amp_phase_relationship(self):
- """Test that if ampVCg__E is 0, phaseVCg__E must be 0."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Test amp=0 with phase=0 works.
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=0.0,
- periodVCg__E=0.0,
- phaseVCg__E=0.0,
- )
- self.assertIsNotNone(op_movement)
-
- # Test amp=0 with phase!=0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=0.0,
- periodVCg__E=0.0,
- phaseVCg__E=45.0,
- )
-
- def test_spacing_sine_produces_sinusoidal_motion(self):
- """Test that sine spacing actually produces sinusoidal motion for vCg__E."""
- num_steps = 100
- delta_time = 0.01
- operating_points = self.sine_spacing_op_movement.generate_operating_points(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract vCg__E values from generated OperatingPoints.
- vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
-
- # Calculate expected sine wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_vCg = 100.0 + 10.0 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated values match the expected sine wave.
- npt.assert_allclose(vCg_values, expected_vCg, rtol=1e-10, atol=1e-14)
-
- def test_spacing_uniform_produces_triangular_wave(self):
- """Test that uniform spacing actually produces triangular wave motion for vCg__E."""
- num_steps = 100
- delta_time = 0.01
- operating_points = self.uniform_spacing_op_movement.generate_operating_points(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract vCg__E values from generated OperatingPoints.
- vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
-
- # Calculate expected triangular wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_vCg = 100.0 + 10.0 * signal.sawtooth(
- 2 * np.pi * times / 1.0 + np.pi / 2, 0.5
- )
-
- # Assert that the generated values match the expected triangular wave.
- npt.assert_allclose(vCg_values, expected_vCg, rtol=1e-10, atol=1e-14)
-
- def test_max_period_static_movement(self):
- """Test that max_period returns 0.0 for static movement."""
- op_movement = self.static_op_movement
- self.assertEqual(op_movement.max_period, 0.0)
-
- def test_max_period_with_movement(self):
- """Test that max_period returns correct period for movement."""
- op_movement = self.basic_op_movement
- # periodVCg__E is 2.0, so max should be 2.0.
- self.assertEqual(op_movement.max_period, 2.0)
-
- def test_max_period_long_period(self):
- """Test that max_period returns correct value for long period movement."""
- op_movement = self.long_period_op_movement
- # periodVCg__E is 10.0, so max should be 10.0.
- self.assertEqual(op_movement.max_period, 10.0)
-
- def test_generate_operating_points_parameter_validation(self):
- """Test that generate_operating_points validates num_steps and delta_time."""
- op_movement = self.basic_op_movement
-
- # Test invalid num_steps.
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- op_movement.generate_operating_points(num_steps=0, delta_time=0.01)
-
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- op_movement.generate_operating_points(num_steps=-1, delta_time=0.01)
-
- with self.assertRaises(TypeError):
- op_movement.generate_operating_points(num_steps="invalid", delta_time=0.01)
-
- # Test invalid delta_time.
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- op_movement.generate_operating_points(num_steps=10, delta_time=0.0)
-
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- op_movement.generate_operating_points(num_steps=10, delta_time=-0.01)
-
- with self.assertRaises(TypeError):
- op_movement.generate_operating_points(num_steps=10, delta_time="invalid")
-
- def test_generate_operating_points_returns_correct_length(self):
- """Test that generate_operating_points returns list of correct length."""
- op_movement = self.basic_op_movement
-
- test_num_steps = [1, 5, 10, 25, 50, 100, 200]
- for num_steps in test_num_steps:
- with self.subTest(num_steps=num_steps):
- operating_points = op_movement.generate_operating_points(
- num_steps=num_steps, delta_time=0.01
- )
- self.assertEqual(len(operating_points), num_steps)
-
- def test_generate_operating_points_returns_correct_types(self):
- """Test that generate_operating_points returns OperatingPoints."""
- op_movement = self.basic_op_movement
- operating_points = op_movement.generate_operating_points(
- num_steps=10, delta_time=0.01
- )
-
- # Verify all elements are OperatingPoints.
- for op in operating_points:
- self.assertIsInstance(op, ps.operating_point.OperatingPoint)
-
- def test_generate_operating_points_preserves_non_changing_attributes(self):
- """Test that generate_operating_points preserves non-changing attributes."""
- op_movement = self.basic_op_movement
- base_op = op_movement.base_operating_point
-
- operating_points = op_movement.generate_operating_points(
- num_steps=10, delta_time=0.01
- )
-
- # Check that non-changing attributes are preserved.
- for op in operating_points:
- self.assertEqual(op.rho, base_op.rho)
- self.assertEqual(op.alpha, base_op.alpha)
- self.assertEqual(op.beta, base_op.beta)
- self.assertEqual(op.externalFX_W, base_op.externalFX_W)
- self.assertEqual(op.nu, base_op.nu)
-
- def test_generate_operating_points_static_movement(self):
- """Test that static movement produces constant vCg__E."""
- op_movement = self.static_op_movement
- base_op = op_movement.base_operating_point
-
- operating_points = op_movement.generate_operating_points(
- num_steps=50, delta_time=0.01
- )
-
- # All OperatingPoints should have same vCg__E.
- for op in operating_points:
- self.assertEqual(op.vCg__E, base_op.vCg__E)
-
- def test_generate_operating_points_different_delta_time(self):
- """Test generate_operating_points with various delta_time values."""
- op_movement = self.basic_op_movement
-
- delta_time_list = [0.001, 0.01, 0.1, 1.0]
- num_steps = 50
- for delta_time in delta_time_list:
- with self.subTest(delta_time=delta_time):
- operating_points = op_movement.generate_operating_points(
- num_steps=num_steps, delta_time=delta_time
- )
- self.assertEqual(len(operating_points), num_steps)
-
- def test_phase_offset_shifts_initial_value(self):
- """Test that phase shifts initial vCg__E correctly."""
- op_movement = self.phase_offset_op_movement
- operating_points = op_movement.generate_operating_points(
- num_steps=100, delta_time=0.01
+ self.assertIsInstance(
+ operating_point_movement,
+ ps.movements.operating_point_movement.OperatingPointMovement,
)
- # Extract vCg__E values.
- vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
-
- # Verify that phase offset causes non-zero initial value different from base.
- # With 90 degree phase offset, the first value should not be at the base.
- self.assertFalse(
- np.isclose(
- vCg_values[0], op_movement.base_operating_point.vCg__E, atol=1e-10
- )
+ def test_generate_operating_points_returns_operating_points(self):
+ """Test that generate_operating_points returns OperatingPoints when called
+ through the public class.
+ """
+ operating_point_movement = (
+ operating_point_movement_fixtures.make_sine_spacing_operating_point_movement_fixture()
)
-
- def test_custom_spacing_function_works(self):
- """Test that custom spacing function works for vCg__E."""
- op_movement = self.custom_spacing_op_movement
- operating_points = op_movement.generate_operating_points(
- num_steps=100, delta_time=0.01
+ operating_points = operating_point_movement.generate_operating_points(
+ num_steps=5, delta_time=0.01
)
-
- # Extract vCg__E values.
- vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
-
- # Verify that values vary (not constant).
- self.assertFalse(np.allclose(vCg_values, vCg_values[0]))
-
- # Verify that values are within expected range.
- # For custom_harmonic with amp=15.0 and base=100.0, values should be roughly
- # in [85.0, 115.0].
- self.assertTrue(np.all(vCg_values >= 80.0))
- self.assertTrue(np.all(vCg_values <= 120.0))
-
- def test_custom_function_validation_invalid_start_value(self):
- """Test that custom function with invalid start value raises error."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Define invalid custom function that doesn't start at 0.
- def invalid_nonzero_start(x):
- return np.sin(x) + 1.0
-
- # Should raise error during generation.
- with self.assertRaises(ValueError):
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=1.0,
- spacingVCg__E=invalid_nonzero_start,
- )
- op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_invalid_end_value(self):
- """Test that custom function with invalid end value raises error."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Define invalid custom function that doesn't return to 0 at 2*pi.
- def invalid_nonzero_end(x):
- return np.sin(x) + 0.1
-
- with self.assertRaises(ValueError):
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=1.0,
- spacingVCg__E=invalid_nonzero_end,
+ self.assertEqual(len(operating_points), 5)
+ for operating_point in operating_points:
+ self.assertIsInstance(
+ operating_point,
+ ps.operating_point.OperatingPoint,
)
- op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_invalid_mean(self):
- """Test that custom function with invalid mean raises error."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Define invalid custom function with non-zero mean.
- def invalid_nonzero_mean(x):
- return np.sin(x) + 0.5
-
- with self.assertRaises(ValueError):
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=1.0,
- spacingVCg__E=invalid_nonzero_mean,
- )
- op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_invalid_amplitude(self):
- """Test that custom function with invalid amplitude raises error."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Define invalid custom function with wrong amplitude.
- def invalid_wrong_amplitude(x):
- return 2.0 * np.sin(x)
-
- with self.assertRaises(ValueError):
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=1.0,
- spacingVCg__E=invalid_wrong_amplitude,
- )
- op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_not_periodic(self):
- """Test that custom function that is not periodic raises error."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Define invalid custom function that is not periodic.
- def invalid_not_periodic(x):
- return np.tanh(x)
-
- with self.assertRaises(ValueError):
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=1.0,
- spacingVCg__E=invalid_not_periodic,
- )
- op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_returns_non_finite(self):
- """Test that custom function returning NaN or Inf raises error."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Define invalid custom function that returns NaN.
- def invalid_non_finite(x):
- return np.where(x < np.pi, np.sin(x), np.nan)
-
- with self.assertRaises(ValueError):
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=1.0,
- spacingVCg__E=invalid_non_finite,
- )
- op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_wrong_shape(self):
- """Test that custom function returning wrong shape raises error."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Define invalid custom function that returns wrong shape.
- def invalid_wrong_shape(x):
- return np.sin(x)[: len(x) // 2]
-
- with self.assertRaises(ValueError):
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=1.0,
- periodVCg__E=1.0,
- spacingVCg__E=invalid_wrong_shape,
- )
- op_movement.generate_operating_points(num_steps=10, delta_time=0.01)
-
- def test_unsafe_amplitude_causes_error(self):
- """Test that amplitude too high for base vCg__E causes error during generation."""
- # Use low-speed operating point with vCg__E = 10.0.
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- # Create OperatingPointMovement with amplitude that will drive vCg__E negative.
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=15.0,
- periodVCg__E=1.0,
- spacingVCg__E="sine",
- phaseVCg__E=-90.0,
- )
-
- # Generating OperatingPoints should raise ValueError when vCg__E goes negative.
- with self.assertRaises(ValueError) as context:
- op_movement.generate_operating_points(num_steps=100, delta_time=0.01)
-
- # Verify the error message is about vCg__E validation.
- self.assertIn("vCg__E", str(context.exception))
-
- def test_large_amplitude_movement(self):
- """Test OperatingPointMovement with large amplitude."""
- op_movement = self.large_amplitude_op_movement
- operating_points = op_movement.generate_operating_points(
- num_steps=100, delta_time=0.01
- )
-
- # Verify that all OperatingPoints have valid vCg__E values.
- for op in operating_points:
- self.assertGreater(op.vCg__E, 0.0)
-
- # Extract vCg__E values.
- vCg_values = np.array([op.vCg__E for op in operating_points], dtype=float)
-
- # Verify that values vary significantly.
- self.assertGreater(float(np.max(vCg_values)) - float(np.min(vCg_values)), 50.0)
-
- def test_integer_parameters_converted_to_float(self):
- """Test that integer parameters are converted to float internally."""
- base_op = operating_point_fixtures.make_basic_operating_point_fixture()
-
- op_movement = ps.movements.operating_point_movement.OperatingPointMovement(
- base_operating_point=base_op,
- ampVCg__E=5,
- periodVCg__E=2,
- phaseVCg__E=90,
- )
-
- self.assertIsInstance(op_movement.ampVCg__E, float)
- self.assertIsInstance(op_movement.periodVCg__E, float)
- self.assertIsInstance(op_movement.phaseVCg__E, float)
- self.assertEqual(op_movement.ampVCg__E, 5.0)
- self.assertEqual(op_movement.periodVCg__E, 2.0)
- self.assertEqual(op_movement.phaseVCg__E, 90.0)
-
-
-class TestOperatingPointMovementImmutability(unittest.TestCase):
- """Tests for OperatingPointMovement attribute immutability."""
-
- def setUp(self):
- """Set up test fixtures for immutability tests."""
- self.basic_op_movement = (
- operating_point_movement_fixtures.make_basic_operating_point_movement_fixture()
- )
-
- def test_immutable_base_operating_point_property(self):
- """Test that base_operating_point property is read only."""
- new_op = operating_point_fixtures.make_high_speed_operating_point_fixture()
- with self.assertRaises(AttributeError):
- self.basic_op_movement.base_operating_point = new_op
-
- def test_immutable_ampVCg__E_property(self):
- """Test that ampVCg__E property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_op_movement.ampVCg__E = 10.0
-
- def test_immutable_periodVCg__E_property(self):
- """Test that periodVCg__E property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_op_movement.periodVCg__E = 5.0
-
- def test_immutable_spacingVCg__E_property(self):
- """Test that spacingVCg__E property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_op_movement.spacingVCg__E = "uniform"
-
- def test_immutable_phaseVCg__E_property(self):
- """Test that phaseVCg__E property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_op_movement.phaseVCg__E = 45.0
if __name__ == "__main__":
diff --git a/tests/unit/test_oscillation.py b/tests/unit/test_oscillation.py
new file mode 100644
index 000000000..1684b89cd
--- /dev/null
+++ b/tests/unit/test_oscillation.py
@@ -0,0 +1,132 @@
+"""This module contains a class to test oscillation functions."""
+
+import unittest
+
+import numpy.testing as npt
+
+# noinspection PyProtectedMember
+from pterasoftware import _oscillation
+from tests.unit.fixtures import oscillation_fixtures
+
+
+class TestOscillation(unittest.TestCase):
+ """This is a class with functions to test oscillation functions."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures once for all oscillation function tests."""
+ # Parameter fixtures.
+ (
+ cls.static_amp,
+ cls.static_period,
+ cls.static_phase,
+ cls.static_base,
+ ) = oscillation_fixtures.make_static_parameters_fixture()
+
+ (
+ cls.phase_offset_amp,
+ cls.phase_offset_period,
+ cls.phase_offset_phase,
+ cls.phase_offset_base,
+ ) = oscillation_fixtures.make_phase_offset_parameters_fixture()
+
+ (
+ cls.max_phase_amp,
+ cls.max_phase_period,
+ cls.max_phase_phase,
+ cls.max_phase_base,
+ ) = oscillation_fixtures.make_max_phase_parameters_fixture()
+
+ # Time fixture.
+ cls.time = oscillation_fixtures.make_time_fixture()
+
+ # Custom function fixtures.
+ cls.valid_custom_sine = staticmethod(
+ oscillation_fixtures.make_valid_custom_sine_function_fixture()
+ )
+
+ def test_oscillating_sin_at_time_static_parameters(self):
+ """Test oscillating_sin_at_time with static parameters."""
+ result = _oscillation.oscillating_sin_at_time(
+ amp=self.static_amp,
+ period=self.static_period,
+ phase=self.static_phase,
+ base=self.static_base,
+ time=self.time,
+ )
+
+ # Verify output is equal to base.
+ npt.assert_allclose(result, self.static_base, rtol=1e-10, atol=1e-14)
+
+ def test_oscillating_sin_at_time_phase_offset(self):
+ """Test oscillating_sin_at_time with phase offset."""
+ result = _oscillation.oscillating_sin_at_time(
+ amp=self.phase_offset_amp,
+ period=self.phase_offset_period,
+ phase=self.phase_offset_phase,
+ base=self.phase_offset_base,
+ time=0.0,
+ )
+
+ # Verify that phase offset shifts the waveform.
+ # At t=0.0, sine with 90.0 degree phase should equal the amplitude.
+ npt.assert_allclose(result, self.phase_offset_amp, rtol=1e-10, atol=1e-14)
+
+ def test_oscillating_sin_at_time_max_phase(self):
+ """Test oscillating_sin_at_time with maximum phase."""
+ result = _oscillation.oscillating_sin_at_time(
+ amp=self.max_phase_amp,
+ period=self.max_phase_period,
+ phase=self.max_phase_phase,
+ base=self.max_phase_base,
+ time=0.0,
+ )
+
+ # Verify that max phase (180.0 degrees) inverts the waveform.
+ # At t=0.0, sine with 180.0 degree phase should be approximately 0.0.
+ npt.assert_allclose(result, 0.0, rtol=1e-10, atol=1e-14)
+
+ def test_oscillating_lin_at_time_static_parameters(self):
+ """Test oscillating_lin_at_time with static parameters."""
+ result = _oscillation.oscillating_lin_at_time(
+ amp=self.static_amp,
+ period=self.static_period,
+ phase=self.static_phase,
+ base=self.static_base,
+ time=self.time,
+ )
+
+ # Verify output is equal to base.
+ npt.assert_allclose(result, self.static_base, rtol=1e-10, atol=1e-14)
+
+ def test_oscillating_lin_at_time_phase_offset(self):
+ """Test oscillating_lin_at_time with phase offset."""
+ result = _oscillation.oscillating_lin_at_time(
+ amp=self.phase_offset_amp,
+ period=self.phase_offset_period,
+ phase=self.phase_offset_phase,
+ base=self.phase_offset_base,
+ time=0.0,
+ )
+
+ # Verify that phase offset shifts the waveform.
+ # At t=0.0, triangular wave with 90.0 degree phase should be near maximum.
+ self.assertGreater(result, 0.5 * self.phase_offset_amp)
+
+ def test_oscillating_custom_at_time_static_parameters(self):
+ """Test oscillating_custom_at_time with static parameters."""
+ result = _oscillation.oscillating_custom_at_time(
+ amp=self.static_amp,
+ period=self.static_period,
+ base=self.static_base,
+ phase=self.static_phase,
+ time=self.time,
+ custom_function=self.valid_custom_sine,
+ )
+
+ # Verify output is equal to base.
+ npt.assert_allclose(result, self.static_base, rtol=1e-10, atol=1e-14)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_problems.py b/tests/unit/test_problems.py
index 9fdff4974..bc8bb468b 100644
--- a/tests/unit/test_problems.py
+++ b/tests/unit/test_problems.py
@@ -1,6 +1,5 @@
"""This module contains classes to test SteadyProblems and UnsteadyProblems."""
-import math
import unittest
import numpy as np
@@ -269,12 +268,6 @@ def setUpClass(cls):
cls.only_final_results_unsteady_problem = (
problem_fixtures.make_only_final_results_unsteady_problem_fixture()
)
- cls.static_unsteady_problem = (
- problem_fixtures.make_static_unsteady_problem_fixture()
- )
- cls.cyclic_unsteady_problem = (
- problem_fixtures.make_cyclic_unsteady_problem_fixture()
- )
cls.multi_airplane_unsteady_problem = (
problem_fixtures.make_multi_airplane_unsteady_problem_fixture()
)
@@ -356,108 +349,6 @@ def test_delta_time_attribute(self):
self.basic_unsteady_problem.movement.delta_time,
)
- def test_first_averaging_step_static_movement(self):
- """Test first_averaging_step for static Movement."""
- # For static Movement (max_period = 0), first_averaging_step should be
- # num_steps - 1.
- expected_first_averaging_step = self.static_unsteady_problem.num_steps - 1
- self.assertEqual(
- self.static_unsteady_problem.first_averaging_step,
- expected_first_averaging_step,
- )
-
- def test_first_averaging_step_cyclic_movement(self):
- """Test first_averaging_step for cyclic Movement."""
- # For cyclic Movement (lcm_period > 0), first_averaging_step should be
- # calculated based on the lcm_period.
- movement_lcm_period = self.cyclic_unsteady_problem.movement.lcm_period
- expected_first_averaging_step = max(
- 0,
- math.floor(
- self.cyclic_unsteady_problem.num_steps
- - (movement_lcm_period / self.cyclic_unsteady_problem.delta_time)
- ),
- )
- self.assertEqual(
- self.cyclic_unsteady_problem.first_averaging_step,
- expected_first_averaging_step,
- )
-
- def test_first_results_step_only_final_results_false(self):
- """Test first_results_step when only_final_results is False."""
- # When only_final_results is False, first_results_step should be 0.
- self.assertEqual(self.basic_unsteady_problem.first_results_step, 0)
-
- def test_first_results_step_only_final_results_true(self):
- """Test first_results_step when only_final_results is True."""
- # When only_final_results is True, first_results_step should equal
- # first_averaging_step.
- self.assertEqual(
- self.only_final_results_unsteady_problem.first_results_step,
- self.only_final_results_unsteady_problem.first_averaging_step,
- )
-
- def test_initialization_of_load_lists(self):
- """Test that load lists are initialized as empty."""
- # All load lists should be initialized as empty lists.
- self.assertIsInstance(self.basic_unsteady_problem.finalForces_W, list)
- self.assertEqual(len(self.basic_unsteady_problem.finalForces_W), 0)
-
- self.assertIsInstance(
- self.basic_unsteady_problem.finalForceCoefficients_W, list
- )
- self.assertEqual(len(self.basic_unsteady_problem.finalForceCoefficients_W), 0)
-
- self.assertIsInstance(self.basic_unsteady_problem.finalMoments_W_CgP1, list)
- self.assertEqual(len(self.basic_unsteady_problem.finalMoments_W_CgP1), 0)
-
- self.assertIsInstance(
- self.basic_unsteady_problem.finalMomentCoefficients_W_CgP1, list
- )
- self.assertEqual(
- len(self.basic_unsteady_problem.finalMomentCoefficients_W_CgP1), 0
- )
-
- self.assertIsInstance(self.basic_unsteady_problem.finalMeanForces_W, list)
- self.assertEqual(len(self.basic_unsteady_problem.finalMeanForces_W), 0)
-
- self.assertIsInstance(
- self.basic_unsteady_problem.finalMeanForceCoefficients_W, list
- )
- self.assertEqual(
- len(self.basic_unsteady_problem.finalMeanForceCoefficients_W), 0
- )
-
- self.assertIsInstance(self.basic_unsteady_problem.finalMeanMoments_W_CgP1, list)
- self.assertEqual(len(self.basic_unsteady_problem.finalMeanMoments_W_CgP1), 0)
-
- self.assertIsInstance(
- self.basic_unsteady_problem.finalMeanMomentCoefficients_W_CgP1, list
- )
- self.assertEqual(
- len(self.basic_unsteady_problem.finalMeanMomentCoefficients_W_CgP1), 0
- )
-
- self.assertIsInstance(self.basic_unsteady_problem.finalRmsForces_W, list)
- self.assertEqual(len(self.basic_unsteady_problem.finalRmsForces_W), 0)
-
- self.assertIsInstance(
- self.basic_unsteady_problem.finalRmsForceCoefficients_W, list
- )
- self.assertEqual(
- len(self.basic_unsteady_problem.finalRmsForceCoefficients_W), 0
- )
-
- self.assertIsInstance(self.basic_unsteady_problem.finalRmsMoments_W_CgP1, list)
- self.assertEqual(len(self.basic_unsteady_problem.finalRmsMoments_W_CgP1), 0)
-
- self.assertIsInstance(
- self.basic_unsteady_problem.finalRmsMomentCoefficients_W_CgP1, list
- )
- self.assertEqual(
- len(self.basic_unsteady_problem.finalRmsMomentCoefficients_W_CgP1), 0
- )
-
def test_steady_problems_tuple_initialization(self):
"""Test that steady_problems tuple is initialized correctly."""
# steady_problems tuple should be initialized with correct length.
@@ -492,17 +383,6 @@ def test_steady_problems_list_operating_points(self):
steady_problem.operating_point, ps.operating_point.OperatingPoint
)
- def test_only_final_results_accepts_numpy_bool(self):
- """Test that only_final_results accepts numpy bool values."""
- # Create a fresh movement fixture for this test.
- movement = movement_fixtures.make_basic_movement_fixture()
- unsteady_problem = ps.problems.UnsteadyProblem(
- movement=movement,
- only_final_results=np.bool_(True),
- )
- self.assertTrue(unsteady_problem.only_final_results)
- self.assertIsInstance(unsteady_problem.only_final_results, bool)
-
def test_initialization_multiple_airplanes(self):
"""Test UnsteadyProblem initialization with multiple Airplanes."""
# Test that UnsteadyProblem with multiple Airplanes initializes correctly.
@@ -531,31 +411,6 @@ def test_immutable_movement_property(self):
with self.assertRaises(AttributeError):
self.basic_unsteady_problem.movement = new_movement
- def test_immutable_only_final_results_property(self):
- """Test that only_final_results property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_unsteady_problem.only_final_results = True
-
- def test_immutable_num_steps_property(self):
- """Test that num_steps property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_unsteady_problem.num_steps = 100
-
- def test_immutable_delta_time_property(self):
- """Test that delta_time property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_unsteady_problem.delta_time = 0.1
-
- def test_immutable_first_averaging_step_property(self):
- """Test that first_averaging_step property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_unsteady_problem.first_averaging_step = 0
-
- def test_immutable_first_results_step_property(self):
- """Test that first_results_step property is read only."""
- with self.assertRaises(AttributeError):
- self.basic_unsteady_problem.first_results_step = 0
-
def test_immutable_steady_problems_property(self):
"""Test that steady_problems property is read only."""
with self.assertRaises(AttributeError):
@@ -571,15 +426,6 @@ def test_steady_problems_tuple_immutability(self):
problem_fixtures.make_basic_steady_problem_fixture()
)
- def test_mutable_load_lists(self):
- """Test that load lists remain mutable for solver population."""
- # The load lists should be mutable so the solver can populate them.
- self.basic_unsteady_problem.finalForces_W.append(np.array([1.0, 2.0, 3.0]))
- self.assertEqual(len(self.basic_unsteady_problem.finalForces_W), 1)
-
- # Clean up.
- self.basic_unsteady_problem.finalForces_W.pop()
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/test_serialization.py b/tests/unit/test_serialization.py
index da25a16f0..06323eab0 100644
--- a/tests/unit/test_serialization.py
+++ b/tests/unit/test_serialization.py
@@ -8,6 +8,12 @@
import numpy as np
import numpy.testing as npt
+# noinspection PyProtectedMember
+from pterasoftware._oscillation import (
+ oscillating_lin_at_time,
+ oscillating_sin_at_time,
+)
+
# noinspection PyProtectedMember
from pterasoftware._panel import Panel
@@ -36,12 +42,6 @@
from pterasoftware.geometry.airplane import Airplane
from pterasoftware.geometry.wing import Wing
from pterasoftware.geometry.wing_cross_section import WingCrossSection
-
-# noinspection PyProtectedMember
-from pterasoftware.movements._functions import (
- oscillating_linspaces,
- oscillating_sinspaces,
-)
from pterasoftware.movements.airplane_movement import AirplaneMovement
from pterasoftware.movements.movement import Movement
from pterasoftware.movements.operating_point_movement import OperatingPointMovement
@@ -436,19 +436,19 @@ def test_nested_tuple(self):
self.assertEqual(len(inner["items"]), 2)
def test_callable_sine(self):
- """Tests that the oscillating_sinspaces function serializes by name.
+ """Tests that the oscillating_sin_at_time function serializes by name.
:return: None
"""
- result = _serialize_value(oscillating_sinspaces)
+ result = _serialize_value(oscillating_sin_at_time)
self.assertEqual(result, {"_type": "callable", "name": "sine"})
def test_callable_uniform(self):
- """Tests that the oscillating_linspaces function serializes by name.
+ """Tests that the oscillating_lin_at_time function serializes by name.
:return: None
"""
- result = _serialize_value(oscillating_linspaces)
+ result = _serialize_value(oscillating_lin_at_time)
self.assertEqual(result, {"_type": "callable", "name": "uniform"})
def test_callable_custom_raises(self):
@@ -562,21 +562,21 @@ def test_list(self):
def test_callable_sine(self):
"""Tests that a callable dict with name "sine" deserializes to
- oscillating_sinspaces.
+ oscillating_sin_at_time.
:return: None
"""
result = _deserialize_value({"_type": "callable", "name": "sine"})
- self.assertIs(result, oscillating_sinspaces)
+ self.assertIs(result, oscillating_sin_at_time)
def test_callable_uniform(self):
"""Tests that a callable dict with name "uniform" deserializes to
- oscillating_linspaces.
+ oscillating_lin_at_time.
:return: None
"""
result = _deserialize_value({"_type": "callable", "name": "uniform"})
- self.assertIs(result, oscillating_linspaces)
+ self.assertIs(result, oscillating_lin_at_time)
def test_callable_unknown_name_raises(self):
"""Tests that an unknown callable name raises a ValueError.
@@ -723,23 +723,23 @@ def test_nested_containers(self):
self.assertIsInstance(result[1], tuple)
def test_callable_sine(self):
- """Tests round trip for the oscillating_sinspaces function.
+ """Tests round trip for the oscillating_sin_at_time function.
:return: None
"""
self.assertIs(
- _deserialize_value(_serialize_value(oscillating_sinspaces)),
- oscillating_sinspaces,
+ _deserialize_value(_serialize_value(oscillating_sin_at_time)),
+ oscillating_sin_at_time,
)
def test_callable_uniform(self):
- """Tests round trip for the oscillating_linspaces function.
+ """Tests round trip for the oscillating_lin_at_time function.
:return: None
"""
self.assertIs(
- _deserialize_value(_serialize_value(oscillating_linspaces)),
- oscillating_linspaces,
+ _deserialize_value(_serialize_value(oscillating_lin_at_time)),
+ oscillating_lin_at_time,
)
diff --git a/tests/unit/test_slots.py b/tests/unit/test_slots.py
index aa98e3ade..ef15b2d5d 100644
--- a/tests/unit/test_slots.py
+++ b/tests/unit/test_slots.py
@@ -9,12 +9,17 @@
import pterasoftware as ps
# noinspection PyProtectedMember
-from pterasoftware import _panel
+from pterasoftware import _core, _panel
# noinspection PyProtectedMember
from pterasoftware._vortices import _line_vortex
from tests.unit.fixtures import (
airplane_movement_fixtures,
+ core_airplane_movement_fixtures,
+ core_movement_fixtures,
+ core_operating_point_movement_fixtures,
+ core_wing_cross_section_movement_fixtures,
+ core_wing_movement_fixtures,
geometry_fixtures,
horseshoe_vortex_fixtures,
line_vortex_fixtures,
@@ -935,7 +940,10 @@ def test_deepcopy(self):
class TestUnsteadyProblemSlots(unittest.TestCase):
"""This class contains tests to verify __slots__ enforcement on
- UnsteadyProblem.
+ UnsteadyProblem. Core-owned properties (only_final_results, num_steps,
+ delta_time, first_averaging_step, first_results_step, and the mutable load
+ lists) are tested at the CoreUnsteadyProblem level. This class tests
+ UnsteadyProblem-specific slots and deepcopy.
"""
def setUp(self):
@@ -955,43 +963,21 @@ def test_dynamic_attribute_raises(self):
with self.assertRaises(AttributeError):
self.unsteady_problem.nonexistent_attribute = 42
+ def test_subclass(self):
+ """Test that UnsteadyProblem is a subclass of CoreUnsteadyProblem."""
+ self.assertIsInstance(self.unsteady_problem, _core.CoreUnsteadyProblem)
+
def test_property_access(self):
- """Test that all properties remain accessible after adding __slots__."""
- # Immutable properties.
+ """Test that UnsteadyProblem-specific properties are accessible."""
self.assertIsInstance(
self.unsteady_problem.movement, ps.movements.movement.Movement
)
- self.assertIsInstance(self.unsteady_problem.only_final_results, bool)
- self.assertIsInstance(self.unsteady_problem.num_steps, int)
- self.assertIsInstance(self.unsteady_problem.delta_time, float)
- self.assertIsInstance(self.unsteady_problem.first_averaging_step, int)
- self.assertIsInstance(self.unsteady_problem.first_results_step, int)
self.assertIsInstance(self.unsteady_problem.steady_problems, tuple)
self.assertEqual(
len(self.unsteady_problem.steady_problems),
self.unsteady_problem.num_steps,
)
- # Mutable list attributes (initialized empty).
- self.assertIsInstance(self.unsteady_problem.finalForces_W, list)
- self.assertIsInstance(self.unsteady_problem.finalForceCoefficients_W, list)
- self.assertIsInstance(self.unsteady_problem.finalMoments_W_CgP1, list)
- self.assertIsInstance(
- self.unsteady_problem.finalMomentCoefficients_W_CgP1, list
- )
- self.assertIsInstance(self.unsteady_problem.finalMeanForces_W, list)
- self.assertIsInstance(self.unsteady_problem.finalMeanForceCoefficients_W, list)
- self.assertIsInstance(self.unsteady_problem.finalMeanMoments_W_CgP1, list)
- self.assertIsInstance(
- self.unsteady_problem.finalMeanMomentCoefficients_W_CgP1, list
- )
- self.assertIsInstance(self.unsteady_problem.finalRmsForces_W, list)
- self.assertIsInstance(self.unsteady_problem.finalRmsForceCoefficients_W, list)
- self.assertIsInstance(self.unsteady_problem.finalRmsMoments_W_CgP1, list)
- self.assertIsInstance(
- self.unsteady_problem.finalRmsMomentCoefficients_W_CgP1, list
- )
-
def test_deepcopy(self):
"""Test that copy.deepcopy produces a correct independent copy."""
copied = copy.deepcopy(self.unsteady_problem)
@@ -999,13 +985,7 @@ def test_deepcopy(self):
# Verify the copy is a separate instance.
self.assertIsNot(copied, self.unsteady_problem)
- # Verify property values match.
- self.assertEqual(copied.num_steps, self.unsteady_problem.num_steps)
- self.assertEqual(copied.delta_time, self.unsteady_problem.delta_time)
- self.assertEqual(
- copied.only_final_results,
- self.unsteady_problem.only_final_results,
- )
+ # Verify UnsteadyProblem-specific property values match.
self.assertEqual(
len(copied.steady_problems),
len(self.unsteady_problem.steady_problems),
@@ -1015,369 +995,622 @@ def test_deepcopy(self):
self.assertIsNot(copied.movement, self.unsteady_problem.movement)
-class TestOperatingPointMovementSlots(unittest.TestCase):
+class TestCoreOperatingPointMovementSlots(unittest.TestCase):
"""This class contains tests to verify __slots__ enforcement on
- OperatingPointMovement.
+ CoreOperatingPointMovement.
"""
def setUp(self):
- """Set up test fixtures for OperatingPointMovement slots tests."""
- self.static_opm = (
- operating_point_movement_fixtures.make_static_operating_point_movement_fixture()
+ """Set up test fixtures for CoreOperatingPointMovement slots tests."""
+ self.static_copm = (
+ core_operating_point_movement_fixtures.make_static_core_operating_point_movement_fixture()
)
- self.sine_opm = (
- operating_point_movement_fixtures.make_sine_spacing_operating_point_movement_fixture()
+ self.sine_copm = (
+ core_operating_point_movement_fixtures.make_sine_spacing_core_operating_point_movement_fixture()
)
def test_slots_defined(self):
- """Test that __slots__ is defined on OperatingPointMovement."""
- self.assertTrue(
- hasattr(
- ps.movements.operating_point_movement.OperatingPointMovement,
- "__slots__",
- )
- )
+ """Test that __slots__ is defined on CoreOperatingPointMovement."""
+ self.assertTrue(hasattr(_core.CoreOperatingPointMovement, "__slots__"))
def test_no_instance_dict(self):
- """Test that OperatingPointMovement instances have no __dict__."""
- self.assertFalse(hasattr(self.static_opm, "__dict__"))
+ """Test that CoreOperatingPointMovement instances have no __dict__."""
+ self.assertFalse(hasattr(self.static_copm, "__dict__"))
def test_dynamic_attribute_raises(self):
"""Test that dynamic attribute assignment raises AttributeError."""
with self.assertRaises(AttributeError):
- self.static_opm.nonexistent_attribute = 42
+ self.static_copm.nonexistent_attribute = 42
def test_property_access(self):
"""Test that all properties remain accessible after adding __slots__."""
# Immutable properties on sine fixture.
self.assertIsInstance(
- self.sine_opm.base_operating_point,
+ self.sine_copm.base_operating_point,
ps.operating_point.OperatingPoint,
)
- self.assertEqual(self.sine_opm.ampVCg__E, 10.0)
- self.assertEqual(self.sine_opm.periodVCg__E, 1.0)
- self.assertEqual(self.sine_opm.spacingVCg__E, "sine")
- self.assertEqual(self.sine_opm.phaseVCg__E, 0.0)
+ self.assertEqual(self.sine_copm.ampVCg__E, 10.0)
+ self.assertEqual(self.sine_copm.periodVCg__E, 1.0)
+ self.assertEqual(self.sine_copm.spacingVCg__E, "sine")
+ self.assertEqual(self.sine_copm.phaseVCg__E, 0.0)
# Cached computed property.
- self.assertEqual(self.sine_opm.max_period, 1.0)
+ self.assertEqual(self.sine_copm.max_period, 1.0)
# Static fixture has zero max_period.
- self.assertEqual(self.static_opm.max_period, 0.0)
+ self.assertEqual(self.static_copm.max_period, 0.0)
def test_deepcopy(self):
"""Test that copy.deepcopy produces a correct independent copy."""
# Access cached property before copying.
- _ = self.sine_opm.max_period
+ _ = self.sine_copm.max_period
- copied = copy.deepcopy(self.sine_opm)
+ copied = copy.deepcopy(self.sine_copm)
# Verify the copy is a separate instance.
- self.assertIsNot(copied, self.sine_opm)
+ self.assertIsNot(copied, self.sine_copm)
# Verify property values match.
- self.assertEqual(copied.ampVCg__E, self.sine_opm.ampVCg__E)
- self.assertEqual(copied.periodVCg__E, self.sine_opm.periodVCg__E)
- self.assertEqual(copied.spacingVCg__E, self.sine_opm.spacingVCg__E)
- self.assertEqual(copied.phaseVCg__E, self.sine_opm.phaseVCg__E)
- self.assertEqual(copied.max_period, self.sine_opm.max_period)
+ self.assertEqual(copied.ampVCg__E, self.sine_copm.ampVCg__E)
+ self.assertEqual(copied.periodVCg__E, self.sine_copm.periodVCg__E)
+ self.assertEqual(copied.spacingVCg__E, self.sine_copm.spacingVCg__E)
+ self.assertEqual(copied.phaseVCg__E, self.sine_copm.phaseVCg__E)
+ self.assertEqual(copied.max_period, self.sine_copm.max_period)
# Verify base OperatingPoint is independent.
self.assertIsNot(
- copied.base_operating_point, self.sine_opm.base_operating_point
+ copied.base_operating_point, self.sine_copm.base_operating_point
)
-class TestWingCrossSectionMovementSlots(unittest.TestCase):
+class TestCoreWingCrossSectionMovementSlots(unittest.TestCase):
"""This class contains tests to verify __slots__ enforcement on
- WingCrossSectionMovement.
+ CoreWingCrossSectionMovement.
"""
def setUp(self):
- """Set up test fixtures for WingCrossSectionMovement slots tests."""
- self.wcsm = (
- wing_cross_section_movement_fixtures.make_basic_wing_cross_section_movement_fixture()
+ """Set up test fixtures for CoreWingCrossSectionMovement slots tests."""
+ self.cwcsm = (
+ core_wing_cross_section_movement_fixtures.make_basic_core_wing_cross_section_movement_fixture()
)
def test_slots_defined(self):
- """Test that __slots__ is defined on WingCrossSectionMovement."""
- self.assertTrue(
- hasattr(
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement,
- "__slots__",
- )
- )
+ """Test that __slots__ is defined on CoreWingCrossSectionMovement."""
+ self.assertTrue(hasattr(_core.CoreWingCrossSectionMovement, "__slots__"))
def test_no_instance_dict(self):
- """Test that WingCrossSectionMovement instances have no __dict__."""
- self.assertFalse(hasattr(self.wcsm, "__dict__"))
+ """Test that CoreWingCrossSectionMovement instances have no __dict__."""
+ self.assertFalse(hasattr(self.cwcsm, "__dict__"))
def test_dynamic_attribute_raises(self):
"""Test that dynamic attribute assignment raises AttributeError."""
with self.assertRaises(AttributeError):
- self.wcsm.nonexistent_attribute = 42
+ self.cwcsm.nonexistent_attribute = 42
def test_property_access(self):
"""Test that all properties remain accessible after adding __slots__."""
# Immutable properties.
self.assertIsInstance(
- self.wcsm.base_wing_cross_section,
+ self.cwcsm.base_wing_cross_section,
ps.geometry.wing_cross_section.WingCrossSection,
)
- self.assertEqual(self.wcsm.ampLp_Wcsp_Lpp.shape, (3,))
- self.assertEqual(self.wcsm.periodLp_Wcsp_Lpp.shape, (3,))
- self.assertIsInstance(self.wcsm.spacingLp_Wcsp_Lpp, tuple)
- self.assertEqual(self.wcsm.phaseLp_Wcsp_Lpp.shape, (3,))
- self.assertEqual(self.wcsm.ampAngles_Wcsp_to_Wcs_ixyz.shape, (3,))
- self.assertEqual(self.wcsm.periodAngles_Wcsp_to_Wcs_ixyz.shape, (3,))
- self.assertIsInstance(self.wcsm.spacingAngles_Wcsp_to_Wcs_ixyz, tuple)
- self.assertEqual(self.wcsm.phaseAngles_Wcsp_to_Wcs_ixyz.shape, (3,))
+ self.assertEqual(self.cwcsm.ampLp_Wcsp_Lpp.shape, (3,))
+ self.assertEqual(self.cwcsm.periodLp_Wcsp_Lpp.shape, (3,))
+ self.assertIsInstance(self.cwcsm.spacingLp_Wcsp_Lpp, tuple)
+ self.assertEqual(self.cwcsm.phaseLp_Wcsp_Lpp.shape, (3,))
+ self.assertEqual(self.cwcsm.ampAngles_Wcsp_to_Wcs_ixyz.shape, (3,))
+ self.assertEqual(self.cwcsm.periodAngles_Wcsp_to_Wcs_ixyz.shape, (3,))
+ self.assertIsInstance(self.cwcsm.spacingAngles_Wcsp_to_Wcs_ixyz, tuple)
+ self.assertEqual(self.cwcsm.phaseAngles_Wcsp_to_Wcs_ixyz.shape, (3,))
# Cached computed properties.
- self.assertIsInstance(self.wcsm.all_periods, tuple)
- self.assertIsInstance(self.wcsm.max_period, float)
+ self.assertIsInstance(self.cwcsm.all_periods, tuple)
+ self.assertIsInstance(self.cwcsm.max_period, float)
def test_deepcopy_method(self):
"""Test that __deepcopy__ produces a correct independent copy."""
# Access cached properties before copying.
- _ = self.wcsm.all_periods
- _ = self.wcsm.max_period
+ _ = self.cwcsm.all_periods
+ _ = self.cwcsm.max_period
- copied = copy.deepcopy(self.wcsm)
+ copied = copy.deepcopy(self.cwcsm)
# Verify the copy is a separate instance.
- self.assertIsNot(copied, self.wcsm)
+ self.assertIsNot(copied, self.cwcsm)
# Verify property values match.
- npt.assert_array_equal(copied.ampLp_Wcsp_Lpp, self.wcsm.ampLp_Wcsp_Lpp)
- npt.assert_array_equal(copied.periodLp_Wcsp_Lpp, self.wcsm.periodLp_Wcsp_Lpp)
- npt.assert_array_equal(copied.phaseLp_Wcsp_Lpp, self.wcsm.phaseLp_Wcsp_Lpp)
+ npt.assert_array_equal(copied.ampLp_Wcsp_Lpp, self.cwcsm.ampLp_Wcsp_Lpp)
+ npt.assert_array_equal(copied.periodLp_Wcsp_Lpp, self.cwcsm.periodLp_Wcsp_Lpp)
+ npt.assert_array_equal(copied.phaseLp_Wcsp_Lpp, self.cwcsm.phaseLp_Wcsp_Lpp)
npt.assert_array_equal(
copied.ampAngles_Wcsp_to_Wcs_ixyz,
- self.wcsm.ampAngles_Wcsp_to_Wcs_ixyz,
+ self.cwcsm.ampAngles_Wcsp_to_Wcs_ixyz,
)
npt.assert_array_equal(
copied.periodAngles_Wcsp_to_Wcs_ixyz,
- self.wcsm.periodAngles_Wcsp_to_Wcs_ixyz,
+ self.cwcsm.periodAngles_Wcsp_to_Wcs_ixyz,
)
npt.assert_array_equal(
copied.phaseAngles_Wcsp_to_Wcs_ixyz,
- self.wcsm.phaseAngles_Wcsp_to_Wcs_ixyz,
+ self.cwcsm.phaseAngles_Wcsp_to_Wcs_ixyz,
)
- self.assertEqual(copied.spacingLp_Wcsp_Lpp, self.wcsm.spacingLp_Wcsp_Lpp)
+ self.assertEqual(copied.spacingLp_Wcsp_Lpp, self.cwcsm.spacingLp_Wcsp_Lpp)
self.assertEqual(
copied.spacingAngles_Wcsp_to_Wcs_ixyz,
- self.wcsm.spacingAngles_Wcsp_to_Wcs_ixyz,
+ self.cwcsm.spacingAngles_Wcsp_to_Wcs_ixyz,
)
# Verify base WingCrossSection is independent.
self.assertIsNot(
- copied.base_wing_cross_section, self.wcsm.base_wing_cross_section
+ copied.base_wing_cross_section, self.cwcsm.base_wing_cross_section
)
# Verify arrays are independent.
- self.assertIsNot(copied.ampLp_Wcsp_Lpp, self.wcsm.ampLp_Wcsp_Lpp)
+ self.assertIsNot(copied.ampLp_Wcsp_Lpp, self.cwcsm.ampLp_Wcsp_Lpp)
self.assertIsNot(
copied.ampAngles_Wcsp_to_Wcs_ixyz,
- self.wcsm.ampAngles_Wcsp_to_Wcs_ixyz,
+ self.cwcsm.ampAngles_Wcsp_to_Wcs_ixyz,
)
def test_deepcopy_no_dict(self):
- """Test that a deep copied WingCrossSectionMovement has no __dict__."""
- copied = copy.deepcopy(self.wcsm)
+ """Test that a deep copied CoreWingCrossSectionMovement has no __dict__."""
+ copied = copy.deepcopy(self.cwcsm)
self.assertFalse(hasattr(copied, "__dict__"))
-class TestWingMovementSlots(unittest.TestCase):
+class TestCoreWingMovementSlots(unittest.TestCase):
"""This class contains tests to verify __slots__ enforcement on
- WingMovement.
+ CoreWingMovement.
"""
def setUp(self):
- """Set up test fixtures for WingMovement slots tests."""
- self.wing_movement = wing_movement_fixtures.make_basic_wing_movement_fixture()
+ """Set up test fixtures for CoreWingMovement slots tests."""
+ self.core_wing_movement = (
+ core_wing_movement_fixtures.make_basic_core_wing_movement_fixture()
+ )
def test_slots_defined(self):
- """Test that __slots__ is defined on WingMovement."""
- self.assertTrue(hasattr(ps.movements.wing_movement.WingMovement, "__slots__"))
+ """Test that __slots__ is defined on CoreWingMovement."""
+ self.assertTrue(hasattr(_core.CoreWingMovement, "__slots__"))
def test_no_instance_dict(self):
- """Test that WingMovement instances have no __dict__."""
- self.assertFalse(hasattr(self.wing_movement, "__dict__"))
+ """Test that CoreWingMovement instances have no __dict__."""
+ self.assertFalse(hasattr(self.core_wing_movement, "__dict__"))
def test_dynamic_attribute_raises(self):
"""Test that dynamic attribute assignment raises AttributeError."""
with self.assertRaises(AttributeError):
- self.wing_movement.nonexistent_attribute = 42
+ self.core_wing_movement.nonexistent_attribute = 42
def test_property_access(self):
"""Test that all properties remain accessible after adding __slots__."""
# Immutable properties.
- self.assertIsInstance(self.wing_movement.base_wing, ps.geometry.wing.Wing)
- self.assertIsInstance(self.wing_movement.wing_cross_section_movements, tuple)
- self.assertEqual(self.wing_movement.ampLer_Gs_Cgs.shape, (3,))
- self.assertEqual(self.wing_movement.periodLer_Gs_Cgs.shape, (3,))
- self.assertIsInstance(self.wing_movement.spacingLer_Gs_Cgs, tuple)
- self.assertEqual(self.wing_movement.phaseLer_Gs_Cgs.shape, (3,))
- self.assertEqual(self.wing_movement.ampAngles_Gs_to_Wn_ixyz.shape, (3,))
- self.assertEqual(self.wing_movement.periodAngles_Gs_to_Wn_ixyz.shape, (3,))
- self.assertIsInstance(self.wing_movement.spacingAngles_Gs_to_Wn_ixyz, tuple)
- self.assertEqual(self.wing_movement.phaseAngles_Gs_to_Wn_ixyz.shape, (3,))
- self.assertEqual(self.wing_movement.rotationPointOffset_Gs_Ler.shape, (3,))
+ self.assertIsInstance(self.core_wing_movement.base_wing, ps.geometry.wing.Wing)
+ self.assertIsInstance(
+ self.core_wing_movement.wing_cross_section_movements, tuple
+ )
+ self.assertEqual(self.core_wing_movement.ampLer_Gs_Cgs.shape, (3,))
+ self.assertEqual(self.core_wing_movement.periodLer_Gs_Cgs.shape, (3,))
+ self.assertIsInstance(self.core_wing_movement.spacingLer_Gs_Cgs, tuple)
+ self.assertEqual(self.core_wing_movement.phaseLer_Gs_Cgs.shape, (3,))
+ self.assertEqual(self.core_wing_movement.ampAngles_Gs_to_Wn_ixyz.shape, (3,))
+ self.assertEqual(self.core_wing_movement.periodAngles_Gs_to_Wn_ixyz.shape, (3,))
+ self.assertIsInstance(
+ self.core_wing_movement.spacingAngles_Gs_to_Wn_ixyz, tuple
+ )
+ self.assertEqual(self.core_wing_movement.phaseAngles_Gs_to_Wn_ixyz.shape, (3,))
+ self.assertEqual(self.core_wing_movement.rotationPointOffset_Gs_Ler.shape, (3,))
# Cached computed properties.
- self.assertIsInstance(self.wing_movement.all_periods, tuple)
- self.assertIsInstance(self.wing_movement.max_period, float)
+ self.assertIsInstance(self.core_wing_movement.all_periods, tuple)
+ self.assertIsInstance(self.core_wing_movement.max_period, float)
def test_deepcopy_method(self):
"""Test that __deepcopy__ produces a correct independent copy."""
# Access cached properties before copying.
- _ = self.wing_movement.all_periods
- _ = self.wing_movement.max_period
+ _ = self.core_wing_movement.all_periods
+ _ = self.core_wing_movement.max_period
- copied = copy.deepcopy(self.wing_movement)
+ copied = copy.deepcopy(self.core_wing_movement)
# Verify the copy is a separate instance.
- self.assertIsNot(copied, self.wing_movement)
+ self.assertIsNot(copied, self.core_wing_movement)
# Verify property values match.
- npt.assert_array_equal(copied.ampLer_Gs_Cgs, self.wing_movement.ampLer_Gs_Cgs)
npt.assert_array_equal(
- copied.periodLer_Gs_Cgs, self.wing_movement.periodLer_Gs_Cgs
+ copied.ampLer_Gs_Cgs, self.core_wing_movement.ampLer_Gs_Cgs
)
npt.assert_array_equal(
- copied.phaseLer_Gs_Cgs, self.wing_movement.phaseLer_Gs_Cgs
+ copied.periodLer_Gs_Cgs, self.core_wing_movement.periodLer_Gs_Cgs
+ )
+ npt.assert_array_equal(
+ copied.phaseLer_Gs_Cgs, self.core_wing_movement.phaseLer_Gs_Cgs
)
npt.assert_array_equal(
copied.ampAngles_Gs_to_Wn_ixyz,
- self.wing_movement.ampAngles_Gs_to_Wn_ixyz,
+ self.core_wing_movement.ampAngles_Gs_to_Wn_ixyz,
)
npt.assert_array_equal(
copied.periodAngles_Gs_to_Wn_ixyz,
- self.wing_movement.periodAngles_Gs_to_Wn_ixyz,
+ self.core_wing_movement.periodAngles_Gs_to_Wn_ixyz,
)
npt.assert_array_equal(
copied.phaseAngles_Gs_to_Wn_ixyz,
- self.wing_movement.phaseAngles_Gs_to_Wn_ixyz,
+ self.core_wing_movement.phaseAngles_Gs_to_Wn_ixyz,
)
npt.assert_array_equal(
copied.rotationPointOffset_Gs_Ler,
- self.wing_movement.rotationPointOffset_Gs_Ler,
+ self.core_wing_movement.rotationPointOffset_Gs_Ler,
)
# Verify base Wing is independent.
- self.assertIsNot(copied.base_wing, self.wing_movement.base_wing)
+ self.assertIsNot(copied.base_wing, self.core_wing_movement.base_wing)
- # Verify WingCrossSectionMovements are independent.
+ # Verify CoreWingCrossSectionMovements are independent.
self.assertIsNot(
copied.wing_cross_section_movements[0],
- self.wing_movement.wing_cross_section_movements[0],
+ self.core_wing_movement.wing_cross_section_movements[0],
)
# Verify arrays are independent.
- self.assertIsNot(copied.ampLer_Gs_Cgs, self.wing_movement.ampLer_Gs_Cgs)
+ self.assertIsNot(copied.ampLer_Gs_Cgs, self.core_wing_movement.ampLer_Gs_Cgs)
def test_deepcopy_no_dict(self):
- """Test that a deep copied WingMovement has no __dict__."""
- copied = copy.deepcopy(self.wing_movement)
+ """Test that a deep copied CoreWingMovement has no __dict__."""
+ copied = copy.deepcopy(self.core_wing_movement)
self.assertFalse(hasattr(copied, "__dict__"))
-class TestAirplaneMovementSlots(unittest.TestCase):
+class TestCoreAirplaneMovementSlots(unittest.TestCase):
"""This class contains tests to verify __slots__ enforcement on
- AirplaneMovement.
+ CoreAirplaneMovement.
"""
def setUp(self):
- """Set up test fixtures for AirplaneMovement slots tests."""
- self.airplane_movement = (
- airplane_movement_fixtures.make_basic_airplane_movement_fixture()
+ """Set up test fixtures for CoreAirplaneMovement slots tests."""
+ self.core_airplane_movement = (
+ core_airplane_movement_fixtures.make_basic_core_airplane_movement_fixture()
)
def test_slots_defined(self):
- """Test that __slots__ is defined on AirplaneMovement."""
- self.assertTrue(
- hasattr(ps.movements.airplane_movement.AirplaneMovement, "__slots__")
- )
+ """Test that __slots__ is defined on CoreAirplaneMovement."""
+ self.assertTrue(hasattr(_core.CoreAirplaneMovement, "__slots__"))
def test_no_instance_dict(self):
- """Test that AirplaneMovement instances have no __dict__."""
- self.assertFalse(hasattr(self.airplane_movement, "__dict__"))
+ """Test that CoreAirplaneMovement instances have no __dict__."""
+ self.assertFalse(hasattr(self.core_airplane_movement, "__dict__"))
def test_dynamic_attribute_raises(self):
"""Test that dynamic attribute assignment raises AttributeError."""
with self.assertRaises(AttributeError):
- self.airplane_movement.nonexistent_attribute = 42
+ self.core_airplane_movement.nonexistent_attribute = 42
def test_property_access(self):
"""Test that all properties remain accessible after adding __slots__."""
# Immutable properties.
self.assertIsInstance(
- self.airplane_movement.base_airplane,
+ self.core_airplane_movement.base_airplane,
ps.geometry.airplane.Airplane,
)
- self.assertIsInstance(self.airplane_movement.wing_movements, tuple)
- self.assertEqual(self.airplane_movement.ampCg_GP1_CgP1.shape, (3,))
- self.assertEqual(self.airplane_movement.periodCg_GP1_CgP1.shape, (3,))
- self.assertIsInstance(self.airplane_movement.spacingCg_GP1_CgP1, tuple)
- self.assertEqual(self.airplane_movement.phaseCg_GP1_CgP1.shape, (3,))
+ self.assertIsInstance(self.core_airplane_movement.wing_movements, tuple)
+ self.assertEqual(self.core_airplane_movement.ampCg_GP1_CgP1.shape, (3,))
+ self.assertEqual(self.core_airplane_movement.periodCg_GP1_CgP1.shape, (3,))
+ self.assertIsInstance(self.core_airplane_movement.spacingCg_GP1_CgP1, tuple)
+ self.assertEqual(self.core_airplane_movement.phaseCg_GP1_CgP1.shape, (3,))
# Cached computed properties.
- self.assertIsInstance(self.airplane_movement.all_periods, tuple)
- self.assertIsInstance(self.airplane_movement.max_period, float)
+ self.assertIsInstance(self.core_airplane_movement.all_periods, tuple)
+ self.assertIsInstance(self.core_airplane_movement.max_period, float)
def test_deepcopy_method(self):
"""Test that __deepcopy__ produces a correct independent copy."""
# Access cached properties before copying.
- _ = self.airplane_movement.all_periods
- _ = self.airplane_movement.max_period
+ _ = self.core_airplane_movement.all_periods
+ _ = self.core_airplane_movement.max_period
- copied = copy.deepcopy(self.airplane_movement)
+ copied = copy.deepcopy(self.core_airplane_movement)
# Verify the copy is a separate instance.
- self.assertIsNot(copied, self.airplane_movement)
+ self.assertIsNot(copied, self.core_airplane_movement)
# Verify property values match.
npt.assert_array_equal(
- copied.ampCg_GP1_CgP1, self.airplane_movement.ampCg_GP1_CgP1
+ copied.ampCg_GP1_CgP1, self.core_airplane_movement.ampCg_GP1_CgP1
)
npt.assert_array_equal(
copied.periodCg_GP1_CgP1,
- self.airplane_movement.periodCg_GP1_CgP1,
+ self.core_airplane_movement.periodCg_GP1_CgP1,
)
npt.assert_array_equal(
- copied.phaseCg_GP1_CgP1, self.airplane_movement.phaseCg_GP1_CgP1
+ copied.phaseCg_GP1_CgP1, self.core_airplane_movement.phaseCg_GP1_CgP1
)
self.assertEqual(
copied.spacingCg_GP1_CgP1,
- self.airplane_movement.spacingCg_GP1_CgP1,
+ self.core_airplane_movement.spacingCg_GP1_CgP1,
)
# Verify base Airplane is independent.
- self.assertIsNot(copied.base_airplane, self.airplane_movement.base_airplane)
+ self.assertIsNot(
+ copied.base_airplane, self.core_airplane_movement.base_airplane
+ )
- # Verify WingMovements are independent.
+ # Verify CoreWingMovements are independent.
self.assertIsNot(
copied.wing_movements[0],
- self.airplane_movement.wing_movements[0],
+ self.core_airplane_movement.wing_movements[0],
)
# Verify arrays are independent.
- self.assertIsNot(copied.ampCg_GP1_CgP1, self.airplane_movement.ampCg_GP1_CgP1)
+ self.assertIsNot(
+ copied.ampCg_GP1_CgP1, self.core_airplane_movement.ampCg_GP1_CgP1
+ )
def test_deepcopy_no_dict(self):
- """Test that a deep copied AirplaneMovement has no __dict__."""
- copied = copy.deepcopy(self.airplane_movement)
+ """Test that a deep copied CoreAirplaneMovement has no __dict__."""
+ copied = copy.deepcopy(self.core_airplane_movement)
self.assertFalse(hasattr(copied, "__dict__"))
+class TestCoreMovementSlots(unittest.TestCase):
+ """This class contains tests to verify __slots__ enforcement on CoreMovement."""
+
+ def setUp(self):
+ """Set up test fixtures for CoreMovement slots tests."""
+ self.static_core_movement = (
+ core_movement_fixtures.make_static_core_movement_fixture()
+ )
+ self.basic_core_movement = (
+ core_movement_fixtures.make_basic_core_movement_fixture()
+ )
+
+ def test_slots_defined(self):
+ """Test that __slots__ is defined on CoreMovement."""
+ self.assertTrue(hasattr(_core.CoreMovement, "__slots__"))
+
+ def test_no_instance_dict(self):
+ """Test that CoreMovement instances have no __dict__."""
+ self.assertFalse(hasattr(self.static_core_movement, "__dict__"))
+
+ def test_dynamic_attribute_raises(self):
+ """Test that dynamic attribute assignment raises AttributeError."""
+ with self.assertRaises(AttributeError):
+ self.static_core_movement.nonexistent_attribute = 42
+
+ def test_property_access_static(self):
+ """Test that all properties are accessible on a static CoreMovement."""
+ # Immutable properties.
+ self.assertIsInstance(self.static_core_movement.airplane_movements, tuple)
+ self.assertIsInstance(
+ self.static_core_movement.operating_point_movement,
+ _core.CoreOperatingPointMovement,
+ )
+ self.assertIsInstance(self.static_core_movement.delta_time, float)
+ self.assertIsInstance(self.static_core_movement.num_steps, int)
+
+ # Cached computed properties.
+ self.assertTrue(self.static_core_movement.static)
+ self.assertEqual(self.static_core_movement.max_period, 0.0)
+ self.assertEqual(self.static_core_movement.lcm_period, 0.0)
+
+ def test_property_access_basic(self):
+ """Test that all properties are accessible on a non-static CoreMovement."""
+ self.assertFalse(self.basic_core_movement.static)
+ self.assertGreater(self.basic_core_movement.max_period, 0.0)
+ self.assertGreater(self.basic_core_movement.min_period, 0.0)
+ self.assertGreater(self.basic_core_movement.lcm_period, 0.0)
+
+
+class TestCoreUnsteadyProblemSlots(unittest.TestCase):
+ """This class contains tests to verify __slots__ enforcement on
+ CoreUnsteadyProblem.
+ """
+
+ def setUp(self):
+ """Set up test fixtures for CoreUnsteadyProblem slots tests."""
+ self.core_unsteady_problem = _core.CoreUnsteadyProblem(
+ only_final_results=False,
+ delta_time=0.01,
+ num_steps=50,
+ max_wake_rows=None,
+ lcm_period=2.0,
+ )
+
+ def test_slots_defined(self):
+ """Test that __slots__ is defined on CoreUnsteadyProblem."""
+ self.assertTrue(hasattr(_core.CoreUnsteadyProblem, "__slots__"))
+
+ def test_no_instance_dict(self):
+ """Test that CoreUnsteadyProblem instances have no __dict__."""
+ self.assertFalse(hasattr(self.core_unsteady_problem, "__dict__"))
+
+ def test_dynamic_attribute_raises(self):
+ """Test that dynamic attribute assignment raises AttributeError."""
+ with self.assertRaises(AttributeError):
+ # noinspection PyDunderSlots
+ self.core_unsteady_problem.nonexistent_attribute = 42
+
+ def test_property_access(self):
+ """Test that all properties remain accessible after adding __slots__."""
+ # Immutable properties.
+ self.assertIsInstance(self.core_unsteady_problem.only_final_results, bool)
+ self.assertIsInstance(self.core_unsteady_problem.num_steps, int)
+ self.assertIsInstance(self.core_unsteady_problem.delta_time, float)
+ self.assertIsInstance(self.core_unsteady_problem.first_averaging_step, int)
+ self.assertIsInstance(self.core_unsteady_problem.first_results_step, int)
+
+ # Mutable list attributes (initialized empty).
+ self.assertIsInstance(self.core_unsteady_problem.finalForces_W, list)
+ self.assertIsInstance(self.core_unsteady_problem.finalForceCoefficients_W, list)
+ self.assertIsInstance(self.core_unsteady_problem.finalMoments_W_CgP1, list)
+ self.assertIsInstance(
+ self.core_unsteady_problem.finalMomentCoefficients_W_CgP1, list
+ )
+ self.assertIsInstance(self.core_unsteady_problem.finalMeanForces_W, list)
+ self.assertIsInstance(
+ self.core_unsteady_problem.finalMeanForceCoefficients_W, list
+ )
+ self.assertIsInstance(self.core_unsteady_problem.finalMeanMoments_W_CgP1, list)
+ self.assertIsInstance(
+ self.core_unsteady_problem.finalMeanMomentCoefficients_W_CgP1, list
+ )
+ self.assertIsInstance(self.core_unsteady_problem.finalRmsForces_W, list)
+ self.assertIsInstance(
+ self.core_unsteady_problem.finalRmsForceCoefficients_W, list
+ )
+ self.assertIsInstance(self.core_unsteady_problem.finalRmsMoments_W_CgP1, list)
+ self.assertIsInstance(
+ self.core_unsteady_problem.finalRmsMomentCoefficients_W_CgP1, list
+ )
+
+
+class TestOperatingPointMovementSlots(unittest.TestCase):
+ """This class contains tests to verify __slots__ enforcement on
+ OperatingPointMovement. All property and deepcopy behavior is tested at the
+ CoreOperatingPointMovement level. This class verifies that the public subclass
+ preserves __slots__ enforcement.
+ """
+
+ def setUp(self):
+ """Set up test fixtures for OperatingPointMovement slots tests."""
+ self.opm = (
+ operating_point_movement_fixtures.make_static_operating_point_movement_fixture()
+ )
+
+ def test_slots_defined(self):
+ """Test that __slots__ is defined on OperatingPointMovement."""
+ self.assertTrue(
+ hasattr(
+ ps.movements.operating_point_movement.OperatingPointMovement,
+ "__slots__",
+ )
+ )
+
+ def test_no_instance_dict(self):
+ """Test that OperatingPointMovement instances have no __dict__."""
+ self.assertFalse(hasattr(self.opm, "__dict__"))
+
+ def test_dynamic_attribute_raises(self):
+ """Test that dynamic attribute assignment raises AttributeError."""
+ with self.assertRaises(AttributeError):
+ self.opm.nonexistent_attribute = 42
+
+ def test_subclass(self):
+ """Test that OperatingPointMovement is a subclass of
+ CoreOperatingPointMovement.
+ """
+ self.assertIsInstance(self.opm, _core.CoreOperatingPointMovement)
+
+
+class TestWingCrossSectionMovementSlots(unittest.TestCase):
+ """This class contains tests to verify __slots__ enforcement on
+ WingCrossSectionMovement. All property and deepcopy behavior is tested at the
+ CoreWingCrossSectionMovement level. This class verifies that the public subclass
+ preserves __slots__ enforcement.
+ """
+
+ def setUp(self):
+ """Set up test fixtures for WingCrossSectionMovement slots tests."""
+ self.wcsm = (
+ wing_cross_section_movement_fixtures.make_basic_wing_cross_section_movement_fixture()
+ )
+
+ def test_slots_defined(self):
+ """Test that __slots__ is defined on WingCrossSectionMovement."""
+ self.assertTrue(
+ hasattr(
+ ps.movements.wing_cross_section_movement.WingCrossSectionMovement,
+ "__slots__",
+ )
+ )
+
+ def test_no_instance_dict(self):
+ """Test that WingCrossSectionMovement instances have no __dict__."""
+ self.assertFalse(hasattr(self.wcsm, "__dict__"))
+
+ def test_dynamic_attribute_raises(self):
+ """Test that dynamic attribute assignment raises AttributeError."""
+ with self.assertRaises(AttributeError):
+ self.wcsm.nonexistent_attribute = 42
+
+ def test_subclass(self):
+ """Test that WingCrossSectionMovement is a subclass of
+ CoreWingCrossSectionMovement.
+ """
+ self.assertIsInstance(self.wcsm, _core.CoreWingCrossSectionMovement)
+
+
+class TestWingMovementSlots(unittest.TestCase):
+ """This class contains tests to verify __slots__ enforcement on
+ WingMovement. All property and deepcopy behavior is tested at the
+ CoreWingMovement level. This class verifies that the public subclass
+ preserves __slots__ enforcement.
+ """
+
+ def setUp(self):
+ """Set up test fixtures for WingMovement slots tests."""
+ self.wing_movement = wing_movement_fixtures.make_basic_wing_movement_fixture()
+
+ def test_slots_defined(self):
+ """Test that __slots__ is defined on WingMovement."""
+ self.assertTrue(hasattr(ps.movements.wing_movement.WingMovement, "__slots__"))
+
+ def test_no_instance_dict(self):
+ """Test that WingMovement instances have no __dict__."""
+ self.assertFalse(hasattr(self.wing_movement, "__dict__"))
+
+ def test_dynamic_attribute_raises(self):
+ """Test that dynamic attribute assignment raises AttributeError."""
+ with self.assertRaises(AttributeError):
+ self.wing_movement.nonexistent_attribute = 42
+
+ def test_subclass(self):
+ """Test that WingMovement is a subclass of CoreWingMovement."""
+ self.assertIsInstance(self.wing_movement, _core.CoreWingMovement)
+
+
+class TestAirplaneMovementSlots(unittest.TestCase):
+ """This class contains tests to verify __slots__ enforcement on
+ AirplaneMovement. All property and deepcopy behavior is tested at the
+ CoreAirplaneMovement level. This class verifies that the public subclass
+ preserves __slots__ enforcement.
+ """
+
+ def setUp(self):
+ """Set up test fixtures for AirplaneMovement slots tests."""
+ self.airplane_movement = (
+ airplane_movement_fixtures.make_basic_airplane_movement_fixture()
+ )
+
+ def test_slots_defined(self):
+ """Test that __slots__ is defined on AirplaneMovement."""
+ self.assertTrue(
+ hasattr(ps.movements.airplane_movement.AirplaneMovement, "__slots__")
+ )
+
+ def test_no_instance_dict(self):
+ """Test that AirplaneMovement instances have no __dict__."""
+ self.assertFalse(hasattr(self.airplane_movement, "__dict__"))
+
+ def test_dynamic_attribute_raises(self):
+ """Test that dynamic attribute assignment raises AttributeError."""
+ with self.assertRaises(AttributeError):
+ self.airplane_movement.nonexistent_attribute = 42
+
+ def test_subclass(self):
+ """Test that AirplaneMovement is a subclass of CoreAirplaneMovement."""
+ self.assertIsInstance(self.airplane_movement, _core.CoreAirplaneMovement)
+
+
class TestMovementSlots(unittest.TestCase):
- """This class contains tests to verify __slots__ enforcement on Movement."""
+ """This class contains tests to verify __slots__ enforcement on Movement.
+ Core-owned properties (airplane_movements, operating_point_movement, delta_time,
+ num_steps, static, max_period, lcm_period, min_period) are tested at the
+ CoreMovement level. This class tests Movement-specific slots and deepcopy.
+ """
def setUp(self):
"""Set up test fixtures for Movement slots tests."""
self.static_movement = movement_fixtures.make_static_movement_fixture()
- self.cyclic_movement = movement_fixtures.make_cyclic_movement_fixture()
def test_slots_defined(self):
"""Test that __slots__ is defined on Movement."""
@@ -1392,16 +1625,12 @@ def test_dynamic_attribute_raises(self):
with self.assertRaises(AttributeError):
self.static_movement.nonexistent_attribute = 42
- def test_property_access_static(self):
- """Test that all properties are accessible on a static Movement."""
- # Immutable properties.
- self.assertIsInstance(self.static_movement.airplane_movements, tuple)
- self.assertIsInstance(
- self.static_movement.operating_point_movement,
- ps.movements.operating_point_movement.OperatingPointMovement,
- )
- self.assertIsInstance(self.static_movement.delta_time, float)
- self.assertIsInstance(self.static_movement.num_steps, int)
+ def test_subclass(self):
+ """Test that Movement is a subclass of CoreMovement."""
+ self.assertIsInstance(self.static_movement, _core.CoreMovement)
+
+ def test_property_access(self):
+ """Test that Movement-specific properties are accessible."""
self.assertIsInstance(self.static_movement.airplanes, tuple)
self.assertIsInstance(self.static_movement.operating_points, tuple)
self.assertEqual(
@@ -1409,36 +1638,14 @@ def test_property_access_static(self):
self.static_movement.num_steps,
)
- # Cached computed properties.
- self.assertTrue(self.static_movement.static)
- self.assertEqual(self.static_movement.max_period, 0.0)
- self.assertEqual(self.static_movement.lcm_period, 0.0)
-
- def test_property_access_cyclic(self):
- """Test that all properties are accessible on a cyclic Movement."""
- self.assertFalse(self.cyclic_movement.static)
- self.assertGreater(self.cyclic_movement.max_period, 0.0)
- self.assertGreater(self.cyclic_movement.min_period, 0.0)
- self.assertGreater(self.cyclic_movement.lcm_period, 0.0)
-
def test_deepcopy(self):
"""Test that copy.deepcopy produces a correct independent copy."""
- # Access cached properties before copying.
- _ = self.static_movement.static
- _ = self.static_movement.max_period
- _ = self.static_movement.lcm_period
-
copied = copy.deepcopy(self.static_movement)
# Verify the copy is a separate instance.
self.assertIsNot(copied, self.static_movement)
- # Verify property values match.
- self.assertEqual(copied.delta_time, self.static_movement.delta_time)
- self.assertEqual(copied.num_steps, self.static_movement.num_steps)
- self.assertEqual(copied.static, self.static_movement.static)
- self.assertEqual(copied.max_period, self.static_movement.max_period)
- self.assertEqual(copied.lcm_period, self.static_movement.lcm_period)
+ # Verify Movement-specific property values match.
self.assertEqual(len(copied.airplanes), len(self.static_movement.airplanes))
self.assertEqual(
len(copied.operating_points),
diff --git a/tests/unit/test_wing.py b/tests/unit/test_wing.py
index 4b7d9a4f4..b0d90642e 100644
--- a/tests/unit/test_wing.py
+++ b/tests/unit/test_wing.py
@@ -22,9 +22,7 @@ def setUp(self):
self.type_5_wing = geometry_fixtures.make_type_5_wing_fixture()
# Create additional test fixtures
- self.test_airfoil = geometry_fixtures.make_test_airfoil_fixture()
self.root_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
- self.tip_wcs = geometry_fixtures.make_tip_wing_cross_section_fixture()
def test_initialization_valid_parameters(self):
"""Test Wing initialization with valid parameters for all types."""
@@ -1282,8 +1280,6 @@ class TestWingGetPlottableData(unittest.TestCase):
def setUp(self):
"""Set up test fixtures for get_plottable_data tests."""
- self.type_1_wing = geometry_fixtures.make_type_1_wing_fixture()
- self.type_4_wing = geometry_fixtures.make_type_4_wing_fixture()
def test_get_plottable_data_returns_none_when_symmetry_type_not_set(self):
"""Test that get_plottable_data returns None when symmetry_type not set."""
diff --git a/tests/unit/test_wing_cross_section_movement.py b/tests/unit/test_wing_cross_section_movement.py
index 1d507d8ff..f93308561 100644
--- a/tests/unit/test_wing_cross_section_movement.py
+++ b/tests/unit/test_wing_cross_section_movement.py
@@ -1,1537 +1,62 @@
"""This module contains classes to test WingCrossSectionMovements."""
-import copy
import unittest
-import numpy as np
-import numpy.testing as npt
-from scipy import signal
-
import pterasoftware as ps
-from tests.unit.fixtures import wing_cross_section_movement_fixtures
+from tests.unit.fixtures import (
+ geometry_fixtures,
+ wing_cross_section_movement_fixtures,
+)
class TestWingCrossSectionMovement(unittest.TestCase):
"""This is a class with functions to test WingCrossSectionMovements."""
- @classmethod
- def setUpClass(cls):
- """Set up test fixtures once for all WingCrossSectionMovement tests."""
- # Spacing test fixtures.
- cls.sine_spacing_Lp_wcs_movement = (
- wing_cross_section_movement_fixtures.make_sine_spacing_Lp_wing_cross_section_movement_fixture()
- )
- cls.uniform_spacing_Lp_wcs_movement = (
- wing_cross_section_movement_fixtures.make_uniform_spacing_Lp_wing_cross_section_movement_fixture()
- )
- cls.mixed_spacing_Lp_wcs_movement = (
- wing_cross_section_movement_fixtures.make_mixed_spacing_Lp_wing_cross_section_movement_fixture()
- )
- cls.sine_spacing_angles_wcs_movement = (
- wing_cross_section_movement_fixtures.make_sine_spacing_angles_wing_cross_section_movement_fixture()
- )
- cls.uniform_spacing_angles_wcs_movement = (
- wing_cross_section_movement_fixtures.make_uniform_spacing_angles_wing_cross_section_movement_fixture()
- )
- cls.mixed_spacing_angles_wcs_movement = (
- wing_cross_section_movement_fixtures.make_mixed_spacing_angles_wing_cross_section_movement_fixture()
- )
-
- # Additional test fixtures.
- cls.static_wcs_movement = (
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- )
- cls.basic_wcs_movement = (
- wing_cross_section_movement_fixtures.make_basic_wing_cross_section_movement_fixture()
- )
- cls.Lp_only_wcs_movement = (
- wing_cross_section_movement_fixtures.make_Lp_only_wing_cross_section_movement_fixture()
- )
- cls.angles_only_wcs_movement = (
- wing_cross_section_movement_fixtures.make_angles_only_wing_cross_section_movement_fixture()
- )
- cls.phase_offset_Lp_wcs_movement = (
- wing_cross_section_movement_fixtures.make_phase_offset_Lp_wing_cross_section_movement_fixture()
- )
- cls.phase_offset_angles_wcs_movement = (
- wing_cross_section_movement_fixtures.make_phase_offset_angles_wing_cross_section_movement_fixture()
- )
- cls.multiple_periods_wcs_movement = (
- wing_cross_section_movement_fixtures.make_multiple_periods_wing_cross_section_movement_fixture()
- )
- cls.custom_spacing_Lp_wcs_movement = (
- wing_cross_section_movement_fixtures.make_custom_spacing_Lp_wing_cross_section_movement_fixture()
- )
- cls.custom_spacing_angles_wcs_movement = (
- wing_cross_section_movement_fixtures.make_custom_spacing_angles_wing_cross_section_movement_fixture()
- )
- cls.mixed_custom_and_standard_spacing_wcs_movement = (
- wing_cross_section_movement_fixtures.make_mixed_custom_and_standard_spacing_wing_cross_section_movement_fixture()
- )
-
- def test_spacing_sine_for_Lp_Wcsp_Lpp(self):
- """Test that sine spacing actually produces sinusoidal motion for
- Lp_Wcsp_Lpp."""
- num_steps = 100
- delta_time = 0.01
- wing_cross_sections = (
- self.sine_spacing_Lp_wcs_movement.generate_wing_cross_sections(
- num_steps=num_steps,
- delta_time=delta_time,
- )
- )
-
- # Extract x-positions from generated WingCrossSections.
- x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
-
- # Calculate expected sine wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 1.0 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated positions match the expected sine wave.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_spacing_uniform_for_Lp_Wcsp_Lpp(self):
- """Test that uniform spacing actually produces triangular wave motion for
- Lp_Wcsp_Lpp."""
- num_steps = 100
- delta_time = 0.01
- wing_cross_sections = (
- self.uniform_spacing_Lp_wcs_movement.generate_wing_cross_sections(
- num_steps=num_steps,
- delta_time=delta_time,
- )
- )
-
- # Extract x-positions from generated WingCrossSections.
- x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
-
- # Calculate expected triangular wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 1.0 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
-
- # Assert that the generated positions match the expected triangular wave.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_spacing_mixed_for_Lp_Wcsp_Lpp(self):
- """Test that mixed spacing types work correctly for Lp_Wcsp_Lpp."""
- num_steps = 100
- delta_time = 0.01
- wing_cross_sections = (
- self.mixed_spacing_Lp_wcs_movement.generate_wing_cross_sections(
- num_steps=num_steps,
- delta_time=delta_time,
- )
- )
-
- # Extract positions from generated WingCrossSections.
- x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
- y_positions = np.array([wcs.Lp_Wcsp_Lpp[1] for wcs in wing_cross_sections])
- z_positions = np.array([wcs.Lp_Wcsp_Lpp[2] for wcs in wing_cross_sections])
-
- # Calculate expected values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 0.5 + 1.0 * np.sin(2 * np.pi * times / 1.0)
- expected_y = 2.0 + 1.5 * signal.sawtooth(
- 2 * np.pi * times / 1.0 + np.pi / 2, 0.5
- )
- expected_z = 0.2 + 0.5 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated positions match the expected values.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
-
- def test_spacing_sine_for_angles_Wcsp_to_Wcs_ixyz(self):
- """Test that sine spacing actually produces sinusoidal motion for
- angles_Wcsp_to_Wcs_ixyz."""
- num_steps = 100
- delta_time = 0.01
- wing_cross_sections = (
- self.sine_spacing_angles_wcs_movement.generate_wing_cross_sections(
- num_steps=num_steps,
- delta_time=delta_time,
- )
- )
-
- # Extract angles from generated WingCrossSections.
- angles_z = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
- )
-
- # Calculate expected sine wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_angles = 10.0 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated angles match the expected sine wave.
- npt.assert_allclose(angles_z, expected_angles, rtol=1e-10, atol=1e-14)
-
- def test_spacing_uniform_for_angles_Wcsp_to_Wcs_ixyz(self):
- """Test that uniform spacing actually produces triangular wave motion for
- angles_Wcsp_to_Wcs_ixyz."""
- num_steps = 100
- delta_time = 0.01
- wing_cross_sections = (
- self.uniform_spacing_angles_wcs_movement.generate_wing_cross_sections(
- num_steps=num_steps,
- delta_time=delta_time,
+ def test_is_subclass_of_core(self):
+ """Test that WingCrossSectionMovement is a subclass of
+ CoreWingCrossSectionMovement.
+ """
+ self.assertTrue(
+ issubclass(
+ ps.movements.wing_cross_section_movement.WingCrossSectionMovement,
+ ps._core.CoreWingCrossSectionMovement,
)
)
- # Extract angles from generated WingCrossSections.
- angles_z = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
- )
-
- # Calculate expected triangular wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_angles = 10.0 * signal.sawtooth(
- 2 * np.pi * times / 1.0 + np.pi / 2, 0.5
+ def test_instantiation_returns_correct_type(self):
+ """Test that WingCrossSectionMovement instantiation returns a
+ WingCrossSectionMovement.
+ """
+ base_wing_cross_section = (
+ geometry_fixtures.make_root_wing_cross_section_fixture()
)
-
- # Assert that the generated angles match the expected triangular wave.
- npt.assert_allclose(angles_z, expected_angles, rtol=1e-10, atol=1e-14)
-
- def test_spacing_mixed_for_angles_Wcsp_to_Wcs_ixyz(self):
- """Test that mixed spacing types work correctly for angles_Wcsp_to_Wcs_ixyz."""
- num_steps = 100
- delta_time = 0.01
- wing_cross_sections = (
- self.mixed_spacing_angles_wcs_movement.generate_wing_cross_sections(
- num_steps=num_steps,
- delta_time=delta_time,
+ wing_cross_section_movement = (
+ ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
+ base_wing_cross_section=base_wing_cross_section,
)
)
-
- # Extract angles from generated WingCrossSections.
- angles_z = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
- )
- angles_y = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[1] for wcs in wing_cross_sections]
- )
- angles_x = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[2] for wcs in wing_cross_sections]
- )
-
- # Calculate expected values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_angles_z = 10.0 * np.sin(2 * np.pi * times / 1.0)
- expected_angles_y = 20.0 * signal.sawtooth(
- 2 * np.pi * times / 1.0 + np.pi / 2, 0.5
- )
- expected_angles_x = 5.0 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated angles match the expected values.
- npt.assert_allclose(angles_z, expected_angles_z, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(angles_y, expected_angles_y, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(angles_x, expected_angles_x, rtol=1e-10, atol=1e-14)
-
- def test_initialization_valid_parameters(self):
- """Test WingCrossSectionMovement initialization with valid parameters."""
- # Test that basic WingCrossSectionMovement initializes correctly.
- wcs_movement = self.basic_wcs_movement
self.assertIsInstance(
- wcs_movement,
+ wing_cross_section_movement,
ps.movements.wing_cross_section_movement.WingCrossSectionMovement,
)
- self.assertIsInstance(
- wcs_movement.base_wing_cross_section,
- ps.geometry.wing_cross_section.WingCrossSection,
- )
- npt.assert_array_equal(wcs_movement.ampLp_Wcsp_Lpp, np.array([0.4, 0.3, 0.15]))
- npt.assert_array_equal(
- wcs_movement.periodLp_Wcsp_Lpp, np.array([2.0, 2.0, 2.0])
- )
- self.assertEqual(wcs_movement.spacingLp_Wcsp_Lpp, ("sine", "sine", "sine"))
- npt.assert_array_equal(wcs_movement.phaseLp_Wcsp_Lpp, np.array([0.0, 0.0, 0.0]))
- npt.assert_array_equal(
- wcs_movement.ampAngles_Wcsp_to_Wcs_ixyz, np.array([15.0, 10.0, 5.0])
- )
- npt.assert_array_equal(
- wcs_movement.periodAngles_Wcsp_to_Wcs_ixyz, np.array([2.0, 2.0, 2.0])
- )
- self.assertEqual(
- wcs_movement.spacingAngles_Wcsp_to_Wcs_ixyz, ("sine", "sine", "sine")
- )
- npt.assert_array_equal(
- wcs_movement.phaseAngles_Wcsp_to_Wcs_ixyz, np.array([0.0, 0.0, 0.0])
- )
-
- def test_base_wing_cross_section_validation(self):
- """Test that base_wing_cross_section parameter validation works correctly."""
- from tests.unit.fixtures import geometry_fixtures
-
- # Test non-WingCrossSection raises error.
- with self.assertRaises(TypeError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section="not a wing cross section"
- )
-
- # Test None raises error.
- with self.assertRaises(TypeError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=None
- )
-
- # Test valid WingCrossSection works.
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs
- )
- )
- self.assertEqual(wcs_movement.base_wing_cross_section, base_wcs)
-
- def test_ampLp_Wcsp_Lpp_validation(self):
- """Test ampLp_Wcsp_Lpp parameter validation."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test valid values.
- valid_amps = [
- (0.0, 0.0, 0.0),
- (1.0, 2.0, 3.0),
- [0.5, 1.5, 2.5],
- np.array([0.1, 0.2, 0.3]),
- ]
- for amp in valid_amps:
- with self.subTest(amp=amp):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs, ampLp_Wcsp_Lpp=amp
- )
- )
- npt.assert_array_equal(wcs_movement.ampLp_Wcsp_Lpp, amp)
-
- # Test negative values raise error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs, ampLp_Wcsp_Lpp=(-1.0, 0.0, 0.0)
- )
-
- # Test invalid types raise error.
- # noinspection PyTypeChecker
- with self.assertRaises((TypeError, ValueError)):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs, ampLp_Wcsp_Lpp="invalid"
- )
-
- def test_periodLp_Wcsp_Lpp_validation(self):
- """Test periodLp_Wcsp_Lpp parameter validation."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test valid values.
- valid_periods = [(0.0, 0.0, 0.0), (1.0, 2.0, 3.0), [0.5, 1.5, 2.5]]
- for period in valid_periods:
- with self.subTest(period=period):
- # Need matching amps for non-zero periods.
- amp = tuple(1.0 if p > 0 else 0.0 for p in period)
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=amp,
- periodLp_Wcsp_Lpp=period,
- )
- )
- npt.assert_array_equal(wcs_movement.periodLp_Wcsp_Lpp, period)
-
- # Test negative values raise error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
- periodLp_Wcsp_Lpp=(-1.0, 1.0, 1.0),
- )
-
- def test_spacingLp_Wcsp_Lpp_validation(self):
- """Test spacingLp_Wcsp_Lpp parameter validation."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test valid string values.
- valid_spacings = [
- ("sine", "sine", "sine"),
- ("uniform", "uniform", "uniform"),
- ("sine", "uniform", "sine"),
- ]
- for spacing in valid_spacings:
- with self.subTest(spacing=spacing):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs, spacingLp_Wcsp_Lpp=spacing
- )
- )
- self.assertEqual(wcs_movement.spacingLp_Wcsp_Lpp, spacing)
-
- # Test invalid string raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- spacingLp_Wcsp_Lpp=("invalid", "sine", "sine"),
- )
-
- def test_phaseLp_Wcsp_Lpp_validation(self):
- """Test phaseLp_Wcsp_Lpp parameter validation."""
- from tests.unit.fixtures import geometry_fixtures
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test valid phase values within range (-180.0, 180.0].
- valid_phases = [
- (0.0, 0.0, 0.0),
- (90.0, 180.0, -90.0),
- (179.9, 0.0, -179.9),
- ]
- for phase in valid_phases:
- with self.subTest(phase=phase):
- # Need non-zero amps for non-zero phases.
- amp = tuple(1.0 if p != 0 else 0.0 for p in phase)
- period = tuple(1.0 if p != 0 else 0.0 for p in phase)
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=amp,
- periodLp_Wcsp_Lpp=period,
- phaseLp_Wcsp_Lpp=phase,
- )
- )
- npt.assert_array_equal(wcs_movement.phaseLp_Wcsp_Lpp, phase)
-
- # Test phase > 180.0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
- periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
- phaseLp_Wcsp_Lpp=(180.1, 0.0, 0.0),
- )
-
- # Test phase <= -180.0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
- periodLp_Wcsp_Lpp=(1.0, 1.0, 1.0),
- phaseLp_Wcsp_Lpp=(-180.0, 0.0, 0.0),
- )
-
- def test_ampAngles_Wcsp_to_Wcs_ixyz_validation(self):
- """Test ampAngles_Wcsp_to_Wcs_ixyz parameter validation."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test valid amplitude values within range [0.0, 180.0].
- valid_amps = [
- (0.0, 0.0, 0.0),
- (45.0, 90.0, 135.0),
- (179.9, 0.0, 90.0),
- (180.0, 0.0, 0.0),
- ]
- for amp in valid_amps:
- with self.subTest(amp=amp):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs, ampAngles_Wcsp_to_Wcs_ixyz=amp
- )
- )
- npt.assert_array_equal(wcs_movement.ampAngles_Wcsp_to_Wcs_ixyz, amp)
-
- # Test amplitude > 180.0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(180.1, 0.0, 0.0),
- )
-
- # Test negative amplitude raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(-1.0, 0.0, 0.0),
- )
-
- def test_periodAngles_Wcsp_to_Wcs_ixyz_validation(self):
- """Test periodAngles_Wcsp_to_Wcs_ixyz parameter validation."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test valid periods.
- valid_periods = [(0.0, 0.0, 0.0), (1.0, 2.0, 3.0)]
- for period in valid_periods:
- with self.subTest(period=period):
- amp = tuple(10.0 if p > 0 else 0.0 for p in period)
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=amp,
- periodAngles_Wcsp_to_Wcs_ixyz=period,
- )
- )
- npt.assert_array_equal(
- wcs_movement.periodAngles_Wcsp_to_Wcs_ixyz, period
- )
-
- # Test negative period raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 10.0, 10.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(-1.0, 1.0, 1.0),
- )
-
- def test_spacingAngles_Wcsp_to_Wcs_ixyz_validation(self):
- """Test spacingAngles_Wcsp_to_Wcs_ixyz parameter validation."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test valid string values.
- valid_spacings = [
- ("sine", "sine", "sine"),
- ("uniform", "uniform", "uniform"),
- ("sine", "uniform", "sine"),
- ]
- for spacing in valid_spacings:
- with self.subTest(spacing=spacing):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- spacingAngles_Wcsp_to_Wcs_ixyz=spacing,
- )
- )
- self.assertEqual(wcs_movement.spacingAngles_Wcsp_to_Wcs_ixyz, spacing)
-
- # Test invalid string raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- spacingAngles_Wcsp_to_Wcs_ixyz=("invalid", "sine", "sine"),
- )
-
- def test_phaseAngles_Wcsp_to_Wcs_ixyz_validation(self):
- """Test phaseAngles_Wcsp_to_Wcs_ixyz parameter validation."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test valid phase values within range (-180.0, 180.0].
- valid_phases = [(0.0, 0.0, 0.0), (90.0, 180.0, -90.0), (179.9, 0.0, -179.9)]
- for phase in valid_phases:
- with self.subTest(phase=phase):
- amp = tuple(10.0 if p != 0 else 0.0 for p in phase)
- period = tuple(1.0 if p != 0 else 0.0 for p in phase)
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=amp,
- periodAngles_Wcsp_to_Wcs_ixyz=period,
- phaseAngles_Wcsp_to_Wcs_ixyz=phase,
- )
- )
- npt.assert_array_equal(wcs_movement.phaseAngles_Wcsp_to_Wcs_ixyz, phase)
-
- # Test phase > 180.0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(10.0, 10.0, 10.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 1.0),
- phaseAngles_Wcsp_to_Wcs_ixyz=(180.1, 0.0, 0.0),
- )
-
- def test_amp_period_relationship_Lp(self):
- """Test that if ampLp_Wcsp_Lpp element is 0, corresponding period must be 0."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test amp=0 with period=0 works.
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
- periodLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
- )
- )
- self.assertIsNotNone(wcs_movement)
-
- # Test amp=0 with period!=0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 1.0, 0.0),
- )
-
- def test_amp_phase_relationship_Lp(self):
- """Test that if ampLp_Wcsp_Lpp element is 0, corresponding phase must be 0."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test amp=0 with phase=0 works.
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
- periodLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
- phaseLp_Wcsp_Lpp=(0.0, -90.0, 0.0),
- )
- )
- self.assertIsNotNone(wcs_movement)
-
- # Test amp=0 with phase!=0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
- periodLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
- phaseLp_Wcsp_Lpp=(45.0, -90.0, 0.0),
- )
-
- def test_amp_period_relationship_angles(self):
- """Test that if ampAngles_Wcsp_to_Wcs_ixyz element is 0, corresponding period must be 0."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test amp=0 with period=0 works.
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 10.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 1.0, 0.0),
- )
- )
- self.assertIsNotNone(wcs_movement)
-
- # Test amp=0 with period!=0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 10.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 1.0, 0.0),
- )
-
- def test_amp_phase_relationship_angles(self):
- """Test that if ampAngles_Wcsp_to_Wcs_ixyz element is 0, corresponding phase must be 0."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test amp=0 with phase=0 works.
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 10.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 1.0, 0.0),
- phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, -90.0, 0.0),
- )
- )
- self.assertIsNotNone(wcs_movement)
-
- # Test amp=0 with phase!=0 raises error.
- with self.assertRaises(ValueError):
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 10.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 1.0, 0.0),
- phaseAngles_Wcsp_to_Wcs_ixyz=(45.0, -90.0, 0.0),
- )
-
- def test_max_period_static_movement(self):
- """Test that max_period returns 0.0 for static movement."""
- wcs_movement = self.static_wcs_movement
- self.assertEqual(wcs_movement.max_period, 0.0)
-
- def test_max_period_Lp_only(self):
- """Test that max_period returns correct period for Lp-only movement."""
- wcs_movement = self.Lp_only_wcs_movement
- # periodLp_Wcsp_Lpp is (1.5, 1.5, 1.5), so max should be 1.5.
- self.assertEqual(wcs_movement.max_period, 1.5)
-
- def test_max_period_angles_only(self):
- """Test that max_period returns correct period for angles-only movement."""
- wcs_movement = self.angles_only_wcs_movement
- # periodAngles_Wcsp_to_Wcs_ixyz is (1.5, 1.5, 1.5), so max should be 1.5.
- self.assertEqual(wcs_movement.max_period, 1.5)
-
- def test_max_period_mixed(self):
- """Test that max_period returns maximum of all periods for mixed movement."""
- wcs_movement = self.multiple_periods_wcs_movement
- # periodLp_Wcsp_Lpp is (1.0, 2.0, 3.0).
- # periodAngles_Wcsp_to_Wcs_ixyz is (0.5, 1.5, 2.5).
- # Maximum should be 3.0.
- self.assertEqual(wcs_movement.max_period, 3.0)
-
- def test_max_period_multiple_dimensions(self):
- """Test max_period with multiple dimensions having different periods."""
- wcs_movement = self.basic_wcs_movement
- # Both periodLp_Wcsp_Lpp and periodAngles_Wcsp_to_Wcs_ixyz are (2.0, 2.0, 2.0).
- # Maximum should be 2.0.
- self.assertEqual(wcs_movement.max_period, 2.0)
-
- def test_all_periods_static_movement(self):
- """Test that all_periods returns empty tuple for static movement."""
- wing_cross_section_movement = self.static_wcs_movement
- self.assertEqual(wing_cross_section_movement.all_periods, ())
-
- def test_all_periods_Lp_only(self):
- """Test that all_periods returns correct periods for Lp only movement."""
- wing_cross_section_movement = self.Lp_only_wcs_movement
- # periodLp_Wcsp_Lpp is (1.5, 1.5, 1.5), all non zero.
- # periodAngles_Wcsp_to_Wcs_ixyz is (0.0, 0.0, 0.0).
- # Should return tuple with three 1.5 values.
- self.assertEqual(wing_cross_section_movement.all_periods, (1.5, 1.5, 1.5))
-
- def test_all_periods_angles_only(self):
- """Test that all_periods returns correct periods for angles only movement."""
- wing_cross_section_movement = self.angles_only_wcs_movement
- # periodLp_Wcsp_Lpp is (0.0, 0.0, 0.0).
- # periodAngles_Wcsp_to_Wcs_ixyz is (1.5, 1.5, 1.5), all non zero.
- # Should return tuple with three 1.5 values.
- self.assertEqual(wing_cross_section_movement.all_periods, (1.5, 1.5, 1.5))
-
- def test_all_periods_mixed(self):
- """Test that all_periods returns all non zero periods for mixed movement."""
- wing_cross_section_movement = self.multiple_periods_wcs_movement
- # periodLp_Wcsp_Lpp is (1.0, 2.0, 3.0).
- # periodAngles_Wcsp_to_Wcs_ixyz is (0.5, 1.5, 2.5).
- # Should return tuple with all six values.
- expected = (1.0, 2.0, 3.0, 0.5, 1.5, 2.5)
- self.assertEqual(wing_cross_section_movement.all_periods, expected)
-
- def test_all_periods_contains_duplicates(self):
- """Test that all_periods contains duplicate periods if they appear multiple
- times.
+ def test_generate_wing_cross_sections_returns_wing_cross_sections(self):
+ """Test that generate_wing_cross_sections returns WingCrossSections when
+ called through the public class.
"""
- wing_cross_section_movement = self.basic_wcs_movement
- # Both periodLp_Wcsp_Lpp and periodAngles_Wcsp_to_Wcs_ixyz are (2.0, 2.0, 2.0).
- # Should return tuple with six 2.0 values (not deduplicated).
- expected = (2.0, 2.0, 2.0, 2.0, 2.0, 2.0)
- self.assertEqual(wing_cross_section_movement.all_periods, expected)
-
- def test_all_periods_partial_movement(self):
- """Test all_periods with only some dimensions having non zero periods."""
- wing_cross_section_movement = self.sine_spacing_Lp_wcs_movement
- # periodLp_Wcsp_Lpp is (1.0, 0.0, 0.0), only first element is non zero.
- # periodAngles_Wcsp_to_Wcs_ixyz is (0.0, 0.0, 0.0).
- # Should return tuple with one 1.0 value.
- self.assertEqual(wing_cross_section_movement.all_periods, (1.0,))
-
- def test_generate_wing_cross_sections_parameter_validation(self):
- """Test that generate_wing_cross_sections validates num_steps and delta_time."""
- wcs_movement = self.basic_wcs_movement
-
- # Test invalid num_steps.
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- wcs_movement.generate_wing_cross_sections(num_steps=0, delta_time=0.01)
-
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- wcs_movement.generate_wing_cross_sections(num_steps=-1, delta_time=0.01)
-
- with self.assertRaises(TypeError):
- wcs_movement.generate_wing_cross_sections(
- num_steps="invalid", delta_time=0.01
- )
-
- # Test invalid delta_time.
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=0.0)
-
- # noinspection PyTypeChecker
- with self.assertRaises((ValueError, TypeError)):
- wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=-0.01)
-
- with self.assertRaises(TypeError):
- wcs_movement.generate_wing_cross_sections(
- num_steps=10, delta_time="invalid"
- )
-
- def test_generate_wing_cross_sections_returns_correct_length(self):
- """Test that generate_wing_cross_sections returns list of correct length."""
- wcs_movement = self.basic_wcs_movement
-
- test_num_steps = [1, 5, 10, 50, 100]
- for num_steps in test_num_steps:
- with self.subTest(num_steps=num_steps):
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=num_steps, delta_time=0.01
- )
- self.assertEqual(len(wing_cross_sections), num_steps)
-
- def test_generate_wing_cross_sections_returns_correct_types(self):
- """Test that generate_wing_cross_sections returns WingCrossSections."""
- wcs_movement = self.basic_wcs_movement
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=10, delta_time=0.01
- )
-
- # Verify all elements are WingCrossSections.
- for wcs in wing_cross_sections:
- self.assertIsInstance(wcs, ps.geometry.wing_cross_section.WingCrossSection)
-
- def test_generate_wing_cross_sections_preserves_non_changing_attributes(self):
- """Test that generate_wing_cross_sections preserves non-changing attributes."""
- wcs_movement = self.basic_wcs_movement
- base_wcs = wcs_movement.base_wing_cross_section
-
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=10, delta_time=0.01
- )
-
- # Check that non-changing attributes are preserved.
- for wcs in wing_cross_sections:
- self.assertEqual(wcs.airfoil, base_wcs.airfoil)
- self.assertEqual(wcs.chord, base_wcs.chord)
- self.assertEqual(wcs.num_spanwise_panels, base_wcs.num_spanwise_panels)
- self.assertEqual(
- wcs.control_surface_symmetry_type,
- base_wcs.control_surface_symmetry_type,
- )
- self.assertEqual(
- wcs.control_surface_hinge_point, base_wcs.control_surface_hinge_point
- )
- self.assertEqual(
- wcs.control_surface_deflection, base_wcs.control_surface_deflection
- )
- self.assertEqual(wcs.spanwise_spacing, base_wcs.spanwise_spacing)
-
- def test_generate_wing_cross_sections_static_movement(self):
- """Test that static movement produces constant positions and angles."""
- wcs_movement = self.static_wcs_movement
- base_wcs = wcs_movement.base_wing_cross_section
-
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=50, delta_time=0.01
- )
-
- # All WingCrossSections should have same Lp_Wcsp_Lpp and angles_Wcsp_to_Wcs_ixyz.
- for wcs in wing_cross_sections:
- npt.assert_array_equal(wcs.Lp_Wcsp_Lpp, base_wcs.Lp_Wcsp_Lpp)
- npt.assert_array_equal(
- wcs.angles_Wcsp_to_Wcs_ixyz, base_wcs.angles_Wcsp_to_Wcs_ixyz
- )
-
- def test_phase_offset_Lp(self):
- """Test that phase shifts initial position correctly for Lp_Wcsp_Lpp."""
- wcs_movement = self.phase_offset_Lp_wcs_movement
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=100, delta_time=0.01
- )
-
- # Extract positions.
- x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
- y_positions = np.array([wcs.Lp_Wcsp_Lpp[1] for wcs in wing_cross_sections])
- z_positions = np.array([wcs.Lp_Wcsp_Lpp[2] for wcs in wing_cross_sections])
-
- # Verify that phase offset causes non-zero initial values.
- # With phase offsets, the first values should not all be at the base position.
- self.assertFalse(np.allclose(x_positions[0], 0.0, atol=1e-10))
- self.assertFalse(np.allclose(y_positions[0], 0.0, atol=1e-10))
- self.assertFalse(np.allclose(z_positions[0], 0.0, atol=1e-10))
-
- def test_phase_offset_angles(self):
- """Test that phase shifts initial angles correctly for angles_Wcsp_to_Wcs_ixyz."""
- wcs_movement = self.phase_offset_angles_wcs_movement
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=100, delta_time=0.01
- )
-
- # Extract angles.
- angles_z = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
- )
- angles_y = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[1] for wcs in wing_cross_sections]
- )
- angles_x = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[2] for wcs in wing_cross_sections]
- )
-
- # Verify that phase offset causes non-zero initial values.
- # With phase offsets, the first values should not all be at the base angles.
- self.assertFalse(np.allclose(angles_z[0], 0.0, atol=1e-10))
- self.assertFalse(np.allclose(angles_y[0], 0.0, atol=1e-10))
- self.assertFalse(np.allclose(angles_x[0], 0.0, atol=1e-10))
-
- def test_single_dimension_movement_Lp(self):
- """Test that only one dimension of Lp_Wcsp_Lpp moves."""
- wcs_movement = self.sine_spacing_Lp_wcs_movement
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=50, delta_time=0.01
- )
-
- # Extract positions.
- x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
- y_positions = np.array([wcs.Lp_Wcsp_Lpp[1] for wcs in wing_cross_sections])
- z_positions = np.array([wcs.Lp_Wcsp_Lpp[2] for wcs in wing_cross_sections])
-
- # Only x should vary, y and z should be constant.
- self.assertFalse(np.allclose(x_positions, x_positions[0]))
- npt.assert_array_equal(y_positions, y_positions[0])
- npt.assert_array_equal(z_positions, z_positions[0])
-
- def test_single_dimension_movement_angles(self):
- """Test that only one dimension of angles_Wcsp_to_Wcs_ixyz moves."""
- wcs_movement = self.sine_spacing_angles_wcs_movement
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=50, delta_time=0.01
- )
-
- # Extract angles.
- angles_z = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
- )
- angles_y = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[1] for wcs in wing_cross_sections]
- )
- angles_x = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[2] for wcs in wing_cross_sections]
- )
-
- # Only z should vary, y and x should be constant.
- self.assertFalse(np.allclose(angles_z, angles_z[0]))
- npt.assert_array_equal(angles_y, angles_y[0])
- npt.assert_array_equal(angles_x, angles_x[0])
-
- def test_boundary_amplitude_angles(self):
- """Test amplitude at boundary value (180.0 degrees)."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test amplitude at 180.0 works.
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(180.0, 0.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
- )
- )
- self.assertEqual(wcs_movement.ampAngles_Wcsp_to_Wcs_ixyz[0], 180.0)
-
- def test_boundary_phase_values(self):
- """Test phase at boundary values (-179.9, 0.0, and 180.0)."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Test phase = 0.0 works.
- wcs_movement1 = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- )
- )
- self.assertEqual(wcs_movement1.phaseLp_Wcsp_Lpp[0], 0.0)
-
- # Test phase = 180.0 works (upper boundary, inclusive).
- wcs_movement2 = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- phaseLp_Wcsp_Lpp=(180.0, 0.0, 0.0),
- )
- )
- self.assertEqual(wcs_movement2.phaseLp_Wcsp_Lpp[0], 180.0)
-
- # Test phase = -179.9 works (near lower boundary).
- wcs_movement3 = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- phaseLp_Wcsp_Lpp=(-179.9, 0.0, 0.0),
- )
- )
- self.assertEqual(wcs_movement3.phaseLp_Wcsp_Lpp[0], -179.9)
-
- def test_custom_spacing_function_Lp(self):
- """Test that custom spacing function works for Lp_Wcsp_Lpp."""
- wcs_movement = self.custom_spacing_Lp_wcs_movement
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=100, delta_time=0.01
- )
-
- # Extract x-positions.
- x_positions = np.array([wcs.Lp_Wcsp_Lpp[0] for wcs in wing_cross_sections])
-
- # Verify that values vary (not constant).
- self.assertFalse(np.allclose(x_positions, x_positions[0]))
-
- # Verify that values are within expected range.
- # For custom_harmonic with amp=1.0, values should be in [-1.0, 1.0].
- self.assertTrue(np.all(x_positions >= -1.1))
- self.assertTrue(np.all(x_positions <= 1.1))
-
- def test_custom_spacing_function_angles(self):
- """Test that custom spacing function works for angles_Wcsp_to_Wcs_ixyz."""
- wcs_movement = self.custom_spacing_angles_wcs_movement
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=100, delta_time=0.01
- )
-
- # Extract z-angles.
- angles_z = np.array(
- [wcs.angles_Wcsp_to_Wcs_ixyz[0] for wcs in wing_cross_sections]
- )
-
- # Verify that values vary (not constant).
- self.assertFalse(np.allclose(angles_z, angles_z[0]))
-
- # Verify that values are within expected range.
- # For custom_triangle with amp=10.0, values should be in [-10.0, 10.0].
- self.assertTrue(np.all(angles_z >= -11.0))
- self.assertTrue(np.all(angles_z <= 11.0))
-
- def test_custom_spacing_function_mixed_with_standard(self):
- """Test that custom and standard spacing functions can be mixed."""
- wcs_movement = self.mixed_custom_and_standard_spacing_wcs_movement
- wing_cross_sections = wcs_movement.generate_wing_cross_sections(
- num_steps=100, delta_time=0.01
- )
-
- # Verify that WingCrossSections are generated successfully.
- self.assertEqual(len(wing_cross_sections), 100)
- for wcs in wing_cross_sections:
- self.assertIsInstance(wcs, ps.geometry.wing_cross_section.WingCrossSection)
-
- def test_custom_function_validation_invalid_start_value(self):
- """Test that custom function with invalid start value raises error."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Define invalid custom function that doesn't start at 0.
- def invalid_nonzero_start(x):
- return np.sin(x) + 1.0
-
- # Should raise error during initialization or generation.
- with self.assertRaises(ValueError):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=(invalid_nonzero_start, "sine", "sine"),
- )
- )
- wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_invalid_end_value(self):
- """Test that custom function with invalid end value raises error."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Define invalid custom function that doesn't return to 0 at 2*pi.
- def invalid_nonzero_end(x):
- return np.sin(x) + 0.1
-
- with self.assertRaises(ValueError):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=(invalid_nonzero_end, "sine", "sine"),
- )
- )
- wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_invalid_mean(self):
- """Test that custom function with invalid mean raises error."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Define invalid custom function with non-zero mean.
- def invalid_nonzero_mean(x):
- return np.sin(x) + 0.5
-
- with self.assertRaises(ValueError):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=(invalid_nonzero_mean, "sine", "sine"),
- )
- )
- wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_invalid_amplitude(self):
- """Test that custom function with invalid amplitude raises error."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Define invalid custom function with wrong amplitude.
- def invalid_wrong_amplitude(x):
- return 2.0 * np.sin(x)
-
- with self.assertRaises(ValueError):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=(invalid_wrong_amplitude, "sine", "sine"),
- )
- )
- wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_not_periodic(self):
- """Test that custom function that is not periodic raises error."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Define invalid custom function that is not periodic.
- def invalid_not_periodic(x):
- return np.tanh(x)
-
- with self.assertRaises(ValueError):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=(invalid_not_periodic, "sine", "sine"),
- )
- )
- wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_returns_non_finite(self):
- """Test that custom function returning NaN or Inf raises error."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Define invalid custom function that returns NaN.
- def invalid_non_finite(x):
- return np.where(x < np.pi, np.sin(x), np.nan)
-
- with self.assertRaises(ValueError):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=(invalid_non_finite, "sine", "sine"),
- )
- )
- wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=0.01)
-
- def test_custom_function_validation_wrong_shape(self):
- """Test that custom function returning wrong shape raises error."""
- from tests.unit.fixtures import geometry_fixtures
-
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Define invalid custom function that returns wrong shape.
- def invalid_wrong_shape(x):
- return np.sin(x)[: len(x) // 2]
-
- with self.assertRaises(ValueError):
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- periodLp_Wcsp_Lpp=(1.0, 0.0, 0.0),
- spacingLp_Wcsp_Lpp=(invalid_wrong_shape, "sine", "sine"),
- )
- )
- wcs_movement.generate_wing_cross_sections(num_steps=10, delta_time=0.01)
-
- def test_unsafe_amplitude_causes_error_Lp(self):
- """Test that amplitude too high for base Lp value causes error during generation."""
- from tests.unit.fixtures import geometry_fixtures
-
- # Use root fixture with Lp_Wcsp_Lpp = [0.0, 0.0, 0.0].
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Create WingCrossSectionMovement with amplitude that will drive the second element in
- # Lp_Wcsp_Lpp negative, which is never allowed by WingCrossSection.
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
- periodLp_Wcsp_Lpp=(0.0, 1.0, 0.0),
- spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
- phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
- )
- )
-
- # Generating WingCrossSections should raise ValueError when Lp goes negative.
- with self.assertRaises(ValueError) as context:
- wcs_movement.generate_wing_cross_sections(num_steps=100, delta_time=0.01)
-
- # Verify the error message is about Lp_Wcsp_Lpp validation.
- self.assertIn("Lp_Wcsp_Lpp", str(context.exception))
-
- def test_unsafe_amplitude_causes_error_angles(self):
- """Test that amplitude too high for base angle value causes error during generation."""
- from tests.unit.fixtures import geometry_fixtures
-
- # Use root fixture with angles = [0.0, 0.0, 0.0].
- base_wcs = geometry_fixtures.make_root_wing_cross_section_fixture()
-
- # Create WingCrossSectionMovement with amplitude that will drive angles out of valid range.
- # Valid range for angles is (-180, 180], so amplitude 181 with base 0 will exceed.
- wcs_movement = (
- ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
- base_wing_cross_section=base_wcs,
- ampAngles_Wcsp_to_Wcs_ixyz=(179.0, 0.0, 0.0),
- periodAngles_Wcsp_to_Wcs_ixyz=(1.0, 0.0, 0.0),
- spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
- phaseAngles_Wcsp_to_Wcs_ixyz=(90.0, 0.0, 0.0),
- )
- )
-
- # Generating WingCrossSections should raise ValueError when angles exceed range.
- with self.assertRaises(ValueError) as context:
- wcs_movement.generate_wing_cross_sections(num_steps=100, delta_time=0.01)
-
- # Verify the error message is about angles_Wcsp_to_Wcs_ixyz validation.
- self.assertIn("angles_Wcsp_to_Wcs_ixyz", str(context.exception))
-
-
-class TestWingCrossSectionMovementImmutability(unittest.TestCase):
- """Tests for WingCrossSectionMovement attribute immutability."""
-
- def setUp(self):
- """Set up test fixtures for immutability tests."""
- self.wing_cross_section_movement = (
+ wing_cross_section_movement = (
wing_cross_section_movement_fixtures.make_basic_wing_cross_section_movement_fixture()
)
-
- def test_immutable_base_wing_cross_section_property(self):
- """Test that base_wing_cross_section property is read only."""
- from tests.unit.fixtures import geometry_fixtures
-
- new_wing_cross_section = (
- geometry_fixtures.make_root_wing_cross_section_fixture()
+ wing_cross_sections = wing_cross_section_movement.generate_wing_cross_sections(
+ num_steps=5, delta_time=0.01
)
- with self.assertRaises(AttributeError):
- self.wing_cross_section_movement.base_wing_cross_section = (
- new_wing_cross_section
- )
-
- def test_immutable_ampLp_Wcsp_Lpp_property(self):
- """Test that ampLp_Wcsp_Lpp property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_cross_section_movement.ampLp_Wcsp_Lpp = np.array([1.0, 2.0, 3.0])
-
- def test_immutable_ampLp_Wcsp_Lpp_array_read_only(self):
- """Test that ampLp_Wcsp_Lpp array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_cross_section_movement.ampLp_Wcsp_Lpp[0] = 999.0
-
- def test_immutable_periodLp_Wcsp_Lpp_property(self):
- """Test that periodLp_Wcsp_Lpp property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_cross_section_movement.periodLp_Wcsp_Lpp = np.array(
- [1.0, 2.0, 3.0]
- )
-
- def test_immutable_periodLp_Wcsp_Lpp_array_read_only(self):
- """Test that periodLp_Wcsp_Lpp array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_cross_section_movement.periodLp_Wcsp_Lpp[0] = 999.0
-
- def test_immutable_spacingLp_Wcsp_Lpp_property(self):
- """Test that spacingLp_Wcsp_Lpp property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_cross_section_movement.spacingLp_Wcsp_Lpp = (
- "uniform",
- "uniform",
- "uniform",
- )
-
- def test_immutable_phaseLp_Wcsp_Lpp_property(self):
- """Test that phaseLp_Wcsp_Lpp property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_cross_section_movement.phaseLp_Wcsp_Lpp = np.array(
- [45.0, 45.0, 45.0]
+ self.assertEqual(len(wing_cross_sections), 5)
+ for wing_cross_section in wing_cross_sections:
+ self.assertIsInstance(
+ wing_cross_section,
+ ps.geometry.wing_cross_section.WingCrossSection,
)
- def test_immutable_phaseLp_Wcsp_Lpp_array_read_only(self):
- """Test that phaseLp_Wcsp_Lpp array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_cross_section_movement.phaseLp_Wcsp_Lpp[0] = 999.0
-
- def test_immutable_ampAngles_Wcsp_to_Wcs_ixyz_property(self):
- """Test that ampAngles_Wcsp_to_Wcs_ixyz property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_cross_section_movement.ampAngles_Wcsp_to_Wcs_ixyz = np.array(
- [1.0, 2.0, 3.0]
- )
-
- def test_immutable_ampAngles_Wcsp_to_Wcs_ixyz_array_read_only(self):
- """Test that ampAngles_Wcsp_to_Wcs_ixyz array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_cross_section_movement.ampAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
-
- def test_immutable_periodAngles_Wcsp_to_Wcs_ixyz_property(self):
- """Test that periodAngles_Wcsp_to_Wcs_ixyz property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_cross_section_movement.periodAngles_Wcsp_to_Wcs_ixyz = np.array(
- [1.0, 2.0, 3.0]
- )
-
- def test_immutable_periodAngles_Wcsp_to_Wcs_ixyz_array_read_only(self):
- """Test that periodAngles_Wcsp_to_Wcs_ixyz array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_cross_section_movement.periodAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
-
- def test_immutable_spacingAngles_Wcsp_to_Wcs_ixyz_property(self):
- """Test that spacingAngles_Wcsp_to_Wcs_ixyz property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_cross_section_movement.spacingAngles_Wcsp_to_Wcs_ixyz = (
- "uniform",
- "uniform",
- "uniform",
- )
-
- def test_immutable_phaseAngles_Wcsp_to_Wcs_ixyz_property(self):
- """Test that phaseAngles_Wcsp_to_Wcs_ixyz property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_cross_section_movement.phaseAngles_Wcsp_to_Wcs_ixyz = np.array(
- [45.0, 45.0, 45.0]
- )
-
- def test_immutable_phaseAngles_Wcsp_to_Wcs_ixyz_array_read_only(self):
- """Test that phaseAngles_Wcsp_to_Wcs_ixyz array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_cross_section_movement.phaseAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
-
-
-class TestWingCrossSectionMovementCaching(unittest.TestCase):
- """Tests for WingCrossSectionMovement caching behavior."""
-
- def setUp(self):
- """Set up test fixtures for caching tests."""
- self.wing_cross_section_movement = (
- wing_cross_section_movement_fixtures.make_basic_wing_cross_section_movement_fixture()
- )
-
- def test_all_periods_caching_returns_same_object(self):
- """Test that repeated access to all_periods returns the same cached object."""
- all_periods_1 = self.wing_cross_section_movement.all_periods
- all_periods_2 = self.wing_cross_section_movement.all_periods
- self.assertIs(all_periods_1, all_periods_2)
-
- def test_max_period_caching_returns_same_value(self):
- """Test that repeated access to max_period returns the same cached value."""
- max_period_1 = self.wing_cross_section_movement.max_period
- max_period_2 = self.wing_cross_section_movement.max_period
- # Since floats are immutable, we check equality rather than identity.
- self.assertEqual(max_period_1, max_period_2)
-
-
-class TestWingCrossSectionMovementDeepcopy(unittest.TestCase):
- """Tests for WingCrossSectionMovement deepcopy behavior."""
-
- def setUp(self):
- """Set up test fixtures for deepcopy tests."""
- self.wing_cross_section_movement = (
- wing_cross_section_movement_fixtures.make_basic_wing_cross_section_movement_fixture()
- )
-
- def test_deepcopy_returns_new_instance(self):
- """Test that deepcopy returns a new WingCrossSectionMovement instance."""
- original = self.wing_cross_section_movement
- copied = copy.deepcopy(original)
-
- self.assertIsInstance(
- copied, ps.movements.wing_cross_section_movement.WingCrossSectionMovement
- )
- self.assertIsNot(original, copied)
-
- def test_deepcopy_preserves_attribute_values(self):
- """Test that deepcopy preserves all attribute values."""
- original = self.wing_cross_section_movement
- copied = copy.deepcopy(original)
-
- # Check numpy array attributes.
- npt.assert_array_equal(copied.ampLp_Wcsp_Lpp, original.ampLp_Wcsp_Lpp)
- npt.assert_array_equal(copied.periodLp_Wcsp_Lpp, original.periodLp_Wcsp_Lpp)
- npt.assert_array_equal(copied.phaseLp_Wcsp_Lpp, original.phaseLp_Wcsp_Lpp)
- npt.assert_array_equal(
- copied.ampAngles_Wcsp_to_Wcs_ixyz, original.ampAngles_Wcsp_to_Wcs_ixyz
- )
- npt.assert_array_equal(
- copied.periodAngles_Wcsp_to_Wcs_ixyz, original.periodAngles_Wcsp_to_Wcs_ixyz
- )
- npt.assert_array_equal(
- copied.phaseAngles_Wcsp_to_Wcs_ixyz, original.phaseAngles_Wcsp_to_Wcs_ixyz
- )
-
- # Check tuple attributes.
- self.assertEqual(copied.spacingLp_Wcsp_Lpp, original.spacingLp_Wcsp_Lpp)
- self.assertEqual(
- copied.spacingAngles_Wcsp_to_Wcs_ixyz,
- original.spacingAngles_Wcsp_to_Wcs_ixyz,
- )
-
- def test_deepcopy_numpy_arrays_are_independent(self):
- """Test that deepcopied numpy arrays are independent objects."""
- original = self.wing_cross_section_movement
- copied = copy.deepcopy(original)
-
- # Verify arrays are different objects.
- self.assertIsNot(copied.ampLp_Wcsp_Lpp, original.ampLp_Wcsp_Lpp)
- self.assertIsNot(copied.periodLp_Wcsp_Lpp, original.periodLp_Wcsp_Lpp)
- self.assertIsNot(copied.phaseLp_Wcsp_Lpp, original.phaseLp_Wcsp_Lpp)
- self.assertIsNot(
- copied.ampAngles_Wcsp_to_Wcs_ixyz, original.ampAngles_Wcsp_to_Wcs_ixyz
- )
- self.assertIsNot(
- copied.periodAngles_Wcsp_to_Wcs_ixyz, original.periodAngles_Wcsp_to_Wcs_ixyz
- )
- self.assertIsNot(
- copied.phaseAngles_Wcsp_to_Wcs_ixyz, original.phaseAngles_Wcsp_to_Wcs_ixyz
- )
-
- def test_deepcopy_numpy_arrays_cannot_be_modified_in_place(self):
- """Test that deepcopied numpy arrays raise ValueError on in place modification."""
- original = self.wing_cross_section_movement
- copied = copy.deepcopy(original)
-
- # Verify that attempting to modify copied arrays raises ValueError.
- with self.assertRaises(ValueError):
- copied.ampLp_Wcsp_Lpp[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.periodLp_Wcsp_Lpp[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.phaseLp_Wcsp_Lpp[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.ampAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.periodAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.phaseAngles_Wcsp_to_Wcs_ixyz[0] = 999.0
-
- def test_deepcopy_base_wing_cross_section_is_independent(self):
- """Test that deepcopied base_wing_cross_section is an independent object."""
- original = self.wing_cross_section_movement
- copied = copy.deepcopy(original)
-
- # Verify base_wing_cross_section is a different object.
- self.assertIsNot(
- copied.base_wing_cross_section, original.base_wing_cross_section
- )
-
- # Verify attributes are equal.
- self.assertEqual(
- copied.base_wing_cross_section.chord,
- original.base_wing_cross_section.chord,
- )
- npt.assert_array_equal(
- copied.base_wing_cross_section.Lp_Wcsp_Lpp,
- original.base_wing_cross_section.Lp_Wcsp_Lpp,
- )
- npt.assert_array_equal(
- copied.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz,
- original.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz,
- )
-
- def test_deepcopy_resets_caches_to_none(self):
- """Test that deepcopy resets cached derived properties to None."""
- original = self.wing_cross_section_movement
-
- # Access cached properties to populate caches.
- _ = original.all_periods
- _ = original.max_period
-
- # Verify original caches are populated.
- self.assertIsNotNone(original._all_periods)
- self.assertIsNotNone(original._max_period)
-
- # Deepcopy the object.
- copied = copy.deepcopy(original)
-
- # Verify copied caches are reset to None.
- self.assertIsNone(copied._all_periods)
- self.assertIsNone(copied._max_period)
-
- def test_deepcopy_cached_properties_can_be_recomputed(self):
- """Test that cached properties work correctly after deepcopy."""
- original = self.wing_cross_section_movement
-
- # Get original cached values.
- original_all_periods = original.all_periods
- original_max_period = original.max_period
-
- # Deepcopy the object.
- copied = copy.deepcopy(original)
-
- # Verify cached properties can be computed and match original.
- self.assertEqual(copied.all_periods, original_all_periods)
- self.assertEqual(copied.max_period, original_max_period)
-
- def test_deepcopy_generate_wing_cross_sections_produces_same_results(self):
- """Test that generate_wing_cross_sections produces same results after deepcopy."""
- original = self.wing_cross_section_movement
- copied = copy.deepcopy(original)
-
- num_steps = 50
- delta_time = 0.01
-
- original_wcs_list = original.generate_wing_cross_sections(
- num_steps=num_steps, delta_time=delta_time
- )
- copied_wcs_list = copied.generate_wing_cross_sections(
- num_steps=num_steps, delta_time=delta_time
- )
-
- # Verify same number of WingCrossSections.
- self.assertEqual(len(copied_wcs_list), len(original_wcs_list))
-
- # Verify each WingCrossSection has matching attributes.
- for original_wcs, copied_wcs in zip(original_wcs_list, copied_wcs_list):
- npt.assert_array_equal(copied_wcs.Lp_Wcsp_Lpp, original_wcs.Lp_Wcsp_Lpp)
- npt.assert_array_equal(
- copied_wcs.angles_Wcsp_to_Wcs_ixyz, original_wcs.angles_Wcsp_to_Wcs_ixyz
- )
- self.assertEqual(copied_wcs.chord, original_wcs.chord)
-
- def test_deepcopy_handles_memo_correctly(self):
- """Test that deepcopy handles the memo dict correctly for circular references."""
- original = self.wing_cross_section_movement
- memo = {}
-
- # First deepcopy.
- copied1 = copy.deepcopy(original, memo)
-
- # Verify original is in memo.
- self.assertIn(id(original), memo)
- self.assertIs(memo[id(original)], copied1)
-
- # Second deepcopy with same memo should return same object.
- copied2 = copy.deepcopy(original, memo)
- self.assertIs(copied1, copied2)
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/test_wing_movement.py b/tests/unit/test_wing_movement.py
index 57c41a424..098edb970 100644
--- a/tests/unit/test_wing_movement.py
+++ b/tests/unit/test_wing_movement.py
@@ -2,10 +2,6 @@
import unittest
-import numpy as np
-import numpy.testing as npt
-from scipy import signal
-
import pterasoftware as ps
from tests.unit.fixtures import (
geometry_fixtures,
@@ -17,1168 +13,58 @@
class TestWingMovement(unittest.TestCase):
"""This is a class with functions to test WingMovements."""
- @classmethod
- def setUpClass(cls):
- """Set up test fixtures once for all WingMovement tests."""
- # Spacing test fixtures for Ler_Gs_Cgs.
- cls.sine_spacing_Ler_wing_movement = (
- wing_movement_fixtures.make_sine_spacing_Ler_wing_movement_fixture()
- )
- cls.uniform_spacing_Ler_wing_movement = (
- wing_movement_fixtures.make_uniform_spacing_Ler_wing_movement_fixture()
- )
- cls.mixed_spacing_Ler_wing_movement = (
- wing_movement_fixtures.make_mixed_spacing_Ler_wing_movement_fixture()
- )
-
- # Spacing test fixtures for angles_Gs_to_Wn_ixyz.
- cls.sine_spacing_angles_wing_movement = (
- wing_movement_fixtures.make_sine_spacing_angles_wing_movement_fixture()
- )
- cls.uniform_spacing_angles_wing_movement = (
- wing_movement_fixtures.make_uniform_spacing_angles_wing_movement_fixture()
- )
- cls.mixed_spacing_angles_wing_movement = (
- wing_movement_fixtures.make_mixed_spacing_angles_wing_movement_fixture()
- )
-
- # Additional test fixtures.
- cls.static_wing_movement = (
- wing_movement_fixtures.make_static_wing_movement_fixture()
- )
- cls.basic_wing_movement = (
- wing_movement_fixtures.make_basic_wing_movement_fixture()
- )
- cls.Ler_only_wing_movement = (
- wing_movement_fixtures.make_Ler_only_wing_movement_fixture()
- )
- cls.angles_only_wing_movement = (
- wing_movement_fixtures.make_angles_only_wing_movement_fixture()
- )
- cls.phase_offset_Ler_wing_movement = (
- wing_movement_fixtures.make_phase_offset_Ler_wing_movement_fixture()
- )
- cls.phase_offset_angles_wing_movement = (
- wing_movement_fixtures.make_phase_offset_angles_wing_movement_fixture()
- )
- cls.multiple_periods_wing_movement = (
- wing_movement_fixtures.make_multiple_periods_wing_movement_fixture()
- )
- cls.custom_spacing_Ler_wing_movement = (
- wing_movement_fixtures.make_custom_spacing_Ler_wing_movement_fixture()
- )
- cls.custom_spacing_angles_wing_movement = (
- wing_movement_fixtures.make_custom_spacing_angles_wing_movement_fixture()
- )
- cls.mixed_custom_and_standard_spacing_wing_movement = (
- wing_movement_fixtures.make_mixed_custom_and_standard_spacing_wing_movement_fixture()
- )
- cls.rotation_point_offset_wing_movement = (
- wing_movement_fixtures.make_rotation_point_offset_wing_movement_fixture()
- )
-
- def test_spacing_sine_for_Ler_Gs_Cgs(self):
- """Test that sine spacing actually produces sinusoidal motion for
- Ler_Gs_Cgs."""
- num_steps = 100
- delta_time = 0.01
- wings = self.sine_spacing_Ler_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract x-positions from generated Wings.
- x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
-
- # Calculate expected sine wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 0.2 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated positions match the expected sine wave.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_spacing_uniform_for_Ler_Gs_Cgs(self):
- """Test that uniform spacing actually produces triangular wave motion for
- Ler_Gs_Cgs."""
- num_steps = 100
- delta_time = 0.01
- wings = self.uniform_spacing_Ler_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract x-positions from generated Wings.
- x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
-
- # Calculate expected triangular wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 0.2 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
-
- # Assert that the generated positions match the expected triangular wave.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_spacing_mixed_for_Ler_Gs_Cgs(self):
- """Test that mixed spacing types work correctly for Ler_Gs_Cgs."""
- num_steps = 100
- delta_time = 0.01
- wings = self.mixed_spacing_Ler_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract positions from generated Wings.
- x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
- y_positions = np.array([wing.Ler_Gs_Cgs[1] for wing in wings])
- z_positions = np.array([wing.Ler_Gs_Cgs[2] for wing in wings])
-
- # Calculate expected values for each dimension.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 0.2 * np.sin(2 * np.pi * times / 1.0)
- expected_y = 0.15 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
- expected_z = 0.1 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated positions match the expected values.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
-
- def test_spacing_sine_for_angles_Gs_to_Wn_ixyz(self):
- """Test that sine spacing actually produces sinusoidal motion for
- angles_Gs_to_Wn_ixyz."""
- num_steps = 100
- delta_time = 0.01
- wings = self.sine_spacing_angles_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract x-angles from generated Wings.
- x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
-
- # Calculate expected sine wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 10.0 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated angles match the expected sine wave.
- npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_spacing_uniform_for_angles_Gs_to_Wn_ixyz(self):
- """Test that uniform spacing actually produces triangular wave motion for
- angles_Gs_to_Wn_ixyz."""
- num_steps = 100
- delta_time = 0.01
- wings = self.uniform_spacing_angles_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract x-angles from generated Wings.
- x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
-
- # Calculate expected triangular wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 10.0 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
-
- # Assert that the generated angles match the expected triangular wave.
- npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_spacing_mixed_for_angles_Gs_to_Wn_ixyz(self):
- """Test that mixed spacing types work correctly for
- angles_Gs_to_Wn_ixyz."""
- num_steps = 100
- delta_time = 0.01
- wings = self.mixed_spacing_angles_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract angles from generated Wings.
- x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
- y_angles = np.array([wing.angles_Gs_to_Wn_ixyz[1] for wing in wings])
- z_angles = np.array([wing.angles_Gs_to_Wn_ixyz[2] for wing in wings])
-
- # Calculate expected values for each dimension.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 10.0 * np.sin(2 * np.pi * times / 1.0)
- expected_y = 15.0 * signal.sawtooth(2 * np.pi * times / 1.0 + np.pi / 2, 0.5)
- expected_z = 8.0 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that the generated angles match the expected values.
- npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(y_angles, expected_y, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(z_angles, expected_z, rtol=1e-10, atol=1e-14)
-
- def test_static_wing_movement_produces_constant_wings(self):
- """Test that static WingMovement produces Wings with constant parameters."""
- num_steps = 50
- delta_time = 0.02
- wings = self.static_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract parameters from all Wings.
- Lers_G_Cg = np.array([wing.Ler_Gs_Cgs for wing in wings])
- angles_Gs_to_Wn_ixyzs = np.array([wing.angles_Gs_to_Wn_ixyz for wing in wings])
-
- # Assert that all Wings have the same parameters.
- npt.assert_allclose(
- Lers_G_Cg,
- np.tile(wings[0].Ler_Gs_Cgs, (num_steps, 1)),
- rtol=1e-10,
- atol=1e-14,
- )
- npt.assert_allclose(
- angles_Gs_to_Wn_ixyzs,
- np.tile(wings[0].angles_Gs_to_Wn_ixyz, (num_steps, 1)),
- rtol=1e-10,
- atol=1e-14,
- )
-
- def test_generate_wings_returns_correct_number(self):
- """Test that generate_wings returns the correct number of Wings."""
- num_steps = 75
- delta_time = 0.015
- wings = self.basic_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- self.assertEqual(len(wings), num_steps)
-
- def test_generate_wings_preserves_wing_properties(self):
- """Test that generate_wings preserves non-changing Wing properties."""
- num_steps = 30
- delta_time = 0.02
- wings = self.basic_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Check that all Wings have the same non-changing properties.
- base_wing = self.basic_wing_movement.base_wing
- for wing in wings:
- self.assertEqual(wing.name, base_wing.name)
- self.assertEqual(wing.symmetric, base_wing.symmetric)
- self.assertEqual(wing.mirror_only, base_wing.mirror_only)
- npt.assert_array_equal(wing.symmetryNormal_G, base_wing.symmetryNormal_G)
- npt.assert_array_equal(
- wing.symmetryPoint_G_Cg, base_wing.symmetryPoint_G_Cg
+ def test_is_subclass_of_core(self):
+ """Test that WingMovement is a subclass of CoreWingMovement."""
+ self.assertTrue(
+ issubclass(
+ ps.movements.wing_movement.WingMovement,
+ ps._core.CoreWingMovement,
)
- self.assertEqual(wing.num_chordwise_panels, base_wing.num_chordwise_panels)
- self.assertEqual(wing.chordwise_spacing, base_wing.chordwise_spacing)
- self.assertEqual(
- len(wing.wing_cross_sections), len(base_wing.wing_cross_sections)
- )
-
- def test_phase_offset_Ler_produces_shifted_motion(self):
- """Test that phase offset for Ler_Gs_Cgs produces phase-shifted
- motion."""
- num_steps = 100
- delta_time = 0.01
- wings = self.phase_offset_Ler_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
)
- # Extract positions from generated Wings.
- x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
- y_positions = np.array([wing.Ler_Gs_Cgs[1] for wing in wings])
- z_positions = np.array([wing.Ler_Gs_Cgs[2] for wing in wings])
-
- # Calculate expected phase-shifted sine waves.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 0.1 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(90.0))
- expected_y = 0.08 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(-45.0))
- expected_z = 0.06 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(60.0))
-
- # Assert that the generated positions match the expected phase-shifted waves.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
-
- def test_phase_offset_angles_produces_shifted_motion(self):
- """Test that phase offset for angles_Gs_to_Wn_ixyz produces
- phase-shifted motion."""
- num_steps = 100
- delta_time = 0.01
- wings = self.phase_offset_angles_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract angles from generated Wings.
- x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
- y_angles = np.array([wing.angles_Gs_to_Wn_ixyz[1] for wing in wings])
- z_angles = np.array([wing.angles_Gs_to_Wn_ixyz[2] for wing in wings])
-
- # Calculate expected phase-shifted sine waves.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 10.0 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(45.0))
- expected_y = 12.0 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(90.0))
- expected_z = 8.0 * np.sin(2 * np.pi * times / 1.0 + np.deg2rad(-30.0))
-
- # Assert that the generated angles match the expected phase-shifted waves.
- npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(y_angles, expected_y, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(z_angles, expected_z, rtol=1e-10, atol=1e-14)
-
- def test_max_period_static_movement(self):
- """Test that max_period returns 0.0 for static WingMovement."""
- max_period = self.static_wing_movement.max_period
- self.assertEqual(max_period, 0.0)
-
- def test_max_period_Ler_only_movement(self):
- """Test that max_period correctly identifies the maximum period for
- Ler-only WingMovement."""
- max_period = self.Ler_only_wing_movement.max_period
- self.assertEqual(max_period, 1.5)
-
- def test_max_period_angles_only_movement(self):
- """Test that max_period correctly identifies the maximum period for
- angles-only WingMovement."""
- max_period = self.angles_only_wing_movement.max_period
- self.assertEqual(max_period, 1.5)
-
- def test_max_period_multiple_periods_movement(self):
- """Test that max_period correctly identifies the maximum period when
- different dimensions have different periods."""
- max_period = self.multiple_periods_wing_movement.max_period
-
- # The maximum should be from either the WingMovement's own motion or from
- # WingCrossSectionMovements.
- expected_max = max(3.0, 2.5, 2.0)
- self.assertEqual(max_period, expected_max)
-
- def test_initialization_with_valid_parameters(self):
- """Test WingMovement initialization with valid parameters."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
- ]
-
- wing_movement = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.1, 0.05, 0.02),
- periodLer_Gs_Cgs=(1.0, 1.0, 1.0),
- spacingLer_Gs_Cgs=("sine", "uniform", "sine"),
- phaseLer_Gs_Cgs=(0.0, 45.0, -30.0),
- ampAngles_Gs_to_Wn_ixyz=(5.0, 3.0, 2.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 1.0),
- spacingAngles_Gs_to_Wn_ixyz=("uniform", "sine", "uniform"),
- phaseAngles_Gs_to_Wn_ixyz=(30.0, 0.0, -45.0),
- )
-
- self.assertIsInstance(wing_movement, ps.movements.wing_movement.WingMovement)
- self.assertEqual(wing_movement.base_wing, base_wing)
- self.assertEqual(
- len(wing_movement.wing_cross_section_movements),
- len(base_wing.wing_cross_sections),
- )
- npt.assert_array_equal(wing_movement.ampLer_Gs_Cgs, np.array([0.1, 0.05, 0.02]))
- npt.assert_array_equal(
- wing_movement.periodLer_Gs_Cgs, np.array([1.0, 1.0, 1.0])
- )
- self.assertEqual(wing_movement.spacingLer_Gs_Cgs, ("sine", "uniform", "sine"))
- npt.assert_array_equal(
- wing_movement.phaseLer_Gs_Cgs, np.array([0.0, 45.0, -30.0])
- )
- npt.assert_array_equal(
- wing_movement.ampAngles_Gs_to_Wn_ixyz, np.array([5.0, 3.0, 2.0])
- )
- npt.assert_array_equal(
- wing_movement.periodAngles_Gs_to_Wn_ixyz, np.array([1.0, 1.0, 1.0])
- )
- self.assertEqual(
- wing_movement.spacingAngles_Gs_to_Wn_ixyz,
- ("uniform", "sine", "uniform"),
- )
- npt.assert_array_equal(
- wing_movement.phaseAngles_Gs_to_Wn_ixyz, np.array([30.0, 0.0, -45.0])
- )
-
- def test_initialization_invalid_base_wing(self):
- """Test that WingMovement initialization fails with invalid base_wing."""
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- ]
-
- with self.assertRaises(TypeError):
- ps.movements.wing_movement.WingMovement(
- base_wing="not_a_wing",
- wing_cross_section_movements=wcs_movements,
- )
-
- def test_initialization_invalid_wing_cross_section_movements_type(self):
- """Test that WingMovement initialization fails with invalid
- wing_cross_section_movements type."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
-
- with self.assertRaises(TypeError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements="not_a_list",
- )
-
- def test_initialization_invalid_wing_cross_section_movements_length(self):
- """Test that WingMovement initialization fails when
- wing_cross_section_movements length doesn't match base_wing."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- ]
-
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- )
-
- def test_initialization_ampLer_Gs_Cgs_validation(self):
- """Test ampLer_Gs_Cgs parameter validation."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
- ]
-
- # Test with negative amplitude.
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(-0.1, 0.0, 0.0),
- periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
- )
-
- def test_initialization_periodLer_Gs_Cgs_validation(self):
- """Test periodLer_Gs_Cgs parameter validation."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
- ]
-
- # Test with zero amplitude but non-zero period.
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
- )
-
- def test_initialization_phaseLer_Gs_Cgs_validation(self):
- """Test phaseLer_Gs_Cgs parameter validation."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
- ]
-
- # Test with phase out of valid range.
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.1, 0.0, 0.0),
- periodLer_Gs_Cgs=(1.0, 0.0, 0.0),
- phaseLer_Gs_Cgs=(181.0, 0.0, 0.0),
- )
-
- # Test with zero amplitude but non-zero phase.
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
- periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
- phaseLer_Gs_Cgs=(45.0, 0.0, 0.0),
- )
-
- def test_initialization_ampAngles_Gs_to_Wn_ixyz_validation(self):
- """Test ampAngles_Gs_to_Wn_ixyz parameter validation."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
- ]
-
- # Test with amplitude > 180 degrees.
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampAngles_Gs_to_Wn_ixyz=(180.1, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- )
-
- # Test with negative amplitude.
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampAngles_Gs_to_Wn_ixyz=(-10.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- )
-
- def test_initialization_periodAngles_Gs_to_Wn_ixyz_validation(self):
- """Test periodAngles_Gs_to_Wn_ixyz parameter validation."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
- ]
-
- # Test with zero amplitude but non-zero period.
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- )
-
- def test_initialization_phaseAngles_Gs_to_Wn_ixyz_validation(self):
- """Test phaseAngles_Gs_to_Wn_ixyz parameter validation."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
- ]
-
- # Test with phase out of valid range.
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- phaseAngles_Gs_to_Wn_ixyz=(181.0, 0.0, 0.0),
- )
-
- # Test with zero amplitude but non-zero phase.
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
- phaseAngles_Gs_to_Wn_ixyz=(45.0, 0.0, 0.0),
- )
-
- def test_custom_spacing_Ler_produces_expected_motion(self):
- """Test that custom spacing function for Ler_Gs_Cgs produces
- expected motion."""
- num_steps = 100
- delta_time = 0.01
- wings = self.custom_spacing_Ler_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract x-positions from generated Wings.
- x_positions = np.array([wing.Ler_Gs_Cgs[0] for wing in wings])
-
- # Calculate expected custom harmonic wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- x_rad = 2 * np.pi * times / 1.0
- expected_x = (
- 0.15
- * (3.0 / (2.0 * np.sqrt(2.0)))
- * (np.sin(x_rad) + (1.0 / 3.0) * np.sin(3.0 * x_rad))
- )
-
- # Assert that the generated positions match the expected custom wave.
- npt.assert_allclose(x_positions, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_custom_spacing_angles_produces_expected_motion(self):
- """Test that custom spacing function for angles_Gs_to_Wn_ixyz
- produces expected motion."""
- num_steps = 100
- delta_time = 0.01
- wings = self.custom_spacing_angles_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract x-angles from generated Wings.
- x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
-
- # Calculate expected custom harmonic wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- x_rad = 2 * np.pi * times / 1.0
- expected_x = (
- 10.0
- * (3.0 / (2.0 * np.sqrt(2.0)))
- * (np.sin(x_rad) + (1.0 / 3.0) * np.sin(3.0 * x_rad))
- )
-
- # Assert that the generated angles match the expected custom wave.
- npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_rotation_point_offset_zero_matches_default(self):
- """Test that zero rotation point offset produces identical results to default
- behavior."""
- num_steps = 50
- delta_time = 0.02
-
- # Create two WingMovements: one with explicit zero offset, one without.
+ def test_instantiation_returns_correct_type(self):
+ """Test that WingMovement instantiation returns a WingMovement."""
base_wing = geometry_fixtures.make_origin_wing_fixture()
- wcs_movements = [
+ wing_cross_section_movements = [
wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
]
-
- movement_default = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- )
-
- movement_zero_offset = ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=[
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture(),
- wing_cross_section_movement_fixtures.make_static_tip_wing_cross_section_movement_fixture(),
- ],
- ampAngles_Gs_to_Wn_ixyz=(10.0, 0.0, 0.0),
- periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0),
- rotationPointOffset_Gs_Ler=(0.0, 0.0, 0.0),
- )
-
- wings_default = movement_default.generate_wings(num_steps, delta_time)
- wings_zero = movement_zero_offset.generate_wings(num_steps, delta_time)
-
- for i in range(num_steps):
- npt.assert_allclose(
- wings_default[i].Ler_Gs_Cgs,
- wings_zero[i].Ler_Gs_Cgs,
- rtol=1e-10,
- atol=1e-14,
- )
- npt.assert_allclose(
- wings_default[i].angles_Gs_to_Wn_ixyz,
- wings_zero[i].angles_Gs_to_Wn_ixyz,
- rtol=1e-10,
- atol=1e-14,
- )
-
- def test_rotation_point_offset_produces_position_adjustment(self):
- """Test that non zero rotation point offset causes position changes when
- angles oscillate."""
- num_steps = 100
- delta_time = 0.01
- wings = self.rotation_point_offset_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # With rotation about a point offset in y (0.5m in y direction from Ler),
- # z positions should vary when rotating about x axis.
- # The offset is perpendicular to the rotation axis, so position changes occur.
- z_positions = np.array([wing.Ler_Gs_Cgs[2] for wing in wings])
- y_positions = np.array([wing.Ler_Gs_Cgs[1] for wing in wings])
-
- # Verify that z positions are not all zero (they should oscillate).
- self.assertFalse(np.allclose(z_positions, 0.0))
-
- # For rotation about x axis with offset P = (0, 0.5, 0), the position
- # adjustment is (I - R) @ P where R is the rotation matrix about x.
- # The active rotation matrix for angle theta about x is:
- # R = [[1, 0, 0], [0, cos(theta), -sin(theta)], [0, sin(theta), cos(theta)]]
- # So (I - R) @ [0, 0.5, 0] = [0, 0.5*(1 - cos(theta)), -0.5*sin(theta)]
- # Thus y_adj = 0.5*(1 - cos(theta)) and z_adj = -0.5*sin(theta)
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- angles_x_rad = np.deg2rad(10.0 * np.sin(2 * np.pi * times / 1.0))
- expected_y = 0.5 * (1.0 - np.cos(angles_x_rad))
- expected_z = -0.5 * np.sin(angles_x_rad)
-
- npt.assert_allclose(y_positions, expected_y, rtol=1e-10, atol=1e-14)
- npt.assert_allclose(z_positions, expected_z, rtol=1e-10, atol=1e-14)
-
- def test_rotation_point_offset_preserves_angles(self):
- """Test that rotation point offset does not affect the Wing angles."""
- num_steps = 50
- delta_time = 0.02
- wings = self.rotation_point_offset_wing_movement.generate_wings(
- num_steps=num_steps,
- delta_time=delta_time,
- )
-
- # Extract angles from generated Wings.
- x_angles = np.array([wing.angles_Gs_to_Wn_ixyz[0] for wing in wings])
-
- # Calculate expected sine wave values.
- times = np.linspace(0, num_steps * delta_time, num_steps, endpoint=False)
- expected_x = 10.0 * np.sin(2 * np.pi * times / 1.0)
-
- # Assert that angles are unaffected by rotation point offset.
- npt.assert_allclose(x_angles, expected_x, rtol=1e-10, atol=1e-14)
-
- def test_rotation_point_offset_initialization(self):
- """Test that rotationPointOffset_Gs_Ler is correctly initialized."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
- ]
-
wing_movement = ps.movements.wing_movement.WingMovement(
base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- rotationPointOffset_Gs_Ler=(0.25, 0.1, -0.05),
+ wing_cross_section_movements=wing_cross_section_movements,
)
-
- npt.assert_array_equal(
- wing_movement.rotationPointOffset_Gs_Ler, np.array([0.25, 0.1, -0.05])
+ self.assertIsInstance(
+ wing_movement,
+ ps.movements.wing_movement.WingMovement,
)
- def test_rotation_point_offset_validation_wrong_size(self):
- """Test that invalid rotationPointOffset_Gs_Ler size raises error."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
- ]
-
- with self.assertRaises(ValueError):
- ps.movements.wing_movement.WingMovement(
- base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- rotationPointOffset_Gs_Ler=(0.1, 0.2),
- )
+ def test_rejects_core_wing_cross_section_movement_children(self):
+ """Test that WingMovement rejects CoreWingCrossSectionMovement instances."""
+ from tests.unit.fixtures import core_wing_cross_section_movement_fixtures
- def test_rotation_point_offset_validation_non_numeric(self):
- """Test that non numeric rotationPointOffset_Gs_Ler raises error."""
- base_wing = geometry_fixtures.make_type_1_wing_fixture()
- wcs_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- for _ in base_wing.wing_cross_sections
+ base_wing = geometry_fixtures.make_origin_wing_fixture()
+ wing_cross_section_movements = [
+ core_wing_cross_section_movement_fixtures.make_static_core_wing_cross_section_movement_fixture(),
+ core_wing_cross_section_movement_fixtures.make_static_tip_core_wing_cross_section_movement_fixture(),
]
-
with self.assertRaises(TypeError):
ps.movements.wing_movement.WingMovement(
base_wing=base_wing,
- wing_cross_section_movements=wcs_movements,
- rotationPointOffset_Gs_Ler=("a", "b", "c"),
+ wing_cross_section_movements=wing_cross_section_movements,
)
- def test_all_periods_static_movement(self):
- """Test that all_periods returns empty tuple for static WingMovement."""
- wing_movement = self.static_wing_movement
- self.assertEqual(wing_movement.all_periods, ())
-
- def test_all_periods_Ler_only(self):
- """Test that all_periods returns correct periods for Ler only movement."""
- wing_movement = self.Ler_only_wing_movement
- # periodLer_Gs_Cgs is (1.5, 1.5, 1.5), all non zero.
- # periodAngles_Gs_to_Wn_ixyz is (0.0, 0.0, 0.0).
- # WingCrossSectionMovements are static (all zeros).
- # Should return tuple with three 1.5 values.
- self.assertEqual(wing_movement.all_periods, (1.5, 1.5, 1.5))
-
- def test_all_periods_angles_only(self):
- """Test that all_periods returns correct periods for angles only movement."""
- wing_movement = self.angles_only_wing_movement
- # periodLer_Gs_Cgs is (0.0, 0.0, 0.0).
- # periodAngles_Gs_to_Wn_ixyz is (1.5, 1.5, 1.5), all non zero.
- # WingCrossSectionMovements are static (all zeros).
- # Should return tuple with three 1.5 values.
- self.assertEqual(wing_movement.all_periods, (1.5, 1.5, 1.5))
-
- def test_all_periods_mixed(self):
- """Test that all_periods returns all non zero periods for mixed movement."""
- wing_movement = self.multiple_periods_wing_movement
- # periodLer_Gs_Cgs is (1.0, 2.0, 3.0).
- # periodAngles_Gs_to_Wn_ixyz is (0.5, 1.5, 2.5).
- # WingCrossSectionMovements include one with multiple periods.
- # Should return tuple with all non zero values from WingCrossSectionMovements first,
- # then WingMovement's own periods.
- all_periods = wing_movement.all_periods
-
- # Verify WingMovement's own periods are included.
- self.assertIn(1.0, all_periods)
- self.assertIn(2.0, all_periods)
- self.assertIn(3.0, all_periods)
- self.assertIn(0.5, all_periods)
- self.assertIn(1.5, all_periods)
- self.assertIn(2.5, all_periods)
-
- def test_all_periods_contains_duplicates(self):
- """Test that all_periods contains duplicate periods if they appear multiple
- times.
+ def test_generate_wings_returns_wings(self):
+ """Test that generate_wings returns Wings when called through the public
+ class.
"""
- wing_movement = self.basic_wing_movement
- all_periods = wing_movement.all_periods
-
- # Both periodLer_Gs_Cgs and periodAngles_Gs_to_Wn_ixyz are (2.0, 2.0, 2.0).
- # This contributes six 2.0 values. Plus WingCrossSectionMovement periods.
- # Count how many times 2.0 appears (should be at least 6 from WingMovement).
- count_2_0 = all_periods.count(2.0)
- self.assertGreaterEqual(count_2_0, 6)
-
- def test_all_periods_partial_movement(self):
- """Test all_periods with only some dimensions having non zero periods."""
- wing_movement = self.sine_spacing_Ler_wing_movement
- # periodLer_Gs_Cgs is (1.0, 0.0, 0.0), only first element is non zero.
- # periodAngles_Gs_to_Wn_ixyz is (0.0, 0.0, 0.0).
- # WingCrossSectionMovements are static.
- # Should return tuple with one 1.0 value.
- self.assertEqual(wing_movement.all_periods, (1.0,))
-
-
-class TestWingMovementImmutability(unittest.TestCase):
- """Tests for WingMovement attribute immutability."""
-
- def setUp(self):
- """Set up test fixtures for immutability tests."""
- self.wing_movement = wing_movement_fixtures.make_basic_wing_movement_fixture()
-
- def test_immutable_base_wing_property(self):
- """Test that base_wing property is read only."""
- new_wing = geometry_fixtures.make_type_1_wing_fixture()
- with self.assertRaises(AttributeError):
- self.wing_movement.base_wing = new_wing
-
- def test_immutable_wing_cross_section_movements_property(self):
- """Test that wing_cross_section_movements property is read only."""
- new_movements = [
- wing_cross_section_movement_fixtures.make_static_wing_cross_section_movement_fixture()
- ]
- with self.assertRaises(AttributeError):
- self.wing_movement.wing_cross_section_movements = new_movements
-
- def test_immutable_ampLer_Gs_Cgs_property(self):
- """Test that ampLer_Gs_Cgs property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_movement.ampLer_Gs_Cgs = np.array([1.0, 2.0, 3.0])
-
- def test_immutable_ampLer_Gs_Cgs_array_read_only(self):
- """Test that ampLer_Gs_Cgs array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_movement.ampLer_Gs_Cgs[0] = 999.0
-
- def test_immutable_periodLer_Gs_Cgs_property(self):
- """Test that periodLer_Gs_Cgs property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_movement.periodLer_Gs_Cgs = np.array([1.0, 2.0, 3.0])
-
- def test_immutable_periodLer_Gs_Cgs_array_read_only(self):
- """Test that periodLer_Gs_Cgs array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_movement.periodLer_Gs_Cgs[0] = 999.0
-
- def test_immutable_spacingLer_Gs_Cgs_property(self):
- """Test that spacingLer_Gs_Cgs property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_movement.spacingLer_Gs_Cgs = ("uniform", "uniform", "uniform")
-
- def test_immutable_phaseLer_Gs_Cgs_property(self):
- """Test that phaseLer_Gs_Cgs property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_movement.phaseLer_Gs_Cgs = np.array([45.0, 45.0, 45.0])
-
- def test_immutable_phaseLer_Gs_Cgs_array_read_only(self):
- """Test that phaseLer_Gs_Cgs array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_movement.phaseLer_Gs_Cgs[0] = 999.0
-
- def test_immutable_ampAngles_Gs_to_Wn_ixyz_property(self):
- """Test that ampAngles_Gs_to_Wn_ixyz property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_movement.ampAngles_Gs_to_Wn_ixyz = np.array([1.0, 2.0, 3.0])
-
- def test_immutable_ampAngles_Gs_to_Wn_ixyz_array_read_only(self):
- """Test that ampAngles_Gs_to_Wn_ixyz array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_movement.ampAngles_Gs_to_Wn_ixyz[0] = 999.0
-
- def test_immutable_periodAngles_Gs_to_Wn_ixyz_property(self):
- """Test that periodAngles_Gs_to_Wn_ixyz property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_movement.periodAngles_Gs_to_Wn_ixyz = np.array([1.0, 2.0, 3.0])
-
- def test_immutable_periodAngles_Gs_to_Wn_ixyz_array_read_only(self):
- """Test that periodAngles_Gs_to_Wn_ixyz array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_movement.periodAngles_Gs_to_Wn_ixyz[0] = 999.0
-
- def test_immutable_spacingAngles_Gs_to_Wn_ixyz_property(self):
- """Test that spacingAngles_Gs_to_Wn_ixyz property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_movement.spacingAngles_Gs_to_Wn_ixyz = (
- "uniform",
- "uniform",
- "uniform",
- )
-
- def test_immutable_phaseAngles_Gs_to_Wn_ixyz_property(self):
- """Test that phaseAngles_Gs_to_Wn_ixyz property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_movement.phaseAngles_Gs_to_Wn_ixyz = np.array([45.0, 45.0, 45.0])
-
- def test_immutable_phaseAngles_Gs_to_Wn_ixyz_array_read_only(self):
- """Test that phaseAngles_Gs_to_Wn_ixyz array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_movement.phaseAngles_Gs_to_Wn_ixyz[0] = 999.0
-
- def test_immutable_rotationPointOffset_Gs_Ler_property(self):
- """Test that rotationPointOffset_Gs_Ler property is read only."""
- with self.assertRaises(AttributeError):
- self.wing_movement.rotationPointOffset_Gs_Ler = np.array([1.0, 2.0, 3.0])
-
- def test_immutable_rotationPointOffset_Gs_Ler_array_read_only(self):
- """Test that rotationPointOffset_Gs_Ler array cannot be modified in place."""
- with self.assertRaises(ValueError):
- self.wing_movement.rotationPointOffset_Gs_Ler[0] = 999.0
-
-
-class TestWingMovementCaching(unittest.TestCase):
- """Tests for WingMovement caching behavior."""
-
- def setUp(self):
- """Set up test fixtures for caching tests."""
- self.wing_movement = wing_movement_fixtures.make_basic_wing_movement_fixture()
-
- def test_all_periods_caching_returns_same_object(self):
- """Test that repeated access to all_periods returns the same cached object."""
- all_periods_1 = self.wing_movement.all_periods
- all_periods_2 = self.wing_movement.all_periods
- self.assertIs(all_periods_1, all_periods_2)
-
- def test_max_period_caching_returns_same_value(self):
- """Test that repeated access to max_period returns the same cached value."""
- max_period_1 = self.wing_movement.max_period
- max_period_2 = self.wing_movement.max_period
- # Since floats are immutable, we check equality rather than identity.
- self.assertEqual(max_period_1, max_period_2)
-
-
-class TestWingMovementDeepcopy(unittest.TestCase):
- """Tests for WingMovement deepcopy behavior."""
-
- def setUp(self):
- """Set up test fixtures for deepcopy tests."""
- self.wing_movement = wing_movement_fixtures.make_basic_wing_movement_fixture()
-
- def test_deepcopy_returns_new_instance(self):
- """Test that deepcopy returns a new WingMovement instance."""
- import copy
-
- original = self.wing_movement
- copied = copy.deepcopy(original)
-
- self.assertIsInstance(copied, ps.movements.wing_movement.WingMovement)
- self.assertIsNot(original, copied)
-
- def test_deepcopy_preserves_attribute_values(self):
- """Test that deepcopy preserves all attribute values."""
- import copy
-
- original = self.wing_movement
- copied = copy.deepcopy(original)
-
- # Check numpy array attributes.
- npt.assert_array_equal(copied.ampLer_Gs_Cgs, original.ampLer_Gs_Cgs)
- npt.assert_array_equal(copied.periodLer_Gs_Cgs, original.periodLer_Gs_Cgs)
- npt.assert_array_equal(copied.phaseLer_Gs_Cgs, original.phaseLer_Gs_Cgs)
- npt.assert_array_equal(
- copied.ampAngles_Gs_to_Wn_ixyz, original.ampAngles_Gs_to_Wn_ixyz
- )
- npt.assert_array_equal(
- copied.periodAngles_Gs_to_Wn_ixyz, original.periodAngles_Gs_to_Wn_ixyz
- )
- npt.assert_array_equal(
- copied.phaseAngles_Gs_to_Wn_ixyz, original.phaseAngles_Gs_to_Wn_ixyz
- )
- npt.assert_array_equal(
- copied.rotationPointOffset_Gs_Ler, original.rotationPointOffset_Gs_Ler
- )
-
- # Check tuple attributes.
- self.assertEqual(copied.spacingLer_Gs_Cgs, original.spacingLer_Gs_Cgs)
- self.assertEqual(
- copied.spacingAngles_Gs_to_Wn_ixyz,
- original.spacingAngles_Gs_to_Wn_ixyz,
- )
-
- def test_deepcopy_numpy_arrays_are_independent(self):
- """Test that deepcopied numpy arrays are independent objects."""
- import copy
-
- original = self.wing_movement
- copied = copy.deepcopy(original)
-
- # Verify arrays are different objects.
- self.assertIsNot(copied.ampLer_Gs_Cgs, original.ampLer_Gs_Cgs)
- self.assertIsNot(copied.periodLer_Gs_Cgs, original.periodLer_Gs_Cgs)
- self.assertIsNot(copied.phaseLer_Gs_Cgs, original.phaseLer_Gs_Cgs)
- self.assertIsNot(
- copied.ampAngles_Gs_to_Wn_ixyz, original.ampAngles_Gs_to_Wn_ixyz
- )
- self.assertIsNot(
- copied.periodAngles_Gs_to_Wn_ixyz, original.periodAngles_Gs_to_Wn_ixyz
- )
- self.assertIsNot(
- copied.phaseAngles_Gs_to_Wn_ixyz, original.phaseAngles_Gs_to_Wn_ixyz
- )
- self.assertIsNot(
- copied.rotationPointOffset_Gs_Ler, original.rotationPointOffset_Gs_Ler
- )
-
- def test_deepcopy_numpy_arrays_cannot_be_modified_in_place(self):
- """Test that deepcopied numpy arrays raise ValueError on in place modification."""
- import copy
-
- original = self.wing_movement
- copied = copy.deepcopy(original)
-
- # Verify that attempting to modify copied arrays raises ValueError.
- with self.assertRaises(ValueError):
- copied.ampLer_Gs_Cgs[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.periodLer_Gs_Cgs[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.phaseLer_Gs_Cgs[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.ampAngles_Gs_to_Wn_ixyz[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.periodAngles_Gs_to_Wn_ixyz[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.phaseAngles_Gs_to_Wn_ixyz[0] = 999.0
-
- with self.assertRaises(ValueError):
- copied.rotationPointOffset_Gs_Ler[0] = 999.0
-
- def test_deepcopy_base_wing_is_independent(self):
- """Test that deepcopied base_wing is an independent object."""
- import copy
-
- original = self.wing_movement
- copied = copy.deepcopy(original)
-
- # Verify base_wing is a different object.
- self.assertIsNot(copied.base_wing, original.base_wing)
-
- # Verify attributes are equal.
- self.assertEqual(copied.base_wing.name, original.base_wing.name)
- npt.assert_array_equal(
- copied.base_wing.Ler_Gs_Cgs, original.base_wing.Ler_Gs_Cgs
- )
- npt.assert_array_equal(
- copied.base_wing.angles_Gs_to_Wn_ixyz,
- original.base_wing.angles_Gs_to_Wn_ixyz,
- )
-
- def test_deepcopy_wing_cross_section_movements_are_independent(self):
- """Test that deepcopied wing_cross_section_movements are independent objects."""
- import copy
-
- original = self.wing_movement
- copied = copy.deepcopy(original)
-
- # Verify wing_cross_section_movements tuple is different.
- self.assertIsNot(
- copied.wing_cross_section_movements, original.wing_cross_section_movements
- )
-
- # Verify each WingCrossSectionMovement is a different object.
- for i in range(len(original.wing_cross_section_movements)):
- self.assertIsNot(
- copied.wing_cross_section_movements[i],
- original.wing_cross_section_movements[i],
- )
-
- def test_deepcopy_resets_caches_to_none(self):
- """Test that deepcopy resets cached derived properties to None."""
- import copy
-
- original = self.wing_movement
-
- # Access cached properties to populate caches.
- _ = original.all_periods
- _ = original.max_period
-
- # Verify original caches are populated.
- self.assertIsNotNone(original._all_periods)
- self.assertIsNotNone(original._max_period)
-
- # Deepcopy the object.
- copied = copy.deepcopy(original)
-
- # Verify copied caches are reset to None.
- self.assertIsNone(copied._all_periods)
- self.assertIsNone(copied._max_period)
-
- def test_deepcopy_cached_properties_can_be_recomputed(self):
- """Test that cached properties work correctly after deepcopy."""
- import copy
-
- original = self.wing_movement
-
- # Get original cached values.
- original_all_periods = original.all_periods
- original_max_period = original.max_period
-
- # Deepcopy the object.
- copied = copy.deepcopy(original)
-
- # Verify cached properties can be computed and match original.
- self.assertEqual(copied.all_periods, original_all_periods)
- self.assertEqual(copied.max_period, original_max_period)
-
- def test_deepcopy_generate_wings_produces_same_results(self):
- """Test that generate_wings produces same results after deepcopy."""
- import copy
-
- original = self.wing_movement
- copied = copy.deepcopy(original)
-
- num_steps = 50
- delta_time = 0.01
-
- original_wings = original.generate_wings(
- num_steps=num_steps, delta_time=delta_time
- )
- copied_wings = copied.generate_wings(num_steps=num_steps, delta_time=delta_time)
-
- # Verify same number of Wings.
- self.assertEqual(len(copied_wings), len(original_wings))
-
- # Verify each Wing has matching attributes.
- for original_wing, copied_wing in zip(original_wings, copied_wings):
- npt.assert_array_equal(copied_wing.Ler_Gs_Cgs, original_wing.Ler_Gs_Cgs)
- npt.assert_array_equal(
- copied_wing.angles_Gs_to_Wn_ixyz, original_wing.angles_Gs_to_Wn_ixyz
+ wing_movement = wing_movement_fixtures.make_basic_wing_movement_fixture()
+ wings = wing_movement.generate_wings(num_steps=5, delta_time=0.01)
+ self.assertEqual(len(wings), 5)
+ for wing in wings:
+ self.assertIsInstance(
+ wing,
+ ps.geometry.wing.Wing,
)
- self.assertEqual(copied_wing.name, original_wing.name)
-
- def test_deepcopy_handles_memo_correctly(self):
- """Test that deepcopy handles the memo dict correctly for circular references."""
- import copy
-
- original = self.wing_movement
- memo = {}
-
- # First deepcopy.
- copied1 = copy.deepcopy(original, memo)
-
- # Verify original is in memo.
- self.assertIn(id(original), memo)
- self.assertIs(memo[id(original)], copied1)
-
- # Second deepcopy with same memo should return same object.
- copied2 = copy.deepcopy(original, memo)
- self.assertIs(copied1, copied2)
if __name__ == "__main__":