Skip to content

[FEATURE] Coupled Layer#163

Merged
camUrban merged 4 commits intomainfrom
feature/core_coupled
Apr 21, 2026
Merged

[FEATURE] Coupled Layer#163
camUrban merged 4 commits intomainfrom
feature/core_coupled

Conversation

@camUrban
Copy link
Copy Markdown
Owner

@camUrban camUrban commented Apr 21, 2026

Description

This PR extracts an abstract Coupled* middle layer from the problem and solver hierarchies so that upcoming feature work (single-airplane free flight and aeroelasticity) can build on a shared step-by-step orchestration layer instead of each feature reimplementing the loop. The middle layer is intentionally incomplete on its own: it establishes the shape of the contract (initialize_next_problem as the step-to-step hook) and the machinery to run the parent UVLM loop coupled to that contract, but concrete subclasses land in follow-up PRs.

Much of this code was manually extracted from @JonahJ27's work on PR #156 rather than written from scratch. @JonahJ27 is tagged as co-author on the commits. In addition, I've nailed down a few other design decisions, added unit tests for the new classes and attributes, and updated the docs.

Motivation

Two features, aeroelasticity (PR #156) and free flight (branch feature/free_flight), both need the UVLM solver to advance one step at a time, build the next step's SteadyProblem from the previous step's results, and loop. Their current branches each define a CoupledUnsteadyProblem / coupled solver pair with incompatible interfaces, and each duplicates the UVLM run() method to inject coupling. Without a shared middle layer, merging either feature forces the other to rewrite to match. Extracting the shared pieces here removes that double cost and unblocks both features to rebase onto main cleanly.

The foundation this builds on landed in PR #160: Core* movement classes in _core.py, CoreUnsteadyProblem, and _get_steady_problem_at(step) dispatch on the parent UVLM solver. This PR adds the next layer up.

Relevant Issues

Incidentally, this PR addresses the unit testing portion of #136.

Changes

  • Added _CoupledUnsteadyProblem(CoreUnsteadyProblem) to problems.py as an abstract middle-layer class. Owns the mutable _steady_problems: list[SteadyProblem] backing slot, seeds step 0 from initial_airplanes and initial_operating_point, exposes a get_steady_problem(step) accessor with dynamic bounds, and provides property overrides for movement and steady_problems (the latter returns a read-only tuple view of the backing list). initialize_next_problem(solver) raises NotImplementedError. Subclasses must implement it.
  • Added CoupledUnsteadyRingVortexLatticeMethodSolver(UnsteadyRingVortexLatticeMethodSolver) in a new private module pterasoftware/_coupled_unsteady_ring_vortex_lattice_method.py. Inherits the parent's run() and initialize_step_geometry() unchanged and overrides three hooks to inject coupled behavior (see next bullet). Declares __slots__ = () to avoid silently defeating the parent's __slots__. Exposes a _coupled_problem property that narrows the inherited unsteady_problem to _CoupledUnsteadyProblem via typing.cast.
  • Added three new hooks on the parent UnsteadyRingVortexLatticeMethodSolver so the coupled subclass can share the parent's run loop rather than duplicating it:
    • _initialize_step_vortices(step): default initializes bound vortices for all steps on step 0 and is a no-op afterwards; coupled subclasses override to initialize per step.
    • _reinitialize_step_arrays_hook(): no-op extension point for feature subclasses to reset step-specific arrays.
    • _pre_shed_hook(step): default no-op; coupled subclasses call _CoupledUnsteadyProblem.initialize_next_problem(self) here between steps.
  • Widened UnsteadyRingVortexLatticeMethodSolver.__init__ to accept CoreUnsteadyProblem (previously required UnsteadyProblem) so subclasses can pass their own variants through super().__init__. Added a type(self) is UnsteadyRingVortexLatticeMethodSolver and not isinstance(..., UnsteadyProblem) guard that still rejects direct misuse of the base solver with a _CoupledUnsteadyProblem.
  • Added NotImplementedError property stubs for movement and steady_problems on CoreUnsteadyProblem so that the shared UVLM solver code can reach through either UnsteadyProblem or a coupled subclass without knowing which.
  • Added unit tests:
    • tests/unit/test_coupled_unsteady_problem.py (19 tests): construction, property views, get_steady_problem dynamic bounds, initialize_next_problem NotImplementedError, immutability.
    • tests/unit/test_coupled_unsteady_ring_vortex_lattice_method.py (7 tests): init accept / reject, _coupled_problem cast, _get_steady_problem_at dispatch, empty __slots__.
    • tests/unit/test_unsteady_ring_vortex_lattice_method.py (7 tests): _CoupledUnsteadyProblem rejection on the base solver plus the three new hook defaults.
    • Registered all three modules in tests/unit/__init__.py and added make_basic_coupled_unsteady_problem_fixture / make_coupled_unsteady_ring_solver_fixture to the shared fixture modules.
  • Added a _CoupledUnsteadyProblem section to docs/CLASSES_AND_IMMUTABILITY.md with its attribute classification and a note flagging that the steady_problems tuple view is frozen per call but successive calls may return different-length tuples as the solver initializes new steps. Widened the Solver Classes paragraph for the fourth solver.

Notes for the aeroelasticity rebase

@JonahJ27: Flagging the structural differences between this extracted middle layer and the version currently on feature/aeroelasticity-ptera-merge, so nothing surprises you during the rebase:

  • Problem-class name and privacy: the middle layer is named _CoupledUnsteadyProblem (leading underscore) and lives in public problems.py. Your AeroelasticUnsteadyProblem should extend _CoupledUnsteadyProblem. The underscore indicates it's internal scaffolding, not a user-facing class. Import path: from pterasoftware.problems import _CoupledUnsteadyProblem.
  • Solver-class module path changed: the coupled solver lives in pterasoftware/_coupled_unsteady_ring_vortex_lattice_method.py (private module, public class name). Your AeroelasticSolver imports shift to from pterasoftware._coupled_unsteady_ring_vortex_lattice_method import CoupledUnsteadyRingVortexLatticeMethodSolver.
  • Middle layer is abstract: _CoupledUnsteadyProblem.initialize_next_problem raises NotImplementedError. You already plan to override it in AeroelasticUnsteadyProblem, so no change required. Just noting that the no-op default from PR add coupled unsteady problem and solver #161 is gone.
  • Accumulation moved from solver to problem: your previous solver.steady_problems_data_storage: list[...] worked, but putting the growing list on _CoupledUnsteadyProblem instead has two benefits. The problem already has to expose steady_problems to satisfy the inherited UnsteadyProblem contract used by output.py, _serialization.py, and the solver's own _get_steady_problem_at dispatch, so keeping a parallel accumulator on the solver was redundant. _CoupledUnsteadyProblem now owns the backing list at self._steady_problems, and the public steady_problems tuple property views it. Your initialize_next_problem override on AeroelasticUnsteadyProblem appends newly constructed SteadyProblems to self._steady_problems.
  • run() shouldn't need duplicating: the parent UVLM run() is now hook-friendly. The coupled solver overrides three hooks instead of copying the run loop:
    • _initialize_step_vortices(step): bound-vortex init for the current step. The coupled base already implements this by delegating to _initialize_panel_vortices_at(step). Override further in AeroelasticSolver only if you need additional per-step vortex work.
    • _reinitialize_step_arrays_hook(): reset step-specific solver arrays between steps. The coupled base is a no-op; this is the place to put aeroelastic per-step state resets if you need them.
    • _pre_shed_hook(step): called after loads are computed, before the wake shed. The coupled base calls _coupled_problem.initialize_next_problem(self) and re-initializes the next step's vortices here. Any work you had in the middle of your duplicated run() between load computation and wake shed will likely move here. Any SLEP / moment-processing work that needs to happen specifically for aeroelasticity stays inside AeroelasticSolver.
  • __slots__ on subclasses: CoupledUnsteadyRingVortexLatticeMethodSolver declares __slots__ = (), which means your AeroelasticSolver must declare its own __slots__ (either () or the tuple of any new slots it needs). Leaving __slots__ undeclared on the subclass gives the instance a __dict__ and silently defeats the parent's slots.
  • Type-narrowing pattern for subclasses: CoupledUnsteadyRingVortexLatticeMethodSolver exposes _coupled_problem as a typing.cast narrowed view of the inherited unsteady_problem attribute. AeroelasticSolver can follow the same pattern to expose an _aeroelastic_problem property narrowed to AeroelasticUnsteadyProblem, avoiding repeated cast calls in method bodies.
  • Base solver now accepts CoreUnsteadyProblem: UnsteadyRingVortexLatticeMethodSolver.__init__ widened to accept any CoreUnsteadyProblem so subclasses can chain through super().__init__. A type(self) is ... guard still rejects direct instantiation of the base solver with a _CoupledUnsteadyProblem. I don't think you'll need to modify either. Tag me in a comment if there's an edge case I didn't consider.
  • CoreUnsteadyProblem property stubs: movement and steady_problems now raise NotImplementedError on the CoreUnsteadyProblem base and are overridden by both UnsteadyProblem and _CoupledUnsteadyProblem. AeroelasticUnsteadyProblem inherits the _CoupledUnsteadyProblem overrides, so no action needed.
  • Documentation template for AeroelasticUnsteadyProblem: the new _CoupledUnsteadyProblem section in docs/CLASSES_AND_IMMUTABILITY.md is the shape AeroelasticUnsteadyProblem should follow. Treat its "Immutable" table as the starting point; your aeroelastic class mainly adds AeroelasticStructuralModel and any per-step structural state.

Dependency Updates

None.

Change Magnitude

Moderate: Medium-sized change that adds or modifies a feature without large-scale impact.

Checklist (check each item when completed or not applicable)

  • I am familiar with the current contribution guidelines.
  • PR description links all relevant issues and follows this template.
  • My branch is based on main and is up to date with the upstream main branch.
  • All calculations use S.I. units.
  • Code is formatted with black (line length = 88).
  • Code is well documented with block comments where appropriate.
  • Any external code, algorithms, or equations used have been cited in comments or docstrings.
  • All new modules, classes, functions, and methods have docstrings in reStructuredText format, and are formatted using docformatter (--in-place --black). See the style guide for type hints and docstrings for more details.
  • All new classes, functions, and methods in the pterasoftware package use type hints. See the style guide for type hints and docstrings for more details.
  • If any major functionality was added or significantly changed, I have added or updated tests in the tests package.
  • Code locally passes all tests in the tests package.
  • This PR passes the ReadTheDocs build check (this runs automatically with the other workflows).
  • This PR passes the black, codespell, and isort GitHub actions.
  • This PR passes the mypy GitHub action.
  • This PR passes all the tests GitHub actions.

camUrban and others added 4 commits April 21, 2026 12:10
Introduce a shared private abstract base so free flight and
aeroelasticity can both generate SteadyProblems dynamically per time
step rather than reimplementing step-by-step list management and seeding
logic independently.

Add movement and steady_problems NotImplementedError stubs to
CoreUnsteadyProblem so the base UVLM solver can access them on any
subclass without knowing the concrete type. Widen the solver
unsteady_problem parameter accordingly and guard direct base
instantiation via a type(self) check, which preserves rejection of
misuse while letting future coupled solver subclasses pass their own
variants through super().

Co-Authored-By: JonahJ27 <jobomcbean@gmail.com>
Both upcoming free flight and aeroelasticity features need step by step
geometry orchestration on top of the UVLM run loop. Adding a shared
coupled parent solver lets both features inherit the orchestration
instead of duplicating it.

Introduce three hooks on the base UVLM solver so the coupled subclass
can share run() rather than copying it: per step bound vortex init, per
step array reinit, and a pre shed hook. The coupled subclass overrides
these plus _get_steady_problem_at, routing dynamic geometry dispatch
through _CoupledUnsteadyProblem.

Name the class publicly inside a private module (Core* convention) so
future public subclasses can inherit without cross module private
attribute imports.

Co-Authored-By: JonahJ27 <jobomcbean@gmail.com>
Complete the test coverage for the Coupled* middle layer extracted in
prior commits on this branch. Cover _CoupledUnsteadyProblem construction
and accessors, the coupled solver's init validation and dispatch to the
problem's get_steady_problem accessor, and the base solver's new
_CoupledUnsteadyProblem rejection plus three hook defaults.
Align _CoupledUnsteadyProblem with the _movement/movement idiom used
throughout the package so external readers touch only the tuple-view
property while subclass initialize_next_problem overrides write through
the private backing list. Document the new class in
CLASSES_AND_IMMUTABILITY.md, flagging that the steady_problems view
retains the inherited read-only contract but loses the temporal-
invariance guarantee held by UnsteadyProblem. Update the solver-classes
paragraph to include the fourth solver class.
@camUrban camUrban added this to the v5.1.0 milestone Apr 21, 2026
@camUrban camUrban self-assigned this Apr 21, 2026
@camUrban camUrban added maintenance Improvements or additions to documentation, testing, or robustness feature New feature or request labels Apr 21, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 21, 2026

Codecov Report

❌ Patch coverage is 88.70968% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.72%. Comparing base (7f68e61) to head (e21308e).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...re/_coupled_unsteady_ring_vortex_lattice_method.py 78.26% 5 Missing ⚠️
pterasoftware/_core.py 71.42% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #163      +/-   ##
==========================================
- Coverage   91.74%   91.72%   -0.03%     
==========================================
  Files          34       35       +1     
  Lines        6784     6837      +53     
==========================================
+ Hits         6224     6271      +47     
- Misses        560      566       +6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@camUrban camUrban merged commit 09266c0 into main Apr 21, 2026
13 checks passed
@camUrban camUrban deleted the feature/core_coupled branch April 21, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request maintenance Improvements or additions to documentation, testing, or robustness

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant