diff --git a/docs/CLASSES_AND_IMMUTABILITY.md b/docs/CLASSES_AND_IMMUTABILITY.md index 090d0de1..30443c81 100644 --- a/docs/CLASSES_AND_IMMUTABILITY.md +++ b/docs/CLASSES_AND_IMMUTABILITY.md @@ -3,6 +3,7 @@ This document describes the consistent pattern of immutability and lazy caching across the following core data and geometry classes in the Ptera Software codebase: - `CoreUnsteadyProblem` / `UnsteadyProblem` +- `_CoupledUnsteadyProblem` - `CoreMovement` / `Movement` - `CoreAirplaneMovement` / `AirplaneMovement` - `CoreWingMovement` / `WingMovement` @@ -125,6 +126,23 @@ Store collections as tuples internally to prevent external mutation via `.append **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. +## _CoupledUnsteadyProblem Class (`problems.py`) + +`_CoupledUnsteadyProblem` is a private middle-layer class that extends `CoreUnsteadyProblem`. It is the base for concrete subclasses (forthcoming `AeroelasticUnsteadyProblem` and `FreeFlightUnsteadyProblem`) whose geometry at each time step depends on the solver's results from the previous step. Unlike `UnsteadyProblem`, which builds all `SteadyProblem`s up front from a pre-generated `Movement`, the coupled subclasses grow their `SteadyProblem` collection one step at a time during the solve. + +All `CoreUnsteadyProblem` attributes (documented in the section above) are inherited unchanged. The additions are: + +### Attribute Classification + +#### Immutable (set in `__init__`, never modified) + +| Attribute | Type | Notes | +|-------------------|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `movement` | `CoreMovement` | Source of `delta_time`, `num_steps`, `max_wake_rows`, and `lcm_period` | +| `steady_problems` | `tuple[SteadyProblem, ...]` | Read-only view of the `_steady_problems` backing list; returned tuple is frozen, but successive calls may return different-length tuples (see below) | + +**Note on `steady_problems`**: The parent class's `steady_problems` property is doubly immutable. The returned tuple is read-only and its value never changes over the lifetime of the `UnsteadyProblem`. On `_CoupledUnsteadyProblem`, the first guarantee still holds (callers cannot mutate the tuple), but the second does not. The backing slot `_steady_problems` is a `list[SteadyProblem]` seeded at init with a single entry built from `initial_airplanes` and `initial_operating_point`. Subclass `initialize_next_problem` overrides append to this list as each step is initialized during the solve, so calling `steady_problems` at different points can yield different-length tuples. External code that needs a consistent snapshot should read `steady_problems` once after the solver has completed. + ## 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. @@ -651,4 +669,4 @@ Since `LineVortex` is an internal class whose endpoints ARE updated by parent vo ## Solver Classes (Not Covered Above) -The three solver classes (`SteadyHorseshoeVortexLatticeMethodSolver`, `SteadyRingVortexLatticeMethodSolver`, and `UnsteadyRingVortexLatticeMethodSolver`) are intentionally omitted from the immutability and lazy caching patterns described in this document. Unlike the data and geometry classes above, the solver classes are algorithmic classes whose attributes are internal mutable working state in a procedural computation pipeline. They are not shared data that external code accesses or modifies, so immutable properties, set once enforcement, and lazy caching would add significant boilerplate with no meaningful safety benefit. The solver classes do still use `__slots__`, like all other classes in the package, to protect against dynamic attribute assignment typos. \ No newline at end of file +The four solver classes (`SteadyHorseshoeVortexLatticeMethodSolver`, `SteadyRingVortexLatticeMethodSolver`, `UnsteadyRingVortexLatticeMethodSolver`, and `CoupledUnsteadyRingVortexLatticeMethodSolver`) are intentionally omitted from the immutability and lazy caching patterns described in this document. Unlike the data and geometry classes above, the solver classes are algorithmic classes whose attributes are internal mutable working state in a procedural computation pipeline. They are not shared data that external code accesses or modifies, so immutable properties, set once enforcement, and lazy caching would add significant boilerplate with no meaningful safety benefit. The solver classes do still use `__slots__`, like all other classes in the package, to protect against dynamic attribute assignment typos. \ No newline at end of file diff --git a/pterasoftware/_core.py b/pterasoftware/_core.py index f1052962..916f7a49 100644 --- a/pterasoftware/_core.py +++ b/pterasoftware/_core.py @@ -5,12 +5,16 @@ import copy import math from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING import numpy as np from . import _oscillation, _parameter_validation, _transformations, geometry from . import operating_point as operating_point_mod +if TYPE_CHECKING: + from . import problems + def lcm(a: float, b: float) -> float: """Calculates the least common multiple of two numbers. @@ -2361,3 +2365,22 @@ def first_results_step(self) -> int: @property def max_wake_rows(self) -> int | None: return self._max_wake_rows + + @property + def movement(self) -> CoreMovement: + # This stub lets the UnsteadyRingVortexLatticeMethodSolver access movement and + # steady_problems on any CoreUnsteadyProblem without knowing the concrete + # subclass. + raise NotImplementedError( + "Subclasses of CoreUnsteadyProblem must override the movement property." + ) + + @property + def steady_problems(self) -> tuple[problems.SteadyProblem, ...]: + # This stub lets the UnsteadyRingVortexLatticeMethodSolver access movement and + # steady_problems on any CoreUnsteadyProblem without knowing the concrete + # subclass. + raise NotImplementedError( + "Subclasses of CoreUnsteadyProblem must override the steady_problems " + "property." + ) diff --git a/pterasoftware/_coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/_coupled_unsteady_ring_vortex_lattice_method.py new file mode 100644 index 00000000..4e9c3561 --- /dev/null +++ b/pterasoftware/_coupled_unsteady_ring_vortex_lattice_method.py @@ -0,0 +1,77 @@ +"""Contains the CoupledUnsteadyRingVortexLatticeMethodSolver class. + +**Contains the following classes:** + +CoupledUnsteadyRingVortexLatticeMethodSolver: A subclass of +UnsteadyRingVortexLatticeMethodSolver that solves _CoupledUnsteadyProblems, whose +geometry is initialized and updated step by step rather than being fully precomputed. + +**Contains the following functions:** + +None +""" + +from __future__ import annotations + +from typing import cast + +from . import _logging, problems +from .unsteady_ring_vortex_lattice_method import UnsteadyRingVortexLatticeMethodSolver + +_logger = _logging.get_logger("_coupled_unsteady_ring_vortex_lattice_method") + + +class CoupledUnsteadyRingVortexLatticeMethodSolver( + UnsteadyRingVortexLatticeMethodSolver +): + """A subclass of UnsteadyRingVortexLatticeMethodSolver that solves + _CoupledUnsteadyProblems. + + Geometry in a _CoupledUnsteadyProblem is determined step by step from the solver's + results at the previous step, so bound vortices cannot be initialized upfront. This + class inherits the parent's run() and initialize_step_geometry() unchanged and + overrides three hooks: _initialize_step_vortices (per step bound vortex init), + _pre_shed_hook (calls _CoupledUnsteadyProblem.initialize_next_problem between + steps), and _get_steady_problem_at (dynamic dispatch through the problem's + get_steady_problem accessor). + + **Contains the following methods:** + + None + """ + + __slots__ = () + + def __init__(self, unsteady_problem: problems._CoupledUnsteadyProblem) -> None: + """The initialization method. + + :param unsteady_problem: The _CoupledUnsteadyProblem to be solved. + :return: None + """ + if not isinstance(unsteady_problem, problems._CoupledUnsteadyProblem): + raise TypeError("unsteady_problem must be a _CoupledUnsteadyProblem.") + super().__init__(unsteady_problem) + + @property + def _coupled_problem(self) -> problems._CoupledUnsteadyProblem: + """Type narrowed view of the inherited unsteady_problem attribute. + + The parent stores unsteady_problem as a CoreUnsteadyProblem (widened to let + subclasses pass their own variants). __init__ validates that this subclass + always receives a _CoupledUnsteadyProblem, so the cast here is safe. + + :return: The unsteady_problem narrowed to _CoupledUnsteadyProblem. + """ + return cast(problems._CoupledUnsteadyProblem, self.unsteady_problem) + + def _initialize_step_vortices(self, step: int) -> None: + _logger.debug(f"Initializing step {step}'s bound RingVortices.") + self._initialize_panel_vortices_at(step) + + def _pre_shed_hook(self, step: int) -> None: + if step < self.num_steps - 1: + self._coupled_problem.initialize_next_problem(self) + self._initialize_panel_vortices_at(step + 1) + + def _get_steady_problem_at(self, step: int) -> problems.SteadyProblem: + return self._coupled_problem.get_steady_problem(step) diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 080dc05a..79f0cbfe 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -13,11 +13,18 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import numpy as np -from . import _core, _transformations, geometry, movements +from . import _core, _parameter_validation, _transformations, geometry, movements from . import operating_point as operating_point_mod +if TYPE_CHECKING: + from ._coupled_unsteady_ring_vortex_lattice_method import ( + CoupledUnsteadyRingVortexLatticeMethodSolver, + ) + class SteadyProblem: """A class used to contain steady aerodynamics problems. @@ -229,3 +236,108 @@ def movement(self) -> movements.movement.Movement: @property def steady_problems(self) -> tuple[SteadyProblem, ...]: return self._steady_problems + + +class _CoupledUnsteadyProblem(_core.CoreUnsteadyProblem): + """A class for coupled unsteady aerodynamics problems. + + This class extends CoreUnsteadyProblem to manage SteadyProblems for coupled + simulations where the geometry at each time step depends on the solver's results + from previous time steps. + + **Contains the following methods:** + + movement: The CoreMovement that defines the motion parameters for this problem. + + steady_problems: A tuple of SteadyProblems, one for each time step that has been + initialized so far. + + get_steady_problem: Gets the SteadyProblem at a specified time step. + + initialize_next_problem: Initializes the next time step's SteadyProblem. Must be + overridden by subclasses. + """ + + __slots__ = ( + "_movement", + "_steady_problems", + ) + + def __init__( + self, + movement: _core.CoreMovement, + initial_airplanes: list[geometry.airplane.Airplane], + initial_operating_point: operating_point_mod.OperatingPoint, + ) -> None: + """The initialization method. + + Initializes the coupled unsteady problem with the first time step's geometry and + the motion parameters from the provided CoreMovement. + + :param movement: A CoreMovement object that defines the motion parameters + (delta_time, num_steps, max_wake_rows, lcm_period) for this problem. + :param initial_airplanes: The list of Airplanes at the first time step. + :param initial_operating_point: The OperatingPoint at the first time step. + :return: None + """ + self._movement = movement + + # Delegate shared initialization (validation, first_averaging_step computation, + # load list initialization) to the core class. _CoupledUnsteadyProblems require + # per step results to feed the coupling hook, so only_final_results is always + # False. + super().__init__( + only_final_results=False, + 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, + ) + + # Coupled-specific state: a mutable list of SteadyProblems that grows as the + # solver advances. Subclass initialize_next_problem overrides append to this + # list; external code reads through the steady_problems tuple-view property to + # preserve the read-only contract inherited from UnsteadyProblem. Seed with a + # SteadyProblem built from the initial geometry so step zero is always ready. + self._steady_problems: list[SteadyProblem] = [ + SteadyProblem( + airplanes=initial_airplanes, + operating_point=initial_operating_point, + ) + ] + + # --- Immutable: read only properties --- + @property + def movement(self) -> _core.CoreMovement: + return self._movement + + @property + def steady_problems(self) -> tuple[SteadyProblem, ...]: + return tuple(self._steady_problems) + + def get_steady_problem(self, step: int) -> SteadyProblem: + """Get the SteadyProblem at a given time step. + + :param step: The time step index (zero indexed). Must be greater than or equal + to zero and less than the total number of time steps. + :return: The SteadyProblem at the specified time step. + """ + step = _parameter_validation.int_in_range_return_int( + step, "step", 0, True, len(self._steady_problems), False + ) + + return self._steady_problems[step] + + def initialize_next_problem( + self, solver: CoupledUnsteadyRingVortexLatticeMethodSolver + ) -> None: + """Initialize the next time step's SteadyProblem. + + Must be overridden by subclasses to compute the geometry for the next time step + based on the solver's results. + + :param solver: The CoupledUnsteadyRingVortexLatticeMethodSolver instance + providing aerodynamic data from the current time step. + :return: None + """ + raise NotImplementedError("Subclasses must implement initialize_next_problem.") diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 692d4c88..a673ce52 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -21,6 +21,7 @@ from . import ( _aerodynamics_functions, + _core, _functions, _logging, _panel, @@ -38,7 +39,6 @@ # REFACTOR: Add unit tests for trapezoid-rule-based averages for the mean and RMS loads # and load coefficients. -# TEST: Consider adding unit tests for this function. # TEST: Assess how comprehensive this function's integration tests are and update or # extend them if needed. class UnsteadyRingVortexLatticeMethodSolver: @@ -127,13 +127,17 @@ class UnsteadyRingVortexLatticeMethodSolver: "ran", ) - def __init__(self, unsteady_problem: problems.UnsteadyProblem) -> None: + def __init__(self, unsteady_problem: _core.CoreUnsteadyProblem) -> None: """The initialization method. :param unsteady_problem: The UnsteadyProblem to be solved. :return: None """ - if not isinstance(unsteady_problem, problems.UnsteadyProblem): + # Guard direct instantiation of the base solver against coupled problems while + # allowing subclasses to pass their own CoreUnsteadyProblem variants via super(). + if type(self) is UnsteadyRingVortexLatticeMethodSolver and not isinstance( + unsteady_problem, problems.UnsteadyProblem + ): raise TypeError("unsteady_problem must be an UnsteadyProblem.") self.unsteady_problem = unsteady_problem @@ -399,10 +403,6 @@ def run( bar_format="{desc}:{percentage:3.0f}% |{bar}| Elapsed: {elapsed}, " "Remaining: {remaining}", ) as bar: - # Initialize all the Airplanes' bound RingVortices. - _logger.debug("Initializing all Airplanes' bound RingVortices.") - self._initialize_panel_vortices() - # Update the progress bar based on the initialization step's predicted # approximate, relative computing time. bar.update(n=float(approx_times[0])) @@ -414,6 +414,11 @@ def run( # and OperatingPoint, and freestream velocity (in the first # Airplane's geometry axes, observed from the Earth frame). self._current_step = step + + # Initialize this step's bound RingVortices. The default does an + # upfront init for all steps on step 0 and is a no-op thereafter; + # coupled subclasses override this hook to init one step at a time. + self._initialize_step_vortices(step) current_problem: problems.SteadyProblem = self._get_steady_problem_at( self._current_step ) @@ -506,6 +511,9 @@ def run( self.panel_is_left_edge = np.zeros(self.num_panels, dtype=bool) self.panel_is_right_edge = np.zeros(self.num_panels, dtype=bool) + # Hook: subclasses may reinitialize step-specific arrays here. + self._reinitialize_step_arrays_hook() + # Get the pre-allocated (but still all zero) arrays of wake # information that are associated with this time step. self._current_wake_vortex_strengths = self._list_wake_vortex_strengths[ @@ -554,6 +562,11 @@ def run( _logger.debug("Calculating forces and moments.") self._calculate_loads() + # Hook: subclasses may inject work between load calculation and wake + # shedding (e.g. coupled problems update the next step's geometry + # from this step's solver results). + self._pre_shed_hook(step) + # Shed RingVortices into the wake. _logger.debug("Shedding RingVortices into the wake.") self._populate_next_airplanes_wake() @@ -592,9 +605,10 @@ def initialize_step_geometry(self, step: int) -> None: step, "step", 0, True, self.num_steps, False ) - # Initialize bound RingVortices for all steps on the first call. - if step == 0: - self._initialize_panel_vortices() + # Initialize bound RingVortices. The base solver's hook does an upfront init + # for all steps when step is 0 and is a no-op otherwise; coupled subclasses + # override to initialize only the specified step. + self._initialize_step_vortices(step) # Set the current step and related state. self._current_step = step @@ -608,6 +622,44 @@ def initialize_step_geometry(self, step: int) -> None: self._populate_next_airplanes_wake_vortex_points() self._populate_next_airplanes_wake_vortices() + def _initialize_step_vortices(self, step: int) -> None: + """Initializes this time step's bound RingVortices. + + The default implementation initializes bound RingVortices for all time steps + upfront on step 0 and is a no-op on subsequent steps. Coupled subclasses + override this to initialize only the given step, since their geometry is + determined dynamically from the solver's results at the previous step. + + :param step: The time step to initialize. + :return: None + """ + if step == 0: + _logger.debug("Initializing all Airplanes' bound RingVortices.") + self._initialize_panel_vortices() + + def _reinitialize_step_arrays_hook(self) -> None: + """Hook for subclasses to reinitialize step specific arrays. + + Called once per time step in run(), after the standard per step arrays are + reinitialized and before the wake arrays are retrieved. The default + implementation is a no op. Subclasses may override this to zero out or + reallocate feature specific arrays at the start of each step. + + :return: None + """ + + def _pre_shed_hook(self, step: int) -> None: + """Hook for subclasses to inject work between load calculation and wake shed. + + Called once per time step in run(), after this step's loads have been calculated + and before wake RingVortices are shed. The default implementation is a no op. + Coupled subclasses override this to update the next time step's geometry from + the current step's solver results. + + :param step: The current time step. + :return: None + """ + def _initialize_panel_vortices(self) -> None: """Calculates the locations of the bound RingVortex vertices for all time steps, and then initializes them. @@ -2084,8 +2136,7 @@ def _finalize_loads(self) -> None: num_steps_to_average = self.num_steps - self._first_averaging_step # Determine if this SteadyProblem's geometry is static or variable. - this_movement: movements.movement.Movement = self.unsteady_problem.movement - static = this_movement.static + static = self.unsteady_problem.movement.static # Initialize ndarrays to hold each Airplane's loads and load coefficients at # each of the time steps that calculated the loads. diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 9caf35cc..4f686d2d 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -31,6 +31,12 @@ test_core_unsteady_problem.py: This module contains classes to test CoreUnsteadyProblems. + test_coupled_unsteady_problem.py: This module contains classes to test + _CoupledUnsteadyProblems. + + test_coupled_unsteady_ring_vortex_lattice_method.py: This module contains + classes to test CoupledUnsteadyRingVortexLatticeMethodSolvers. + test_core_wing_cross_section_movement.py: This module contains classes to test CoreWingCrossSectionMovements. @@ -73,6 +79,9 @@ test_transformations.py: This module contains classes to test functions in the transformations module. + test_unsteady_ring_vortex_lattice_method.py: This module contains classes to + test UnsteadyRingVortexLatticeMethodSolvers. + test_wing.py: This module contains classes to test Wings. test_wing_cross_section.py: This module contains classes to test WingCrossSections. @@ -94,6 +103,8 @@ 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_coupled_unsteady_problem +import tests.unit.test_coupled_unsteady_ring_vortex_lattice_method import tests.unit.test_horseshoe_vortex import tests.unit.test_line_vortex import tests.unit.test_movement @@ -108,6 +119,7 @@ import tests.unit.test_serialization import tests.unit.test_slots import tests.unit.test_transformations +import tests.unit.test_unsteady_ring_vortex_lattice_method import tests.unit.test_wing import tests.unit.test_wing_cross_section import tests.unit.test_wing_cross_section_movement diff --git a/tests/unit/fixtures/problem_fixtures.py b/tests/unit/fixtures/problem_fixtures.py index 970be1d6..9b04d1ce 100644 --- a/tests/unit/fixtures/problem_fixtures.py +++ b/tests/unit/fixtures/problem_fixtures.py @@ -2,7 +2,12 @@ import pterasoftware as ps -from . import geometry_fixtures, movement_fixtures, operating_point_fixtures +from . import ( + core_movement_fixtures, + geometry_fixtures, + movement_fixtures, + operating_point_fixtures, +) def make_basic_steady_problem_fixture(): @@ -105,3 +110,20 @@ def make_multi_airplane_unsteady_problem_fixture(): ) return multi_airplane_unsteady_problem_fixture + + +def make_basic_coupled_unsteady_problem_fixture(): + """This method makes a fixture that is a _CoupledUnsteadyProblem for general testing. + + :return basic_coupled_unsteady_problem_fixture: _CoupledUnsteadyProblem + This is the _CoupledUnsteadyProblem configured for general testing. + """ + # SteadyProblem sets GP1_CgP1 attributes on each Panel exactly once, so a fresh + # Airplane is required for every _CoupledUnsteadyProblem instance. + basic_coupled_unsteady_problem_fixture = ps.problems._CoupledUnsteadyProblem( + movement=core_movement_fixtures.make_static_core_movement_fixture(), + initial_airplanes=[geometry_fixtures.make_first_airplane_fixture()], + initial_operating_point=operating_point_fixtures.make_basic_operating_point_fixture(), + ) + + return basic_coupled_unsteady_problem_fixture diff --git a/tests/unit/fixtures/solver_fixtures.py b/tests/unit/fixtures/solver_fixtures.py index 6e29283f..83814bf2 100644 --- a/tests/unit/fixtures/solver_fixtures.py +++ b/tests/unit/fixtures/solver_fixtures.py @@ -2,6 +2,11 @@ import pterasoftware as ps +# noinspection PyProtectedMember +from pterasoftware._coupled_unsteady_ring_vortex_lattice_method import ( + CoupledUnsteadyRingVortexLatticeMethodSolver, +) + from . import problem_fixtures @@ -53,3 +58,19 @@ def make_unsteady_ring_solver_fixture(): ) return solver + + +def make_coupled_unsteady_ring_solver_fixture(): + """This method makes a fixture that is a + CoupledUnsteadyRingVortexLatticeMethodSolver for general testing. + + :return solver: CoupledUnsteadyRingVortexLatticeMethodSolver + This is the CoupledUnsteadyRingVortexLatticeMethodSolver fixture. + """ + coupled_unsteady_problem = ( + problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + ) + + solver = CoupledUnsteadyRingVortexLatticeMethodSolver(coupled_unsteady_problem) + + return solver diff --git a/tests/unit/test_coupled_unsteady_problem.py b/tests/unit/test_coupled_unsteady_problem.py new file mode 100644 index 00000000..0c69be2f --- /dev/null +++ b/tests/unit/test_coupled_unsteady_problem.py @@ -0,0 +1,176 @@ +"""This module contains classes to test _CoupledUnsteadyProblems.""" + +import unittest + +import pterasoftware as ps +from tests.unit.fixtures import ( + core_movement_fixtures, + geometry_fixtures, + operating_point_fixtures, + problem_fixtures, +) + + +class TestCoupledUnsteadyProblem(unittest.TestCase): + """This is a class with functions to test _CoupledUnsteadyProblems.""" + + def setUp(self): + """Set up a fresh _CoupledUnsteadyProblem for each test.""" + self.movement = core_movement_fixtures.make_static_core_movement_fixture() + self.initial_airplane = geometry_fixtures.make_first_airplane_fixture() + self.initial_operating_point = ( + operating_point_fixtures.make_basic_operating_point_fixture() + ) + self.problem = ps.problems._CoupledUnsteadyProblem( + movement=self.movement, + initial_airplanes=[self.initial_airplane], + initial_operating_point=self.initial_operating_point, + ) + + def test_initialization_valid_parameters(self): + """Test _CoupledUnsteadyProblem initialization with valid parameters.""" + self.assertIsInstance(self.problem, ps.problems._CoupledUnsteadyProblem) + self.assertIsInstance(self.problem, ps._core.CoreUnsteadyProblem) + + def test_step_zero_seeded_from_initial_inputs(self): + """Test that _steady_problems is seeded with one SteadyProblem built from + initial_airplanes and initial_operating_point. + """ + self.assertEqual(len(self.problem._steady_problems), 1) + seed = self.problem._steady_problems[0] + self.assertIsInstance(seed, ps.problems.SteadyProblem) + self.assertEqual(seed.airplanes, (self.initial_airplane,)) + self.assertIs(seed.operating_point, self.initial_operating_point) + + def test_only_final_results_forced_false(self): + """Test that only_final_results is always False for coupled problems.""" + self.assertFalse(self.problem.only_final_results) + + def test_delta_time_from_movement(self): + """Test that delta_time is taken from the movement.""" + self.assertEqual(self.problem.delta_time, self.movement.delta_time) + + def test_num_steps_from_movement(self): + """Test that num_steps is taken from the movement.""" + self.assertEqual(self.problem.num_steps, self.movement.num_steps) + + def test_max_wake_rows_from_movement(self): + """Test that max_wake_rows is taken from the movement.""" + self.assertEqual(self.problem.max_wake_rows, self.movement.max_wake_rows) + + def test_lcm_period_from_movement(self): + """Test that lcm_period is taken from the movement by asserting the derived + first_averaging_step matches the static-movement formula (num_steps - 1). + """ + self.assertEqual(self.movement.lcm_period, 0.0) + self.assertEqual(self.problem.first_averaging_step, self.movement.num_steps - 1) + + def test_movement_property_returns_core_movement(self): + """Test that the movement property returns the provided CoreMovement.""" + self.assertIs(self.problem.movement, self.movement) + + def test_steady_problems_property_returns_tuple(self): + """Test that the steady_problems property returns a tuple view of the + _steady_problems backing list. + """ + steady_problems = self.problem.steady_problems + self.assertIsInstance(steady_problems, tuple) + self.assertEqual(len(steady_problems), 1) + self.assertIs(steady_problems[0], self.problem._steady_problems[0]) + + def test_steady_problems_property_reflects_appends(self): + """Test that appends to _steady_problems are reflected in the steady_problems + tuple view on subsequent access. + """ + self.assertEqual(len(self.problem.steady_problems), 1) + + next_steady_problem = problem_fixtures.make_basic_steady_problem_fixture() + self.problem._steady_problems.append(next_steady_problem) + + self.assertEqual(len(self.problem.steady_problems), 2) + self.assertIs(self.problem.steady_problems[1], next_steady_problem) + + def test_get_steady_problem_returns_requested_step(self): + """Test that get_steady_problem returns the SteadyProblem at the given + step. + """ + self.assertIs( + self.problem.get_steady_problem(0), + self.problem._steady_problems[0], + ) + + next_steady_problem = problem_fixtures.make_basic_steady_problem_fixture() + self.problem._steady_problems.append(next_steady_problem) + self.assertIs(self.problem.get_steady_problem(1), next_steady_problem) + + def test_get_steady_problem_rejects_negative_step(self): + """Test that get_steady_problem raises for negative step values.""" + with self.assertRaises(ValueError): + self.problem.get_steady_problem(-1) + + def test_get_steady_problem_rejects_step_beyond_initialized(self): + """Test that get_steady_problem raises for a step index that has not yet + been initialized. + """ + with self.assertRaises(ValueError): + self.problem.get_steady_problem(1) + + def test_get_steady_problem_dynamic_bounds(self): + """Test that the valid range of get_steady_problem grows as new + SteadyProblems are appended to _steady_problems. + """ + with self.assertRaises(ValueError): + self.problem.get_steady_problem(1) + + next_steady_problem = problem_fixtures.make_basic_steady_problem_fixture() + self.problem._steady_problems.append(next_steady_problem) + + self.assertIs(self.problem.get_steady_problem(1), next_steady_problem) + + def test_initialize_next_problem_raises_not_implemented(self): + """Test that initialize_next_problem raises NotImplementedError on the + abstract middle class. + """ + with self.assertRaises(NotImplementedError): + self.problem.initialize_next_problem(None) + + +class TestCoupledUnsteadyProblemImmutability(unittest.TestCase): + """Tests for _CoupledUnsteadyProblem attribute immutability.""" + + def setUp(self): + """Set up a fresh _CoupledUnsteadyProblem for each immutability test.""" + self.problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + + def test_immutable_movement_property(self): + """Test that the movement property is read only.""" + new_movement = core_movement_fixtures.make_static_core_movement_fixture() + with self.assertRaises(AttributeError): + self.problem.movement = new_movement + + def test_immutable_steady_problems_property(self): + """Test that the steady_problems property is read only.""" + with self.assertRaises(AttributeError): + self.problem.steady_problems = () + + def test_steady_problems_tuple_immutability(self): + """Test that the steady_problems tuple cannot be mutated.""" + with self.assertRaises(AttributeError): + self.problem.steady_problems.append( + problem_fixtures.make_basic_steady_problem_fixture() + ) + + def test_private_steady_problems_list_is_mutable(self): + """Test that _steady_problems remains mutable so subclass + initialize_next_problem overrides can append the next step's SteadyProblem + during the run loop. + """ + next_steady_problem = problem_fixtures.make_basic_steady_problem_fixture() + self.problem._steady_problems.append(next_steady_problem) + + self.assertEqual(len(self.problem._steady_problems), 2) + self.assertIs(self.problem._steady_problems[1], next_steady_problem) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_coupled_unsteady_ring_vortex_lattice_method.py b/tests/unit/test_coupled_unsteady_ring_vortex_lattice_method.py new file mode 100644 index 00000000..5ae5529e --- /dev/null +++ b/tests/unit/test_coupled_unsteady_ring_vortex_lattice_method.py @@ -0,0 +1,88 @@ +"""This module contains classes to test CoupledUnsteadyRingVortexLatticeMethodSolvers.""" + +import unittest + +import pterasoftware as ps + +# noinspection PyProtectedMember +from pterasoftware._coupled_unsteady_ring_vortex_lattice_method import ( + CoupledUnsteadyRingVortexLatticeMethodSolver, +) +from tests.unit.fixtures import problem_fixtures, solver_fixtures + + +class TestCoupledUnsteadyRingVortexLatticeMethodSolver(unittest.TestCase): + """This is a class with functions to test + CoupledUnsteadyRingVortexLatticeMethodSolvers. + """ + + def setUp(self): + """Set up a fresh problem and solver for each test.""" + self.solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + self.problem = self.solver.unsteady_problem + + def test_initialization_accepts_coupled_unsteady_problem(self): + """Test that initialization accepts a _CoupledUnsteadyProblem.""" + self.assertIsInstance(self.solver, CoupledUnsteadyRingVortexLatticeMethodSolver) + self.assertIsInstance( + self.solver, + ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + ) + self.assertIs(self.solver.unsteady_problem, self.problem) + + def test_initialization_rejects_plain_unsteady_problem(self): + """Test that initialization raises TypeError for a plain UnsteadyProblem.""" + plain_unsteady_problem = problem_fixtures.make_basic_unsteady_problem_fixture() + with self.assertRaises(TypeError): + CoupledUnsteadyRingVortexLatticeMethodSolver(plain_unsteady_problem) + + def test_initialization_rejects_non_problem_types(self): + """Test that initialization raises TypeError for non-problem inputs.""" + invalid_inputs = ["not_a_problem", 123, [1, 2, 3], {"key": "value"}] + for invalid in invalid_inputs: + with self.subTest(invalid=invalid): + with self.assertRaises(TypeError): + CoupledUnsteadyRingVortexLatticeMethodSolver(invalid) + + def test_initialization_rejects_none(self): + """Test that initialization raises TypeError for None.""" + with self.assertRaises(TypeError): + CoupledUnsteadyRingVortexLatticeMethodSolver(None) + + def test_coupled_problem_property_narrows_unsteady_problem(self): + """Test that the _coupled_problem property returns the same object as + unsteady_problem, narrowed to _CoupledUnsteadyProblem. + """ + self.assertIs(self.solver._coupled_problem, self.solver.unsteady_problem) + self.assertIsInstance( + self.solver._coupled_problem, ps.problems._CoupledUnsteadyProblem + ) + + def test_get_steady_problem_at_dispatches_through_coupled_problem(self): + """Test that _get_steady_problem_at dispatches through + _CoupledUnsteadyProblem.get_steady_problem rather than the cached tuple. + + The parent solver captures steady_problems as a tuple snapshot at + construction time. The coupled subclass must dispatch through the + accessor so appends to the problem's _steady_problems backing list are + visible at later steps. + """ + self.assertEqual(len(self.solver.steady_problems), 1) + + next_steady_problem = problem_fixtures.make_basic_steady_problem_fixture() + self.problem._steady_problems.append(next_steady_problem) + + self.assertEqual(len(self.solver.steady_problems), 1) + self.assertIs(self.solver._get_steady_problem_at(1), next_steady_problem) + + def test_inherits_empty_slots(self): + """Test that the subclass declares __slots__ = () so it does not gain an + instance __dict__ that would defeat the parent's __slots__. + """ + self.assertEqual(CoupledUnsteadyRingVortexLatticeMethodSolver.__slots__, ()) + with self.assertRaises(AttributeError): + self.solver.not_a_real_attribute = 42 + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_unsteady_ring_vortex_lattice_method.py b/tests/unit/test_unsteady_ring_vortex_lattice_method.py new file mode 100644 index 00000000..c6246720 --- /dev/null +++ b/tests/unit/test_unsteady_ring_vortex_lattice_method.py @@ -0,0 +1,87 @@ +"""This module contains classes to test UnsteadyRingVortexLatticeMethodSolvers.""" + +import unittest +from unittest.mock import patch + +import pterasoftware as ps +from tests.unit.fixtures import problem_fixtures, solver_fixtures + + +class TestUnsteadyRingVortexLatticeMethodSolver(unittest.TestCase): + """This is a class with functions to test UnsteadyRingVortexLatticeMethodSolvers.""" + + def test_initialization_accepts_unsteady_problem(self): + """Test that initialization accepts an UnsteadyProblem.""" + solver = solver_fixtures.make_unsteady_ring_solver_fixture() + self.assertIsInstance( + solver, + ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + ) + + def test_initialization_rejects_coupled_unsteady_problem(self): + """Test that initialization on the base solver raises TypeError for a + _CoupledUnsteadyProblem, while still allowing the coupled subclass to + pass one through super(). + """ + coupled_problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + with self.assertRaises(TypeError): + ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver( + coupled_problem + ) + + def test_initialization_rejects_non_problem_types(self): + """Test that initialization raises TypeError for non-problem inputs.""" + invalid_inputs = [None, "not_a_problem", 123, [1, 2, 3], {"key": "value"}] + for invalid in invalid_inputs: + with self.subTest(invalid=invalid): + with self.assertRaises(TypeError): + ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver( + invalid + ) + + +class TestUnsteadyRingVortexLatticeMethodSolverHookDefaults(unittest.TestCase): + """Tests for the default implementations of the three solver extension hooks + added to support coupled subclasses: _initialize_step_vortices, + _reinitialize_step_arrays_hook, and _pre_shed_hook. + """ + + def setUp(self): + """Set up a fresh solver for each hook-default test.""" + self.solver = solver_fixtures.make_unsteady_ring_solver_fixture() + + def test_initialize_step_vortices_initializes_all_on_step_zero(self): + """Test that the default _initialize_step_vortices initializes bound + vortices for all steps when called with step == 0. + """ + with patch.object( + type(self.solver), "_initialize_panel_vortices", autospec=True + ) as mock_init: + self.solver._initialize_step_vortices(0) + mock_init.assert_called_once_with(self.solver) + + def test_initialize_step_vortices_is_noop_for_later_steps(self): + """Test that the default _initialize_step_vortices is a no-op for any + step greater than 0. + """ + later_steps = [1, 2, self.solver.num_steps - 1] + with patch.object( + type(self.solver), "_initialize_panel_vortices", autospec=True + ) as mock_init: + for step in later_steps: + self.solver._initialize_step_vortices(step) + mock_init.assert_not_called() + + def test_reinitialize_step_arrays_hook_default_is_noop(self): + """Test that the default _reinitialize_step_arrays_hook is a no-op.""" + self.assertIsNone(self.solver._reinitialize_step_arrays_hook()) + + def test_pre_shed_hook_default_is_noop(self): + """Test that the default _pre_shed_hook is a no-op for all steps.""" + for step in [0, 1, self.solver.num_steps - 1]: + with self.subTest(step=step): + self.assertIsNone(self.solver._pre_shed_hook(step)) + + +if __name__ == "__main__": + unittest.main()