Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion docs/CLASSES_AND_IMMUTABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
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.
23 changes: 23 additions & 0 deletions pterasoftware/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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."
)
77 changes: 77 additions & 0 deletions pterasoftware/_coupled_unsteady_ring_vortex_lattice_method.py
Original file line number Diff line number Diff line change
@@ -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)
114 changes: 113 additions & 1 deletion pterasoftware/problems.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.")
Loading
Loading