Skip to content

Refactor/add type hints - #128

Merged
andytorrestb merged 9 commits into
plume-kit:masterfrom
andytorrestb:refactor/add-type-hints
Jul 26, 2026
Merged

Refactor/add type hints#128
andytorrestb merged 9 commits into
plume-kit:masterfrom
andytorrestb:refactor/add-type-hints

Conversation

@andytorrestb

Copy link
Copy Markdown
Member

No description provided.

andytorrestb and others added 9 commits July 26, 2026 12:03
pyrpod/ and its subpackages carry no __init__.py, so mypy derived two module
names for the same file (e.g. fs from the file path and pyrpod.util.io.fs
from an import) and aborted before type checking anything:

    pyrpod/util/io/fs.py: error: Source file found twice under different
    module names: fs and pyrpod.util.io.fs

Anchor the package base at the repo root (mypy's own suggested resolution (b))
so a single module name is derived per file. No __init__.py files are added and
no import statement changes, so runtime module resolution is untouched. This is
strictly additive to the mypy configuration; ignore_missing_imports and
strict remain as they were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Complete parameter and return annotations for pyrpod/util/**, logging_utils,
and config_test.

Corrections to existing annotations:

* util/math/transform.rotation_matrix_from_vectors declared its inputs as
  np.ndarray, but every caller (JetFiringHistory.print_JFH_param_curve,
  PlumeStrikeEstimationStudy.calc_time_multiplier) passes plain Python lists
  such as [1, 0, 0], and the body normalizes with np.asarray. Widened to
  numpy.typing.ArrayLike, which is what the docstring already documented
  as array_like of shape (3,). Return narrowed to NDArray[np.float64].

* logging_utils._mark_owned was annotated (Handler) -> Handler, which erased
  the concrete subclass and made configure_logging pass a plain Handler into
  LoggingSession(_counter: _LevelCounter). It has always returned the same
  object it was given, so it is now generic over a Handler-bound TypeVar.
  This removes a real pre-existing mypy error.

Notes:

* numpy-stl ships py.typed, so mesh-carrying signatures use stl.mesh.Mesh
  rather than Any. That also showed BaseStl.from_file/save accept only str,
  so the STL path parameters are annotated str rather than widened to
  os.PathLike.
* file_print does no NumPy work at runtime, so numpy stays a TYPE_CHECKING
  import there, behind a __future__ annotations import.
* config_test: the twelve-entry thruster-group literal is hoisted to an
  annotated module constant so the deliberate reliance on configparser
  stringifying list values is stated in one narrow ignore instead of twelve.
  The generated example.ini is byte-identical (verified by hash).

mypy is clean on these files; tests/rpod/rpod_unit_test_03.py,
rpod_unit_test_04.py and tests/logging pass (61 passed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Complete parameter and return annotations for Vehicle, VisitingVehicle,
TargetVehicle and LogisticsModule.

Type definitions introduced (in VisitingVehicle, where the TCF/CCF parsers
that produce them live):

* ThrusterConfig / ClusterConfig TypedDicts for one parsed thruster or
  cluster entry. The keys and value shapes are pinned by
  process_thruster_def/process_cluster_def and by every consumer
  (['name'][0], ['exit'][0], ['dcm']); tests/rpod/rpod_unit_test_04.py and
  tests/rpod/rpod_integration_test_08.py build the same shape. Declared
  total=False solely so the existing key-by-key parser construction keeps
  type-checking without touching the implementation. 'dcm' accepts nested
  lists or an ndarray because SweepConfig's cant sweeps overwrite it with
  np.dot output.

Attribute annotations, added only where the inference would otherwise be
wrong (bare declarations, so no class attribute is created at runtime):

* VisitingVehicle._thruster_id_map: dict[str, str] | None -- first assignment
  mypy sees is ``= None`` in set_thruster_config.
* VisitingVehicle.thruster_metrics: dict[str, Any] | None -- a real Optional:
  set_thruster_metrics leaves it None when the case has no [tcd] tdf, and
  PlumeStrikeEstimationStudy plus tests/logging observe that None. Any at the
  value level because pandas to_dict(orient='records') yields Hashable-keyed
  records.
* LogisticsModule.rcs_groups: dict[str, list[str]] -- annotated to the class
  contract rather than dict|None, because the ``= None`` written on a missing
  thruster-grouping file is a dead-end sentinel that no consumer checks.

Four narrow ignores were added, each on a pre-existing unguarded-None use
that only became visible once the enclosing signature was annotated (mypy
skips unannotated bodies). Each carries a comment; none of them changes
behavior, and each marks a latent bug rather than hiding one:
LogisticsModule rcs_groups=None assignment, the two active_thrusters uses in
plot_thruster_group (empty group leaves it None), and the unguarded
thruster_metrics lookup in calc_overshoot_v_range.

mypy is clean on pyrpod/vehicle. tests/rpod, tests/mdao and tests/mission
pass (60 passed, 9 skipped). No new flake8 findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Complete parameter and return annotations for every module under
pyrpod/mission plus pyrpod/orbital/HohmannTransfer, which the mission
orbital-transfer engine calls directly.

Corrections to existing annotations:

* MissionEnvironment.clone was annotated -> "MissionContext"; no such class
  exists anywhere in the repository. copy.deepcopy(self) returns this class,
  so the forward reference is now "MissionEnvironment".
* MissionEnvironment.get_current_state was annotated Dict[str, np.ndarray],
  but all four kinematic dataclass fields default to None and nothing in the
  class populates them, so the mapping is Optional-valued. This removes four
  pre-existing mypy errors.
* OrbitalTransferEngine.add_hohmann_transfer declared leg_id: str = None
  (implicit Optional, rejected by mypy's default). Now str | None.
* HohmannTransfer.__init__ declared leg_id: str = "", but the orbital-transfer
  engine passes None for an unnamed leg and `leg_id or f"..."` is what
  supplies the fallback label, so None has always been accepted. Now
  str | None. HohmannTransfer._compute_transfer was also missing its return
  type; it returns summary(verbose=False), i.e. dict[str, Any].

Attribute annotations were added, bare so no class attribute is created at
runtime, for collaborator state that these WIP submodules read but never
define: SixDOFDynamics.vv, ThrusterGrouping.vv/cant,
FuelManager.vv/jfh/cant/rotational_maneuvers plus the three thruster-group
helpers it calls on self, PostProcessor.vv/flight_plan and the three helpers
it calls on self, and FlightEvaluator.flight_plan (Optional: read_flight_plan
leaves it None when the case configures no flight plan).

Narrow ignores, each commented, all on pre-existing defects that only became
visible once the enclosing signature was annotated (mypy skips unannotated
bodies) and none of which is safe to fix under an annotation-only change:

* flight_eval.calc_flight_performance and
  orbital_transfer.add_hohmann_transfer are each defined twice; the second
  definition unconditionally shadows the first.
* thruster_grouping.calc_thrust_sum calls np.cos without numpy imported, so
  that branch raises NameError today. Adding the import would turn a crash
  into a working calculation, which is a behavior change.
* MissionEnvironment.get_thruster_by_id reads self.thruster_data, which is
  never assigned; get_jfh_segment calls jfh.query, which JetFiringHistory does
  not implement.
* Two flight_plan uses that are Optional by declaration but unguarded.
* SixDOFDynamics.calc_trans_performance's bare `return` in the "no thruster
  grouping" branch: mypy demands an explicit `return None` whenever the
  declared return type is not plain None (verified with a minimal repro), and
  the two compile identically, so the source was left alone.

The five plotting accumulators in post_processing are annotated Any: they are
built as lists and then rebound to the NumPy array made from them, and a
list|NDArray union does not survive mypy's loop binder (verified).

mypy is clean on pyrpod/mission and pyrpod/orbital. Full pytest: 192 passed,
12 skipped. No new flake8 findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Complete parameter and return annotations for RarefiedPlumeGasKinetics,
CaiImpingement2016, PlumeStrikeCalculator and IsentropicExpansion.

Type definitions introduced:

* RarefiedPlumeGasKinetics.FloatOrArray (float | NDArray[np.float64]) for the
  closed-form field expressions, which are pure NumPy ufunc math and are
  evaluated on meshgrids by CollisionlessGasKinetics._compute_field_integrals
  and tests/plume/plume_figure_utils.py -- their docstrings already said
  "float or ndarray".
* RarefiedPlumeGasKinetics.Scalar (float | np.floating[Any]) for field-point
  coordinates. The impingement pipeline builds distance with np.linalg.norm
  and theta with 3.14 - np.arccos(...), which produce np.floating, not float.
* CaiImpingement2016.FloatArray / CoefficientField shorthands, purely to keep
  the repeated return annotations inside the 88-column limit.
* Overloads on get_K_factor / get_M_factor / get_N_factor (scalar Q -> float,
  array Q -> array). Without them SimplifiedGasKinetics.K_simple/M_simple/
  N_simple would carry the union and the Eq. 14-17 ratios built from them
  could not be typed as float; NumPy's scalar-in/scalar-out semantics make
  the distinction real.
* Overloads on run_parallel_plume_strikes keyed on Literal[True]/[False] for
  return_meta, which selects the return shape entirely.

Corrections and notable choices:

* PlumeStrikeCalculator._surface_loads_with_incidence and
  _parallel_worker_compute had no return type at all;
  _parallel_worker_compute was implicitly documented as "-> Any".
* The unguarded thruster_metrics[t_type] lookup in
  _compute_plume_strikes_core carries a narrow ignore: the Optional is real
  (compute_plume_strikes passes getattr(vv, 'thruster_metrics', None)) and the
  use_kinetics pairing that makes it safe is a caller invariant.
* IsentropicExpansion.plot_temp_ratios_vs_radius and
  plot_number_density_ratios_vs_radius multiply a Python list by a float,
  which raises TypeError; both are referenced only from commented-out lines in
  tests/plume/plume_verification_test_01.py. Flagged with narrow ignores, not
  fixed -- scaling the ratios would be a numerical change.
* run_flowfield3d_sanity_checks' kw bundle is annotated dict[str, Any]: it is
  forwarded with ** into a function whose parameters are not all float.

mypy is clean on pyrpod/plume. tests/plume: 78 passed, 3 skipped. No new
flake8 findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Complete parameter and return annotations for JetFiringHistory,
PlumeStrikeEstimationStudy and PlumeStudyExport (geometry.py,
approach_maneuvers.py, io.py and header.py were already complete).

With this commit `python -m mypy pyrpod` passes on the whole package.

Corrections to existing annotations:

* PlumeStrikeCalculator.run_parallel_plume_strikes was described as returning
  List[Optional[...]] because results/metas are pre-allocated with None so
  they can be filled in firing order. Every slot is assigned from a future
  before the function returns, so the public return type is now the
  non-Optional list, with the two return statements carrying a narrow ignore
  that documents the pre-allocation. This is what let
  PlumeStrikeEstimationStudy.jfh_plume_strikes type-check without ~15
  suppressions of its own.
* PlumeStrikeEstimationStudy.graph_clusters returns None when no clusters are
  configured (the comment above the return says so); annotated
  mesh.Mesh | None.
* jfh_plume_strikes returns firing_data keyed by the 1-based firing number as
  a string, not by int.

Attribute annotations (all bare, so no runtime attribute is created):

* JetFiringHistory.JFH: list[dict[str, Any]], annotated to the class contract
  for the same reason as LogisticsModule.rcs_groups -- read_jfh writes None on
  two failure paths, but only FuelManager.calc_total_delta_mass checks for it
  while roughly forty sites in this study index JFH unguarded. Two narrow
  ignores mark the sentinel writes. dict[str, Any] rather than a TypedDict
  because the records genuinely exist in two shapes: read_jfh parses the time
  fields as strings with nested-list dcm/xyz, while tests and edit_1d_JFH
  synthesize steps with NumPy arrays under the same keys.
* PlumeStrikeEstimationStudy: jfh/target/vv/viz/case_key/count plus the four
  thruster-group and fuel helpers it calls on self but neither it nor
  MissionPlanner defines.
* MissionPlanner.cant, because calc_jfh_1d_approach assigns it as a class
  attribute on MissionPlanner and reads it straight back.

Narrow ignores for pre-existing defects, each commented:

* graph_jfh_thruster_check builds its frame index with an undefined name `i`
  on the >= 100 firing branch (flake8 already reports this as F821).
* make_test_jfh is declared without self and never called.
* Two unguarded thruster_metrics lookups in calc_time_multiplier.
* Two active_clusters appends where graph_clusters' None return is excluded by
  the use_clusters flag rather than by a type-visible check.

mypy is clean on all 35 files. Full pytest: 192 passed, 12 skipped. No new
flake8 findings (total 1008 -> 960).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Complete parameter and return annotations for SweepConfig and TradeStudy,
plus the three callables missed by the earlier subsystem passes:
rpod.io.write_jfh and the two integrate.quad closures in
RarefiedPlumeGasKinetics. Every function and method under pyrpod/ now has a
complete signature (331/331 by AST inventory).

SweepConfig's sweep methods are typed against the ThrusterConfig TypedDict
introduced with the vehicle subsystem, imported under TYPE_CHECKING because
VisitingVehicle imports SweepConfig at runtime; a real import would close the
cycle.

get_Q_full gains the same scalar/array overload pair as the K/M/N factors, so
the Eq. 21 centerline quadrature closure can be typed float -> float rather
than carrying the union.

rpod.io.write_jfh repeats the _Times/_Rows bound from
pyrpod.util.io.file_print, whose printers it forwards to.

Narrow ignores for pre-existing defects that only became visible once the
enclosing signatures were annotated, each commented:

* SweepDecelAngles.cant_decel_thrusters calls self.calculate_DCM(cant) with
  one argument where the method takes (cant, thruster): TypeError today.
* SweepDecelAngles.sweep_decel_thrusters_all loops over an undefined `config`
  (flake8 also reports F821): NameError today.
* TradeStudy.init_trade_study instantiates PlumeStrikeEstimationStudy.RPOD;
  the module's class is PlumeStrikeEstimationStudy, so this is an
  AttributeError today.

Fixing any of the three would invent behavior, which plume-kit#103 excludes; they are
recorded in the deferred observations instead.

mypy passes on all 35 files. Full pytest: 192 passed, 12 skipped. No new
flake8 findings (1008 -> 960).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six of the eight `if TYPE_CHECKING:` blocks added by the preceding commits
existed only to avoid adding a runtime import edge, not to break a cycle.
They are now plain imports:

    mission/SubModule.py          -> MissionEnvironment
    mission/MissionPlanner.py     -> JetFiringHistory, LogisticsModule
    mission/post_processing.py    -> pandas
    mdao/TradeStudy.py            -> LogisticsModule, TargetVehicle
    rpod/PlumeStrikeEstimationStudy.py -> JetFiringHistory, TargetVehicle
    util/io/file_print.py         -> numpy, NDArray

In file_print the _Times/_Rows aliases move out of the guard with the import;
they evaluate fine at runtime (verified), so the module no longer depends on
`from __future__ import annotations` to resolve them.

Two blocks are kept because removing them raises ImportError, and each now
carries a comment saying so, with the failure mode spelled out:

* mission/flight_eval.py -> MissionPlanner. MissionPlanner imports
  FlightEvaluator at module level; a real import fails in both directions on
  a partially initialized module.
* mdao/SweepConfig.py -> VisitingVehicle / LogisticsModule. VisitingVehicle
  imports this module at module level, so importing
  pyrpod.vehicle.VisitingVehicle first -- the normal order -- fails with
  "cannot import name 'VisitingVehicle' from partially initialized module".
  LogisticsModule subclasses VisitingVehicle and closes the same loop.

Note this changes runtime import edges, not typing: TYPE_CHECKING is False at
runtime and True for mypy, so the guard never affected what was checked. The
trade is uniformity in exchange for mission/mdao/rpod eagerly importing the
vehicle package, and file_print/post_processing eagerly importing numpy and
pandas, which they do no work with at runtime.

Verification beyond the usual suite, since this commit's whole risk is import
order: each of the 33 importable modules under pyrpod/ was imported FIRST in
a fresh interpreter -- all 33 clean.

mypy: clean on all 35 files. Full pytest: 192 passed, 12 skipped. Annotation
coverage unchanged at 331/331. No new flake8 findings (960, vs 1008 on
master); black 199 files failing as on master with no newly-failing file;
isort 138 vs 140 with none newly failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No `if TYPE_CHECKING:` block remains anywhere under pyrpod/. The previous
commit converted the six that were only avoiding a runtime import edge; the
two left were real import cycles, and this removes those too.

mdao/SweepConfig.py -> plain imports of LogisticsModule and ThrusterConfig.

  The cycle only existed because of a dead import: VisitingVehicle carried
  `from pyrpod.mdao import SweepConfig` at module level while never using the
  name. Verified unused three ways -- no ast.Name or ast.Attribute reference
  anywhere in the module, flake8 reports it as F401, and nothing in the
  repository reaches SweepConfig through pyrpod.vehicle.VisitingVehicle.
  Deleting that one line breaks the loop, so SweepConfig can now import the
  vehicle classes for real, and one pre-existing F401 goes away with it.

mission/flight_eval.py -> guard deleted, FlightEvaluator.execute's parameter
  is now `mission_planner: Any`.

  This cycle is structural rather than accidental: MissionPlanner imports
  FlightEvaluator at module level and constructs one in its __init__, so
  naming MissionPlanner in flight_eval -- with or without an import -- closes
  the loop in both directions. execute() is a `pass` stub, so the precision
  lost is nil today; a comment records why the class name is not used. If the
  method is ever implemented, the fix is to invert that dependency, not to
  reinstate the guard.

Verification, since this commit's whole risk is import order: each of the 33
importable modules under pyrpod/ was imported FIRST in a fresh interpreter --
all 33 clean.

mypy: clean on all 35 files. Full pytest: 192 passed, 12 skipped. Annotation
coverage unchanged at 331/331. flake8 959 findings vs 1008 on master with no
new findings; black 199 files failing exactly as on master with no
newly-failing file; isort 138 vs 140, none newly failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@andytorrestb
andytorrestb merged commit 7642736 into plume-kit:master Jul 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant