diff --git a/pterasoftware/__init__.py b/pterasoftware/__init__.py index 6518a6bc..de3468d7 100644 --- a/pterasoftware/__init__.py +++ b/pterasoftware/__init__.py @@ -19,7 +19,8 @@ output.py: Contains functions for visualizing geometry and results. -problems.py: Contains the SteadyProblem and UnsteadyProblem classes. +problems.py: Contains the SteadyProblem, UnsteadyProblem, and CoupledUnsteadyProblem +classes. steady_horseshoe_vortex_lattice_method.py: Contains the SteadyHorseshoeVortexLatticeMethodSolver class. @@ -33,6 +34,9 @@ unsteady_ring_vortex_lattice_method.py: Contains the UnsteadyRingVortexLatticeMethodSolver class. +coupled_unsteady_ring_vortex_lattice_method.py: Contains the +CoupledUnsteadyRingVortexLatticeMethodSolver class. + **Contains the following functions:** load: Loads a Ptera Software object from a JSON file. @@ -52,6 +56,7 @@ # Lazy imports configuration: modules loaded on first access. _LAZY_MODULES = { "convergence": "pterasoftware.convergence", + "coupled_unsteady_ring_vortex_lattice_method": "pterasoftware.coupled_unsteady_ring_vortex_lattice_method", "output": "pterasoftware.output", "steady_horseshoe_vortex_lattice_method": "pterasoftware.steady_horseshoe_vortex_lattice_method", "steady_ring_vortex_lattice_method": "pterasoftware.steady_ring_vortex_lattice_method", diff --git a/pterasoftware/_core.py b/pterasoftware/_core.py index f1052962..181d7bed 100644 --- a/pterasoftware/_core.py +++ b/pterasoftware/_core.py @@ -5,9 +5,13 @@ import copy import math from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING import numpy as np +if TYPE_CHECKING: + from . import problems as problems_mod + from . import _oscillation, _parameter_validation, _transformations, geometry from . import operating_point as operating_point_mod @@ -2361,3 +2365,11 @@ def first_results_step(self) -> int: @property def max_wake_rows(self) -> int | None: return self._max_wake_rows + + @property + def movement(self) -> CoreMovement: + raise NotImplementedError + + @property + def steady_problems(self) -> tuple[problems_mod.SteadyProblem, ...]: + raise NotImplementedError 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..57e0e6c5 --- /dev/null +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -0,0 +1,421 @@ +"""Contains the CoupledUnsteadyRingVortexLatticeMethodSolver class. + +**Contains the following classes:** + +CoupledUnsteadyRingVortexLatticeMethodSolver: A subclass of +UnsteadyRingVortexLatticeMethodSolver that solves CoupledUnsteadyProblems using the +unsteady ring vortex lattice method. This solver handles step-by-step geometry +initialization and supports two-way coupling between the aerodynamic solver and external +models. + +**Contains the following functions:** + +None +""" + +from __future__ import annotations + +import logging + +import numpy as np +from tqdm import tqdm + +from . import ( + _functions, + _logging, + _parameter_validation, + geometry, + 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. + + This solver handles CoupledUnsteadyProblems where geometry is initialized and + updated on a per-step basis (step-by-step), rather than being fully precomputed as + in the parent class. At each time step, the solver generates the current step's + geometry from the CoupledUnsteadyProblem, solves the aerodynamic system, then calls + the problem's initialize_next_problem method to generate the next step's geometry. + This enables two-way coupling with external models (structural deformation, rigid + body dynamics) that modify geometry based on the current aerodynamic solution. + + **Key differences from parent UnsteadyRingVortexLatticeMethodSolver:** + + - Inherits core aerodynamic solver logic from parent (wall-built inheritance). - + Overrides _get_steady_problem_at() to dynamically retrieve problems from the + CoupledUnsteadyProblem. - Initializes bound RingVortices per-step inside the loop, + rather than all-at-once before the loop. - Calls + CoupledUnsteadyProblem.initialize_next_problem() between time steps to allow + external models to update geometry. + + **Inherited methods (used directly from parent):** + + calculate_solution_velocity: Finds the fluid velocity at one or more points due to + freestream and induced velocity from every RingVortex. + + All movement velocity calculation methods, aerodynamic influence calculation + methods, vortex strength calculation methods, load calculation methods, and wake + population methods. + + **Custom methods:** + + run: Runs the solver on the CoupledUnsteadyProblem with per-step geometry + initialization and coupling hooks. + + initialize_step_geometry: Initializes geometry for a specific step without solving. + """ + + def __init__( + self, + coupled_unsteady_problem: problems.CoupledUnsteadyProblem, + ) -> None: + """The initialization method. + + :param coupled_unsteady_problem: The CoupledUnsteadyProblem to be solved. + SteadyProblems are retrieved dynamically from this problem during iteration + via _get_steady_problem_at(). + :return: None + """ + if not isinstance(coupled_unsteady_problem, problems.CoupledUnsteadyProblem): + raise TypeError( + "coupled_unsteady_problem must be a CoupledUnsteadyProblem." + ) + + # self.coupled_unsteady_problem must be defined before the call to + # super().__init__() because the parent class's __init__ method calls + # _get_steady_problem_at(), which this class overrides to dispatch through + # self.coupled_unsteady_problem. + self.coupled_unsteady_problem = coupled_unsteady_problem + super().__init__(coupled_unsteady_problem) + + # Store computed SteadyProblems for each time step. After the solve completes, + # this list is converted to a tuple and assigned to self.steady_problems. + self.steady_problems_data_storage: list[problems.SteadyProblem] = [] + + def run( + self, + prescribed_wake: bool | np.bool_ = True, + calculate_streamlines: bool | np.bool_ = True, + show_progress: bool | np.bool_ = True, + ) -> None: + """Runs the solver on the CoupledUnsteadyProblem. + + Unlike the parent class, which precomputes all geometry before the main loop, + this method initializes geometry per-step and calls the CoupledUnsteadyProblem's + initialize_next_problem method between steps to allow external models to update + the geometry. + + :param prescribed_wake: Set this to True to solve using a prescribed wake model. + Set to False to use a free-wake, which may be more accurate but will make + the run method significantly slower. Can be a bool or a numpy bool and will + be converted internally to a bool. The default is True. + :param calculate_streamlines: Set this to True to calculate streamlines + emanating from the back of the wing after running the solver. It can be a + bool or a numpy bool and will be converted internally to a bool. The default + is True. + :param show_progress: Set this to True to show the TQDM progress bar. For + showing the progress bar and displaying log statements, set up logging using + the setup_logging function. It can be a bool or a numpy bool and will be + converted internally to a bool. The default is True. + :return: None + """ + self._prescribed_wake = _parameter_validation.boolLike_return_bool( + prescribed_wake, "prescribed_wake" + ) + calculate_streamlines = _parameter_validation.boolLike_return_bool( + calculate_streamlines, "calculate_streamlines" + ) + show_progress = _parameter_validation.boolLike_return_bool( + show_progress, "show_progress" + ) + + # Cache the wing geometry from the initial step. Unlike the parent class (which + # precomputes all steps), coupled geometry is only known for step 0 at this + # point. The number of panels is assumed constant across all steps. + this_problem: problems.SteadyProblem = self._get_steady_problem_at(0) + these_airplanes = this_problem.airplanes + num_wing_panels = 0 + these_wings: list[tuple[geometry.wing.Wing, ...]] = [] + for airplane in these_airplanes: + these_wings.append(airplane.wings) + num_wing_panels += airplane.num_panels + + # Iterate through the Wings to get the total number of spanwise Panels. + this_num_spanwise_panels = 0 + for this_wing_set in these_wings: + for this_wing in this_wing_set: + _this_wing_num_spanwise_panels = this_wing.num_spanwise_panels + assert _this_wing_num_spanwise_panels is not None + + this_num_spanwise_panels += _this_wing_num_spanwise_panels + + # Pre-allocate wake arrays for all time steps using the initial geometry's + # spanwise panel count (which is constant across steps). + for step in range(self.num_steps): + this_num_chordwise_wake_rows = step + if self._max_wake_rows is not None: + this_num_chordwise_wake_rows = min(step, self._max_wake_rows) + this_num_wake_ring_vortices = ( + this_num_chordwise_wake_rows * this_num_spanwise_panels + ) + + this_wake_ring_vortex_strengths = np.zeros( + this_num_wake_ring_vortices, dtype=float + ) + this_wake_ring_vortex_ages = np.zeros( + this_num_wake_ring_vortices, dtype=float + ) + thisStackBrwrvp_GP1_CgP1 = np.zeros( + (this_num_wake_ring_vortices, 3), dtype=float + ) + thisStackFrwrvp_GP1_CgP1 = np.zeros( + (this_num_wake_ring_vortices, 3), dtype=float + ) + thisStackFlwrvp_GP1_CgP1 = np.zeros( + (this_num_wake_ring_vortices, 3), dtype=float + ) + thisStackBlwrvp_GP1_CgP1 = np.zeros( + (this_num_wake_ring_vortices, 3), dtype=float + ) + this_wake_rc0s = np.zeros(this_num_wake_ring_vortices, dtype=float) + + self.list_num_wake_vortices.append(this_num_wake_ring_vortices) + self._list_wake_vortex_strengths.append(this_wake_ring_vortex_strengths) + self._list_wake_vortex_ages.append(this_wake_ring_vortex_ages) + self.listStackBrwrvp_GP1_CgP1.append(thisStackBrwrvp_GP1_CgP1) + self.listStackFrwrvp_GP1_CgP1.append(thisStackFrwrvp_GP1_CgP1) + self.listStackFlwrvp_GP1_CgP1.append(thisStackFlwrvp_GP1_CgP1) + self.listStackBlwrvp_GP1_CgP1.append(thisStackBlwrvp_GP1_CgP1) + self._list_wake_rc0s.append(this_wake_rc0s) + + # Estimate progress bar timing using the initial geometry's panel count. + approx_times = np.zeros(self.num_steps + 1, dtype=float) + for step in range(self.num_steps): + if step != 0: + num_wing_ring_vortices = num_wing_panels + num_wake_ring_vortices = self.list_num_wake_vortices[step] + num_ring_vortices = num_wing_ring_vortices + num_wake_ring_vortices + + if step == 1: + approx_times[step] = num_ring_vortices * 70 + elif step == 2: + approx_times[step] = num_ring_vortices * 30 + else: + approx_times[step] = num_ring_vortices * 3 + + approx_partial_time = np.sum(approx_times) + approx_times[0] = round(approx_partial_time / 100) + approx_total_time = np.sum(approx_times) + + with tqdm( + total=approx_total_time, + unit="", + unit_scale=True, + ncols=100, + desc="Simulating", + disable=not show_progress, + bar_format="{desc}:{percentage:3.0f}% |{bar}| Elapsed: {elapsed}, " + "Remaining: {remaining}", + ) as bar: + bar.update(n=float(approx_times[0])) + + for step in range(self.num_steps): + + self._current_step = step + current_problem: problems.SteadyProblem = self._get_steady_problem_at( + self._current_step + ) + + # Initialize the current step's bound RingVortices per-step (not + # all-at-once like the parent). + _logger.debug(f"Initializing step {step}'s RingVortices.") + self._initialize_panel_vortices_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 + _logger.debug( + "Beginning time step " + + str(self._current_step) + + " out of " + + str(self.num_steps - 1) + + "." + ) + + # Initialize per-step aerodynamic and geometric arrays. + self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E + self._currentStackFreestreamWingInfluences__E = np.zeros( + self.num_panels, dtype=float + ) + self._currentGridWingWingInfluences__E = np.zeros( + (self.num_panels, self.num_panels), dtype=float + ) + self._currentStackWakeWingInfluences__E = np.zeros( + self.num_panels, dtype=float + ) + self._current_bound_vortex_strengths = np.ones( + self.num_panels, dtype=float + ) + self._last_bound_vortex_strengths = np.zeros( + self.num_panels, dtype=float + ) + + self.panels = np.empty(self.num_panels, dtype=object) + self.stackUnitNormals_GP1 = np.zeros((self.num_panels, 3), dtype=float) + self.panel_areas = np.zeros(self.num_panels, dtype=float) + + self.stackCpp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self._stackLastCpp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + + self.stackBrbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackFrbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackFlbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackBlbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self._lastStackBrbrvp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackFrbrvp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackFlbrvp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackBlbrvp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + + self.stackCblvpr_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackCblvpf_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackCblvpl_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackCblvpb_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self._lastStackCblvpr_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackCblvpf_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackCblvpl_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackCblvpb_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + + self.stackRbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackFbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackLbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackBbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) + + self.panel_is_trailing_edge = np.zeros(self.num_panels, dtype=bool) + self.panel_is_leading_edge = np.zeros(self.num_panels, dtype=bool) + self.panel_is_left_edge = np.zeros(self.num_panels, dtype=bool) + self.panel_is_right_edge = np.zeros(self.num_panels, dtype=bool) + + self._current_wake_vortex_strengths = self._list_wake_vortex_strengths[ + step + ] + self._current_wake_vortex_ages = self._list_wake_vortex_ages[step] + self._currentStackBrwrvp_GP1_CgP1 = self.listStackBrwrvp_GP1_CgP1[step] + self._currentStackFrwrvp_GP1_CgP1 = self.listStackFrwrvp_GP1_CgP1[step] + self._currentStackFlwrvp_GP1_CgP1 = self.listStackFlwrvp_GP1_CgP1[step] + self._currentStackBlwrvp_GP1_CgP1 = self.listStackBlwrvp_GP1_CgP1[step] + self._currentStackBoundRc0s = np.zeros(self.num_panels, dtype=float) + self._currentStackWakeRc0s = self._list_wake_rc0s[step] + self.stackSeedPoints_GP1_CgP1 = np.zeros((0, 3), dtype=float) + + # Collapse the geometry matrices into 1D ndarrays of attributes. + _logger.debug("Collapsing the geometry.") + self._collapse_geometry() + + _logger.debug("Calculating the Wing Wing influences.") + self._calculate_wing_wing_influences() + + _logger.debug("Calculating the freestream Wing influences.") + self._calculate_freestream_wing_influences() + + _logger.debug("Calculating the wake Wing influences.") + self._calculate_wake_wing_influences() + + _logger.debug("Calculating bound RingVortex strengths.") + self._calculate_vortex_strengths() + + if self._current_step >= self.first_results_step: + _logger.debug("Calculating forces and moments.") + self._calculate_loads() + + # Coupling: generate the next step's geometry from the + # CoupledUnsteadyProblem, then initialize its bound RingVortices + # before shedding wake. + if self._current_step < self.num_steps - 1: + self.coupled_unsteady_problem.initialize_next_problem(self) + self._initialize_panel_vortices_at(step + 1) + + _logger.debug("Shedding RingVortices into the wake.") + self._populate_next_airplanes_wake() + + self.steady_problems_data_storage.append( + self._get_steady_problem_at(step) + ) + bar.update(n=float(approx_times[step + 1])) + + _logger.debug("Calculating averaged or final forces and moments.") + self._finalize_loads() + + if calculate_streamlines: + _logger.debug("Calculating streamlines.") + _functions.calculate_streamlines(self) + + self.steady_problems = tuple(self.steady_problems_data_storage) + self.ran = True + + def initialize_step_geometry(self, step: int) -> None: + """Initializes geometry for a specific step without solving. + + Sets up bound RingVortices and wake RingVortices for the specified time step, + but does not solve the aerodynamic system. Use this for geometry-only analysis + like delta_time optimization. + + This method must be called sequentially for each step starting from 0, as wake + vortices at step N depend on the geometry from step N - 1. + + :param step: The time step to initialize geometry for. It is zero indexed. It + must be a non-negative int and be less than the total number of steps. + :return: None + """ + step = _parameter_validation.int_in_range_return_int( + step, "step", 0, True, self.num_steps, False + ) + + if step == 0: + self._initialize_panel_vortices_at(0) + + self._current_step = 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 + + if step < self.num_steps - 1: + self._populate_next_airplanes_wake_vortex_points() + self._populate_next_airplanes_wake_vortices() + + def _get_steady_problem_at(self, step: int) -> problems.SteadyProblem: + """Gets the SteadyProblem at a given time step via the CoupledUnsteadyProblem. + + This override is the key abstraction point that enables the coupled solver. The + parent retrieves from a pre-computed tuple; this retrieves from the + CoupledUnsteadyProblem's dynamically growing list. + + :param step: The time step of the desired SteadyProblem. It must be between 0 + and the number of problems initialized so far, exclusive. + :return: The SteadyProblem at the given time step. + """ + return self.coupled_unsteady_problem.get_steady_problem(step) diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 080dc05a..365697c2 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -1,4 +1,4 @@ -"""Contains the SteadyProblem and UnsteadyProblem classes. +"""Contains the SteadyProblem, UnsteadyProblem, and CoupledUnsteadyProblem classes. **Contains the following classes:** @@ -6,6 +6,9 @@ UnsteadyProblem: A class used to contain unsteady aerodynamics problems. +CoupledUnsteadyProblem: A class used to contain coupled unsteady aerodynamics problems +where SteadyProblems are generated dynamically at each time step. + **Contains the following functions:** None @@ -13,11 +16,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 .unsteady_ring_vortex_lattice_method import ( + UnsteadyRingVortexLatticeMethodSolver, + ) + class SteadyProblem: """A class used to contain steady aerodynamics problems. @@ -229,3 +239,152 @@ def movement(self) -> movements.movement.Movement: @property def steady_problems(self) -> tuple[SteadyProblem, ...]: return self._steady_problems + + +class CoupledUnsteadyProblem(_core.CoreUnsteadyProblem): + """A class used to contain coupled unsteady aerodynamics problems. + + Unlike UnsteadyProblem, which pre-computes all SteadyProblems during initialization, + CoupledUnsteadyProblem generates SteadyProblems dynamically at each time step. This + enables two-way coupling between the aerodynamic solver and external models (e.g., + structural deformation, rigid body dynamics) where the geometry or operating + conditions at step N + 1 depend on the aerodynamic solution at step N. + + The base implementation generates each step's SteadyProblem from the movement's + prescribed geometry and operating conditions. Subclasses + (AeroelasticUnsteadyProblem, FreeFlightUnsteadyProblem) override + initialize_next_problem to incorporate feedback from the solver. + + **Contains the following methods:** + + movement: The CoreMovement that contains this CoupledUnsteadyProblem's + OperatingPointMovement and AirplaneMovements. + + steady_problems: A tuple of the SteadyProblems generated so far. + + get_steady_problem: Retrieves the SteadyProblem at a given time step. + + initialize_next_problem: Generates and appends the next time step's SteadyProblem. + """ + + __slots__ = ( + "_movement", + "_coupled_steady_problems", + ) + + def __init__( + self, + movement: _core.CoreMovement, + only_final_results: bool | np.bool_ = False, + ) -> None: + """The initialization method. + + :param movement: The CoreMovement (or subclass) that contains this + CoupledUnsteadyProblem's OperatingPointMovement and AirplaneMovements. It + must be an instance of CoreMovement. + :param only_final_results: Determines whether the solver will only calculate + loads for the final time step (for static movements) or will only calculate + loads for the time steps in the final complete motion cycle (for non-static + movements), which increases simulation speed. Can be a bool or a numpy bool + and will be converted internally to a bool. The default is False. + :return: None + """ + if not isinstance(movement, _core.CoreMovement): + raise TypeError("movement must be a CoreMovement or one of its subclasses.") + self._movement = movement + + # 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, + ) + + # Generate the initial SteadyProblem for step 0 from the movement's prescribed + # geometry and operating conditions. + initial_airplanes: list[geometry.airplane.Airplane] = [] + for airplane_movement in self._movement.airplane_movements: + initial_airplanes.append( + airplane_movement.generate_airplane_at_time_step( + 0, self._movement.delta_time + ) + ) + initial_operating_point = self._movement.operating_point_movement.generate_operating_point_at_time_step( + 0, self._movement.delta_time + ) + initial_problem = SteadyProblem( + airplanes=initial_airplanes, + operating_point=initial_operating_point, + ) + + # Initialize the mutable list of SteadyProblems with the initial problem. + self._coupled_steady_problems: list[SteadyProblem] = [initial_problem] + + # --- Immutable: read only properties --- + @property + def movement(self) -> _core.CoreMovement: + return self._movement + + @property + def steady_problems(self) -> tuple[SteadyProblem, ...]: + """A tuple of the SteadyProblems generated so far. + + During simulation, this tuple grows as initialize_next_problem is called at each + time step. After simulation completes, it contains one SteadyProblem per time + step. + + :return: A tuple of SteadyProblems. + """ + return tuple(self._coupled_steady_problems) + + def get_steady_problem(self, step: int) -> SteadyProblem: + """Retrieves the SteadyProblem at a given time step. + + :param step: An int representing the time step index. It must be non-negative + and less than the number of SteadyProblems generated so far. + :return: The SteadyProblem at the given time step. + """ + if step < 0 or step >= len(self._coupled_steady_problems): + raise ValueError( + f"Step index {step} is out of range. Only" + f" {len(self._coupled_steady_problems)} problems have been" + " initialized so far." + ) + return self._coupled_steady_problems[step] + + def initialize_next_problem( + self, solver: UnsteadyRingVortexLatticeMethodSolver + ) -> None: + """Generates and appends the next time step's SteadyProblem. + + The base implementation generates the next SteadyProblem from the movement's + prescribed geometry and operating conditions. Subclasses override this method to + incorporate feedback from the solver (e.g., structural deformation angles, + dynamics-integrated operating conditions). + + :param solver: The solver instance, which provides access to the current + aerodynamic solution state. Subclasses use this to extract forces, moments, + or other quantities needed to compute the next step's geometry or operating + conditions. + :return: None + """ + next_step = len(self._coupled_steady_problems) + + next_airplanes: list[geometry.airplane.Airplane] = [] + for airplane_movement in self._movement.airplane_movements: + next_airplanes.append( + airplane_movement.generate_airplane_at_time_step( + next_step, self._movement.delta_time + ) + ) + next_operating_point = self._movement.operating_point_movement.generate_operating_point_at_time_step( + next_step, self._movement.delta_time + ) + next_problem = SteadyProblem( + airplanes=next_airplanes, + operating_point=next_operating_point, + ) + self._coupled_steady_problems.append(next_problem) diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 692d4c88..59086e33 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, @@ -127,14 +128,20 @@ 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. + :param unsteady_problem: The CoreUnsteadyProblem to be solved. This can be an + UnsteadyProblem (with pre-computed SteadyProblems) or a + CoupledUnsteadyProblem (with dynamically generated SteadyProblems). The + problem must expose a steady_problems attribute and a movement property. :return: None """ - if not isinstance(unsteady_problem, problems.UnsteadyProblem): - raise TypeError("unsteady_problem must be an UnsteadyProblem.") + if not isinstance(unsteady_problem, _core.CoreUnsteadyProblem): + raise TypeError( + "unsteady_problem must be a CoreUnsteadyProblem or one of its" + " subclasses." + ) self.unsteady_problem = unsteady_problem self._max_wake_rows = self.unsteady_problem.max_wake_rows @@ -1449,6 +1456,54 @@ def _calculate_loads(self) -> None: + unsteady_forces_GP1 ) + # Calculate the moments via the hook method. The base implementation + # computes moments in the first Airplane's geometry axes, relative to the + # first Airplane's CG. Subclasses can override the hook to compute + # additional moment representations (e.g., about strip leading edge points). + moments_GP1_CgP1 = self._load_calculation_moment_processing_hook( + rightLegForces_GP1, + frontLegForces_GP1, + leftLegForces_GP1, + backLegForces_GP1, + unsteady_forces_GP1, + ) + + # TODO: Transform forces_GP1 and moments_GP1_CgP1 to each Airplane's local + # geometry axes before passing to process_solver_loads. + _functions.process_solver_loads(self, forces_GP1, moments_GP1_CgP1) + + def _load_calculation_moment_processing_hook( + self, + rightLegForces_GP1: np.ndarray, + frontLegForces_GP1: np.ndarray, + leftLegForces_GP1: np.ndarray, + backLegForces_GP1: np.ndarray, + unsteady_forces_GP1: np.ndarray, + ) -> np.ndarray: + """Computes moments (in the first Airplane's geometry axes, relative to the + first Airplane's CG) from the per-leg and unsteady forces on every Panel. + + Subclasses can override this method to compute additional moment representations + (e.g., about strip leading edge points) while still returning the CG-based + moments for the standard load processing pipeline. + + :param rightLegForces_GP1: An (N, 3) ndarray of floats representing the forces + (in the first Airplane's geometry axes) on each Panel's right LineVortex + leg. + :param frontLegForces_GP1: An (N, 3) ndarray of floats representing the forces + (in the first Airplane's geometry axes) on each Panel's front LineVortex + leg. + :param leftLegForces_GP1: An (N, 3) ndarray of floats representing the forces + (in the first Airplane's geometry axes) on each Panel's left LineVortex leg. + :param backLegForces_GP1: An (N, 3) ndarray of floats representing the forces + (in the first Airplane's geometry axes) on each Panel's back LineVortex leg. + :param unsteady_forces_GP1: An (N, 3) ndarray of floats representing the + unsteady component of the force (in the first Airplane's geometry axes) on + each Panel, derived from the unsteady Bernoulli equation. + :return: An (N, 3) ndarray of floats representing the moments (in the first + Airplane's geometry axes, relative to the first Airplane's CG) on every + Panel at the current time step. + """ # Find the moments (in the first Airplane's geometry axes, relative to the # first Airplane's CG) on the Panels' RingVortex's right LineVortex, # front LineVortex, left LineVortex, and back LineVortex. @@ -1470,23 +1525,21 @@ def _calculate_loads(self) -> None: # collocation point, not at the Panel's centroid. # Find the moments (in the first Airplane's geometry axes, relative to the - # first Airplane's CG) due to the unsteady component of the force on each Panel. + # first Airplane's CG) due to the unsteady component of the force on each + # Panel. unsteady_moments_GP1_CgP1 = _functions.numba_1d_explicit_cross( self.stackCpp_GP1_CgP1, unsteady_forces_GP1 ) - moments_GP1_CgP1 = ( + return cast( + np.ndarray, rightLegMoments_GP1_CgP1 + frontLegMoments_GP1_CgP1 + leftLegMoments_GP1_CgP1 + backLegMoments_GP1_CgP1 - + unsteady_moments_GP1_CgP1 + + unsteady_moments_GP1_CgP1, ) - # TODO: Transform forces_GP1 and moments_GP1_CgP1 to each Airplane's local - # geometry axes before passing to process_solver_loads. - _functions.process_solver_loads(self, forces_GP1, moments_GP1_CgP1) - def _populate_next_airplanes_wake(self) -> None: """Updates the next time step's Airplanes' wakes. @@ -2084,8 +2137,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/fixtures/problem_fixtures.py b/tests/unit/fixtures/problem_fixtures.py index 970be1d6..7e5d2895 100644 --- a/tests/unit/fixtures/problem_fixtures.py +++ b/tests/unit/fixtures/problem_fixtures.py @@ -105,3 +105,39 @@ 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. + """ + # Create a basic Movement. + basic_movement = movement_fixtures.make_basic_movement_fixture() + + # Create the CoupledUnsteadyProblem. + basic_coupled_unsteady_problem_fixture = ps.problems.CoupledUnsteadyProblem( + movement=basic_movement, + only_final_results=False, + ) + + return basic_coupled_unsteady_problem_fixture + + +def make_static_coupled_unsteady_problem_fixture(): + """This method makes a fixture that is a CoupledUnsteadyProblem with static motion. + + :return static_coupled_unsteady_problem_fixture: CoupledUnsteadyProblem + This is the CoupledUnsteadyProblem with static motion. + """ + # Create a static Movement. + static_movement = movement_fixtures.make_static_movement_fixture() + + # Create the CoupledUnsteadyProblem. + static_coupled_unsteady_problem_fixture = ps.problems.CoupledUnsteadyProblem( + movement=static_movement, + only_final_results=False, + ) + + return static_coupled_unsteady_problem_fixture diff --git a/tests/unit/fixtures/solver_fixtures.py b/tests/unit/fixtures/solver_fixtures.py index 6e29283f..f0452cad 100644 --- a/tests/unit/fixtures/solver_fixtures.py +++ b/tests/unit/fixtures/solver_fixtures.py @@ -53,3 +53,21 @@ 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 = ps.coupled_unsteady_ring_vortex_lattice_method.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..039aaca0 --- /dev/null +++ b/tests/unit/test_coupled_unsteady_problem.py @@ -0,0 +1,244 @@ +"""This module contains classes to test CoupledUnsteadyProblems.""" + +import unittest + +import numpy as np + +import pterasoftware as ps +from tests.unit.fixtures import ( + movement_fixtures, + problem_fixtures, +) + + +class TestCoupledUnsteadyProblemInitialization(unittest.TestCase): + """This is a class with functions to test CoupledUnsteadyProblem initialization.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures once for all CoupledUnsteadyProblem tests.""" + cls.basic_coupled_problem = ( + problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + ) + cls.static_coupled_problem = ( + problem_fixtures.make_static_coupled_unsteady_problem_fixture() + ) + + def test_initialization_returns_correct_type(self): + """Test that CoupledUnsteadyProblem initialization returns the correct type.""" + self.assertIsInstance( + self.basic_coupled_problem, + ps.problems.CoupledUnsteadyProblem, + ) + + def test_is_subclass_of_core(self): + """Test that CoupledUnsteadyProblem is a subclass of CoreUnsteadyProblem.""" + self.assertIsInstance( + self.basic_coupled_problem, + ps._core.CoreUnsteadyProblem, + ) + + def test_movement_property_returns_core_movement(self): + """Test that the movement property returns a CoreMovement instance.""" + self.assertIsInstance( + self.basic_coupled_problem.movement, + ps._core.CoreMovement, + ) + + def test_num_steps_matches_movement(self): + """Test that num_steps matches the movement's num_steps.""" + movement = movement_fixtures.make_basic_movement_fixture() + self.assertEqual(self.basic_coupled_problem.num_steps, movement.num_steps) + + def test_delta_time_matches_movement(self): + """Test that delta_time matches the movement's delta_time.""" + movement = movement_fixtures.make_basic_movement_fixture() + self.assertAlmostEqual( + self.basic_coupled_problem.delta_time, + movement.delta_time, + places=10, + ) + + def test_initial_steady_problems_count(self): + """Test that initialization creates exactly one SteadyProblem for step 0.""" + self.assertEqual(len(self.basic_coupled_problem.steady_problems), 1) + + def test_initial_steady_problem_is_steady_problem_type(self): + """Test that the initial SteadyProblem is a SteadyProblem instance.""" + self.assertIsInstance( + self.basic_coupled_problem.steady_problems[0], + ps.problems.SteadyProblem, + ) + + def test_initial_steady_problem_has_airplanes(self): + """Test that the initial SteadyProblem's Airplanes are populated.""" + initial_problem = self.basic_coupled_problem.steady_problems[0] + self.assertGreater(len(initial_problem.airplanes), 0) + for airplane in initial_problem.airplanes: + self.assertIsInstance(airplane, ps.geometry.airplane.Airplane) + + def test_initial_steady_problem_has_operating_point(self): + """Test that the initial SteadyProblem has an OperatingPoint.""" + initial_problem = self.basic_coupled_problem.steady_problems[0] + self.assertIsInstance( + initial_problem.operating_point, + ps.operating_point.OperatingPoint, + ) + + def test_only_final_results_default_false(self): + """Test that only_final_results defaults to False.""" + self.assertFalse(self.basic_coupled_problem.only_final_results) + + def test_only_final_results_true(self): + """Test that only_final_results can be set to True.""" + movement = movement_fixtures.make_basic_movement_fixture() + problem = ps.problems.CoupledUnsteadyProblem( + movement=movement, + only_final_results=True, + ) + self.assertTrue(problem.only_final_results) + + def test_static_movement_first_averaging_step(self): + """Test that static motion sets first_averaging_step to num_steps - 1.""" + self.assertEqual( + self.static_coupled_problem.first_averaging_step, + self.static_coupled_problem.num_steps - 1, + ) + + +class TestCoupledUnsteadyProblemParameterValidation(unittest.TestCase): + """Tests for CoupledUnsteadyProblem parameter validation.""" + + def test_movement_must_be_core_movement(self): + """Test that movement parameter must be a CoreMovement or subclass.""" + with self.assertRaises(TypeError): + ps.problems.CoupledUnsteadyProblem(movement="not_a_movement") + + def test_movement_rejects_none(self): + """Test that movement parameter rejects None.""" + with self.assertRaises(TypeError): + ps.problems.CoupledUnsteadyProblem(movement=None) + + def test_movement_rejects_invalid_types(self): + """Test that movement parameter rejects various invalid types.""" + invalid_movements = [123, [1, 2, 3], {"key": "value"}] + for invalid in invalid_movements: + with self.subTest(invalid=invalid): + with self.assertRaises(TypeError): + ps.problems.CoupledUnsteadyProblem(movement=invalid) + + +class TestCoupledUnsteadyProblemGetSteadyProblem(unittest.TestCase): + """Tests for CoupledUnsteadyProblem.get_steady_problem.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + cls.coupled_problem = ( + problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + ) + + def test_get_step_zero_returns_steady_problem(self): + """Test that get_steady_problem(0) returns a SteadyProblem.""" + result = self.coupled_problem.get_steady_problem(0) + self.assertIsInstance(result, ps.problems.SteadyProblem) + + def test_get_step_zero_returns_initial_problem(self): + """Test that get_steady_problem(0) returns the problem created at init.""" + result = self.coupled_problem.get_steady_problem(0) + initial = self.coupled_problem.steady_problems[0] + self.assertIs(result, initial) + + def test_negative_step_raises_value_error(self): + """Test that negative step index raises ValueError.""" + with self.assertRaises(ValueError): + self.coupled_problem.get_steady_problem(-1) + + def test_out_of_range_step_raises_value_error(self): + """Test that step index beyond initialized problems raises ValueError.""" + with self.assertRaises(ValueError): + self.coupled_problem.get_steady_problem(1) + + +class TestCoupledUnsteadyProblemInitializeNextProblem(unittest.TestCase): + """Tests for CoupledUnsteadyProblem.initialize_next_problem.""" + + def test_initialize_next_problem_appends_problem(self): + """Test that initialize_next_problem adds one SteadyProblem.""" + coupled_problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + initial_count = len(coupled_problem.steady_problems) + + # Create a minimal solver to pass as the argument. + solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_problem + ) + + coupled_problem.initialize_next_problem(solver) + self.assertEqual(len(coupled_problem.steady_problems), initial_count + 1) + + def test_initialize_next_problem_creates_valid_steady_problem(self): + """Test that the appended SteadyProblem has the correct structure.""" + coupled_problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_problem + ) + + coupled_problem.initialize_next_problem(solver) + new_problem = coupled_problem.get_steady_problem(1) + self.assertIsInstance(new_problem, ps.problems.SteadyProblem) + self.assertGreater(len(new_problem.airplanes), 0) + self.assertIsInstance( + new_problem.operating_point, + ps.operating_point.OperatingPoint, + ) + + def test_initialize_multiple_steps(self): + """Test that calling initialize_next_problem repeatedly grows the list.""" + coupled_problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_problem + ) + + num_steps_to_add = 3 + for _ in range(num_steps_to_add): + coupled_problem.initialize_next_problem(solver) + + self.assertEqual(len(coupled_problem.steady_problems), 1 + num_steps_to_add) + + def test_get_steady_problem_after_initialize(self): + """Test that get_steady_problem works for newly added steps.""" + coupled_problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_problem + ) + + coupled_problem.initialize_next_problem(solver) + result = coupled_problem.get_steady_problem(1) + self.assertIsInstance(result, ps.problems.SteadyProblem) + + +class TestCoupledUnsteadyProblemImmutability(unittest.TestCase): + """Tests for CoupledUnsteadyProblem attribute immutability.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + cls.coupled_problem = ( + problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + ) + + def test_immutable_movement_property(self): + """Test that movement property is read only.""" + with self.assertRaises(AttributeError): + self.coupled_problem.movement = None + + def test_steady_problems_returns_tuple(self): + """Test that steady_problems returns a tuple, not a mutable list.""" + result = self.coupled_problem.steady_problems + self.assertIsInstance(result, tuple) + + def test_steady_problems_tuple_prevents_mutation(self): + """Test that the tuple returned by steady_problems cannot be appended to.""" + result = self.coupled_problem.steady_problems + with self.assertRaises(AttributeError): + result.append(None) 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..e2881fcf --- /dev/null +++ b/tests/unit/test_coupled_unsteady_ring_vortex_lattice_method.py @@ -0,0 +1,167 @@ +"""This module contains classes to test CoupledUnsteadyRingVortexLatticeMethodSolver.""" + +import unittest + +import numpy as np + +import pterasoftware as ps +from tests.unit.fixtures import ( + problem_fixtures, + solver_fixtures, +) + + +class TestCoupledSolverInitialization(unittest.TestCase): + """Tests for CoupledUnsteadyRingVortexLatticeMethodSolver initialization.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + cls.solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + + def test_returns_correct_type(self): + """Test that the solver is the expected type.""" + self.assertIsInstance( + self.solver, + ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, + ) + + def test_inherits_from_base_solver(self): + """Test that the coupled solver inherits from the base solver.""" + self.assertIsInstance( + self.solver, + ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + ) + + def test_has_coupled_unsteady_problem(self): + """Test that the solver has the coupled_unsteady_problem attribute.""" + self.assertIsInstance( + self.solver.coupled_unsteady_problem, + ps.problems.CoupledUnsteadyProblem, + ) + + def test_unsteady_problem_equals_coupled_unsteady_problem(self): + """Test that unsteady_problem and coupled_unsteady_problem are the same.""" + self.assertIs( + self.solver.unsteady_problem, + self.solver.coupled_unsteady_problem, + ) + + def test_has_not_run(self): + """Test that the solver has not run after initialization.""" + self.assertFalse(self.solver.ran) + + def test_has_empty_steady_problems_data_storage(self): + """Test that steady_problems_data_storage starts empty.""" + self.assertEqual(len(self.solver.steady_problems_data_storage), 0) + + +class TestCoupledSolverParameterValidation(unittest.TestCase): + """Tests for CoupledUnsteadyRingVortexLatticeMethodSolver parameter validation.""" + + def test_rejects_regular_unsteady_problem(self): + """Test that a regular UnsteadyProblem is rejected.""" + unsteady_problem = problem_fixtures.make_basic_unsteady_problem_fixture() + with self.assertRaises(TypeError): + ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + unsteady_problem + ) + + def test_rejects_none(self): + """Test that None is rejected.""" + with self.assertRaises(TypeError): + ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + None + ) + + def test_rejects_invalid_types(self): + """Test that various invalid types are rejected.""" + invalid_inputs = ["string", 42, [1, 2, 3]] + for invalid in invalid_inputs: + with self.subTest(invalid=invalid): + with self.assertRaises(TypeError): + ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + invalid + ) + + +class TestCoupledSolverGetSteadyProblemAt(unittest.TestCase): + """Tests for the _get_steady_problem_at override.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + cls.solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + + def test_dispatches_to_coupled_problem(self): + """Test that _get_steady_problem_at returns the same object as the coupled + problem's get_steady_problem.""" + from_solver = self.solver._get_steady_problem_at(0) + from_problem = self.solver.coupled_unsteady_problem.get_steady_problem(0) + self.assertIs(from_solver, from_problem) + + def test_returns_steady_problem(self): + """Test that _get_steady_problem_at returns a SteadyProblem.""" + result = self.solver._get_steady_problem_at(0) + self.assertIsInstance(result, ps.problems.SteadyProblem) + + +class TestCoupledSolverRun(unittest.TestCase): + """Tests for running the coupled solver.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures by running the solver. + + This runs the solver once because each run is expensive. + """ + cls.solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + cls.solver.run( + prescribed_wake=True, + calculate_streamlines=False, + show_progress=False, + ) + + def test_ran_is_true(self): + """Test that the ran flag is True after running.""" + self.assertTrue(self.solver.ran) + + def test_steady_problems_populated(self): + """Test that steady_problems is populated after running.""" + self.assertIsInstance(self.solver.steady_problems, tuple) + self.assertEqual(len(self.solver.steady_problems), self.solver.num_steps) + + def test_steady_problems_are_steady_problem_instances(self): + """Test that each element of steady_problems is a SteadyProblem.""" + for problem in self.solver.steady_problems: + self.assertIsInstance(problem, ps.problems.SteadyProblem) + + def test_produces_nonzero_forces(self): + """Test that the solver produces nonzero force coefficients.""" + for airplane in self.solver.current_airplanes: + force_norm = np.linalg.norm(airplane.forceCoefficients_W) + self.assertGreater(force_norm, 0.0) + + def test_coupled_problem_has_all_steps(self): + """Test that the coupled problem contains the correct number of + SteadyProblems.""" + self.assertEqual( + len(self.solver.coupled_unsteady_problem.steady_problems), + self.solver.num_steps, + ) + + +class TestCoupledSolverInitializeStepGeometry(unittest.TestCase): + """Tests for the initialize_step_geometry method.""" + + def test_rejects_negative_step(self): + """Test that a negative step is rejected.""" + solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + with self.assertRaises(Exception): + solver.initialize_step_geometry(-1) + + def test_rejects_step_beyond_range(self): + """Test that a step beyond num_steps is rejected.""" + solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + with self.assertRaises(Exception): + solver.initialize_step_geometry(solver.num_steps)