Skip to content

UPDATE 2.1 - #222

Merged
nscheuer merged 21 commits into
mainfrom
2_1/update
Oct 21, 2026
Merged

nscheuer merged 21 commits into
mainfrom
2_1/update

Conversation

* fix(estimators): make the positional step() and update() overloads fail loudly

With seven controls and a seven-row measurement stack the legacy
step(control, measurements) call was dispatched as correct(measurements=
control, orbital_state=measurements) and ran silently, leaving the control
values in the posterior rate. The two-argument form now requires an orbital
state (duck-typed on J2000). update(sensors=, os=) without a control vector
used to be accepted on the first call and fail on the second; reject it up
front.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(estimators): warn when dt disagrees with the orbital-state time gap

A filter built with one dt and driven by a simulation running at another
integrates the wrong interval and nothing notices: a 10x mismatch took a
16 degree mean attitude error to 47. predict() now compares its step with
the J2000 gap between the two orbital states and warns beyond 10%; identical
orbital states (the static-orbit test pattern) carry no time and are ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(sensors): size scalar error models to the sensor's output length

A scalar Noise or Bias on a vector sensor (EarthHorizonSensor(),
StarTracker(), StarTrackerQuaternion(noise=Noise(std_noise=1e-3)), any
default-constructed vector sensor) produced a 1x1 measurement-noise or
bias-PSD block that the measurement stack assembled unchecked; the filters
then failed at correct() with a covariance-shape message naming no sensor.
Broadcast scalars per axis in Sensor.__init__ and reject any other size
mismatch there. Subclassed error models with their own parameterisation
(AnisotropicNoise) are left untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(satellite): reject quaternion star-tracker bias estimation at assembly

The rejection lived behind the measurement stack's cached property, so an
EstimatedSatellite happily registered a 4-wide sensor-bias block first and the
augmented filters raised a misleading process-noise size error at predict().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(controller): store est_sat on the base class

The remote controller service dereferences controller.est_sat; only two of
fifteen controllers set it, so the other thirteen failed remotely with
AttributeError inside an XML-RPC fault.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(estimators): DeprecationWarning from the UAKF/SRUAKF alias stubs

The stubs silently alias the new UKF/SRUKF, whose constructor differs
entirely from the legacy est_sat/J2000/x_hat/P_hat/Q_hat/cross_term/
quat_as_vec interface. Every legacy keyword call raises a clear TypeError,
but a two-positional call constructs the new class silently and nothing said
the names had moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(estimators): pin the guard rails (calls, dt gap, error-model sizing, tracker bias, controller, stubs)

Also imports warnings in attitude_estimator.py for the dt-gap check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* estimator speedups, 1.4x for EKF/MEKF, 6x for UKF/SRUKF

* added development flag to pyproject

* updated pyproject to 0.2.1.dev0

---------

Co-authored-by: t <p@x>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Benchmark Report

Benchmark multiplication factors

Tests Outside Green Range

status category test multiplier
1.28x sensors gyro_clean_reading 1.28x
0.09x goals environment_vector_goal_group 0.09x
0.11x orbital_state propagate_orbit_rk4_full_state 0.11x
0.12x orbit get_os_interpolated 0.12x
0.12x goals eci_goal_error 0.12x
0.13x orbit new_orbit_from_times_small 0.13x
0.21x orbit construct_propagated_orbit_small 0.21x
0.64x goals coordinate_goal_error 0.64x
0.64x controllers mtq_lovera_find_u 0.64x
0.69x sensors star_tracker_reading 0.69x
0.73x controllers mtq_wisniewski_find_u 0.73x
0.76x sensors earth_horizon_reading 0.76x
0.79x controllers mtq_w_rw_find_u 0.79x

Color bands: green 0.8x-1.2x, yellow 0.5x-0.8x or 1.2x-1.5x, red outside that range.

@nscheuer
nscheuer marked this pull request as draft October 1, 2026 07:47
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Test Results

1 561 tests  +6   1 559 ✅ +6   2m 24s ⏱️ -43s
    1 suites ±0       2 💤 ±0 
    1 files   ±0       0 ❌ ±0 

Results for commit cbaf445. ± Comparison against base commit ca4b28d.

♻️ This comment has been updated with latest results.

nscheuer and others added 15 commits October 1, 2026 10:04
* first attempt at test coverage ci

* ultra fast orbit tests

* removed coverage test which tooks ages to run

* tightened orbit sensitivities

* reorganized test folder structure, tests not run yet! (#214)

* test: place new tests in reorganized test directories
* v1 IGRF choice [needs review]

* added theta=0 check and corresponding test

* added loading message for magnetic field model

* added IGRF coefficients citation in the documentation

* fix test case

---------

Co-authored-by: Niclas Scheuer <niclas_scheuer@web.de>
…tion (#217)

* feat(estimators): TRIAD, QUEST and the q-method for single-frame attitude determination

Pure functions on unit-vector pairs (body direction, inertial reference,
angular standard deviation per observation), in the package's quaternion
convention (scalar first, rot_mat maps body to inertial):

- triad(): the two-direction construction, with a linearised covariance
  when the two standard deviations are given.
- quest(): Shuster's QUEST (Newton on the characteristic polynomial, Gibbs
  vector), solved in four sequential-rotation frames with the best
  conditioned one kept, so 180 degree attitudes are handled.
- q_method(): Davenport's exact 4x4 eigen-solution, used as the oracle.
- wahba_covariance(): the small-angle body-frame covariance of the optimal
  solution, in rad^2; raises when the directions leave a rotation
  unobservable.
- quaternion_from_rotation_matrix(): Shepperd's method (the package had no
  matrix-to-quaternion helper).

Tests: noise-free exactness, QUEST equals the q-method on noisy data, QUEST
never worse than TRIAD, 180 degree cases, Monte-Carlo consistency of both
covariances, degenerate inputs, and weighting.


* fix(estimators): polish QUEST and the q-method with Gauss-Newton steps

The closed-form solutions lose accuracy when one direction is weighted far
more heavily than the others: with weights 1e6 apart QUEST was off by up to
1e-4 rad on exact data, with 1e10 apart by whole radians, and the q-method's
eigenvector degrades the same way. A few Gauss-Newton steps on the Wahba
cost (solved by orthogonal factorisation, where only the square root of the
weight ratio enters) recover the optimum to machine precision. Both solvers
take refine_steps (default 8, 0 for the textbook algorithm) and report the
loss of the attitude they return. The covariance's observability check now
fires only below the numerical resolution of the information matrix.


---------
…ff a raw measurement vector (#218)

* feat(estimators): TRIAD, QUEST and the q-method for single-frame attitude determination

Pure functions on unit-vector pairs (body direction, inertial reference,
angular standard deviation per observation), in the package's quaternion
convention (scalar first, rot_mat maps body to inertial):

- triad(): the two-direction construction, with a linearised covariance
  when the two standard deviations are given.
- quest(): Shuster's QUEST (Newton on the characteristic polynomial, Gibbs
  vector), solved in four sequential-rotation frames with the best
  conditioned one kept, so 180 degree attitudes are handled.
- q_method(): Davenport's exact 4x4 eigen-solution, used as the oracle.
- wahba_covariance(): the small-angle body-frame covariance of the optimal
  solution, in rad^2; raises when the directions leave a rotation
  unobservable.
- quaternion_from_rotation_matrix(): Shepperd's method (the package had no
  matrix-to-quaternion helper).

Tests: noise-free exactness, QUEST equals the q-method on noisy data, QUEST
never worse than TRIAD, 180 degree cases, Monte-Carlo consistency of both
covariances, degenerate inputs, and weighting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(estimators): polish QUEST and the q-method with Gauss-Newton steps

The closed-form solutions lose accuracy when one direction is weighted far
more heavily than the others: with weights 1e6 apart QUEST was off by up to
1e-4 rad on exact data, with 1e10 apart by whole radians, and the q-method's
eigenvector degrades the same way. A few Gauss-Newton steps on the Wahba
cost (solved by orthogonal factorisation, where only the square root of the
weight ratio enters) recover the optimum to machine precision. Both solvers
take refine_steps (default 8, 0 for the textbook algorithm) and report the
loss of the attitude they return. The covariance's observability check now
fires only below the numerical resolution of the information matrix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(sensors, estimators): direction observations from the sensor readings

Every attitude sensor now says what it observes (observation_kind: the
magnetic field, the sun, nadir, a star, the attitude, or the angular rate)
and can give the inertial direction of that quantity for an orbital state
(reference_direction). ADCS.estimators.initialization turns one raw
measurement vector into body/inertial direction pairs with angular standard
deviations (vector_observations), combining single-axis sensors of one kind
by weighted least squares and taking vector sensors as they are; it also
reads the angular rate off the gyros (angular_rate_from_readings) and the
wheel momenta off the wheels (wheel_momentum_from_readings). These are the
inputs the QUEST/TRIAD warm start needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* added additional checks for the initialization

---------

Co-authored-by: Niclas Scheuer <niclas_scheuer@web.de>
…start) (#219)

* feat(estimators): TRIAD, QUEST and the q-method for single-frame attitude determination

Pure functions on unit-vector pairs (body direction, inertial reference,
angular standard deviation per observation), in the package's quaternion
convention (scalar first, rot_mat maps body to inertial):

- triad(): the two-direction construction, with a linearised covariance
  when the two standard deviations are given.
- quest(): Shuster's QUEST (Newton on the characteristic polynomial, Gibbs
  vector), solved in four sequential-rotation frames with the best
  conditioned one kept, so 180 degree attitudes are handled.
- q_method(): Davenport's exact 4x4 eigen-solution, used as the oracle.
- wahba_covariance(): the small-angle body-frame covariance of the optimal
  solution, in rad^2; raises when the directions leave a rotation
  unobservable.
- quaternion_from_rotation_matrix(): Shepperd's method (the package had no
  matrix-to-quaternion helper).

Tests: noise-free exactness, QUEST equals the q-method on noisy data, QUEST
never worse than TRIAD, 180 degree cases, Monte-Carlo consistency of both
covariances, degenerate inputs, and weighting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(estimators): polish QUEST and the q-method with Gauss-Newton steps

The closed-form solutions lose accuracy when one direction is weighted far
more heavily than the others: with weights 1e6 apart QUEST was off by up to
1e-4 rad on exact data, with 1e10 apart by whole radians, and the q-method's
eigenvector degrades the same way. A few Gauss-Newton steps on the Wahba
cost (solved by orthogonal factorisation, where only the square root of the
weight ratio enters) recover the optimum to machine precision. Both solvers
take refine_steps (default 8, 0 for the textbook algorithm) and report the
loss of the attitude they return. The covariance's observability check now
fires only below the numerical resolution of the information matrix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(sensors, estimators): direction observations from the sensor readings

Every attitude sensor now says what it observes (observation_kind: the
magnetic field, the sun, nadir, a star, the attitude, or the angular rate)
and can give the inertial direction of that quantity for an orbital state
(reference_direction). ADCS.estimators.initialization turns one raw
measurement vector into body/inertial direction pairs with angular standard
deviations (vector_observations), combining single-axis sensors of one kind
by weighted least squares and taking vector sensors as they are; it also
reads the angular rate off the gyros (angular_rate_from_readings) and the
wheel momenta off the wheels (wheel_momentum_from_readings). These are the
inputs the QUEST/TRIAD warm start needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(estimators): start a filter from its first readings (QUEST/TRIAD warm start)

Any attitude estimator accepts a WarmStart recipe in place of an initial
state and builds its state from the first measurement vector it receives:
the attitude from a quaternion star tracker or QUEST/TRIAD over the
direction observations, the angular rate from the gyros with zero bias,
the wheel momenta from the wheel readings, biases and disturbance
parameters at zero or the given values, and a block-diagonal covariance
from the sensors' noise and the given standard deviations, mapped into the
filter's attitude chart (or projected onto the full quaternion for the
EKF). That first cycle's correction is skipped because the readings are
consumed; predict(), state and update() refuse to run before it. The same
state can be built directly with initial_state_from_readings, and
Filter.from_readings constructs and initialises in one call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(estimators): compare the warm-started run against an analytic truth

The simulate() comparison now uses a spherical, torque-free satellite whose
attitude at every recorded time is known in closed form, so the test no
longer depends on how simulate() aligns its recorded histories.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: t <p@x>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Nic <102034908+nscheuer@users.noreply.github.com>
…of the record (#220)

simulate() and simulate_mc() recorded the real state after the step's
propagation while the time, orbital state, readings, estimate and control
of the same record came from the start of the step, so state_hist[k] was
the state at time_s[k] + dt, one step ahead of the estimate it is plotted
against, and state_hist[0] was never the initial state. Both drivers now
record the state the step's readings, estimate and control refer to. The
last recorded state is therefore the one at tf - dt, like every other
recorded quantity.
Nothing called a disturbance's update(), and update() only re-applied a
noise sample nothing ever redrew, so a dipole or propulsion disturbance
with a noise model stayed at its nominal value, and parameter_std_rate
only told the estimator to expect a random walk the true parameter never
made. Disturbance.update_errors(j2000) now walks the estimated parameters
at that rate and draws the step's noise sample; Satellite.update_errors
runs it with the actuator update, once per step before the integrator, in
both simulate() and simulate_mc(). The solver still sees one frozen
realisation per step.
* docs(estimators): explain the single-frame attitude methods and cite their sources

The attitude_determination module docstring now says what a direction
observation is, what Wahba's problem asks, how TRIAD, the q-method and
QUEST each solve it and when to use which, why a single-frame solution is
worth having next to the recursive filters, and lists the original papers.
The citations page gets the same sources.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(tutorials): say why a warm start helps before showing how
* disturbance speed improvements

* update benchmark timings
* disturbance speed improvements

* update benchmark timings

* first pass on centralizing math
@nscheuer
nscheuer marked this pull request as ready for review October 19, 2026 07:58

@patrickmckeen patrickmckeen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy with this. Claude had some points on the release notes:

Release note gaps worth fixing, in order

  • Everything from main since 0.2.0 is in this release and unmentioned: #207 (UKF drops a sensor some sigma points cannot see instead of aborting), #208 (sigma-point spread kept inside the chart), #209 (GPS noise rotated into ECI; the right sensor's noise used), #211 (RK4 midpoint averaged in update() and simulate()), #212 (guard rails, dt-versus-orbit check, and the removal of ADCS.estimators.old_attitude_estimators, which is a breaking change for anyone importing it).
  • The simulate() history alignment (#220): state_hist[k] is now the real state at time_s[k], one step earlier than before. Anyone post-processing results will see numbers move; that deserves a sentence.
  • ppigrf is now an optional extra, not a core dependency, and igrf_numba is the default field model. Users who import ppigrf directly need pip install generalized-adcs[ppigrf].
  • Smaller: it says "a faster IGRF implementation is available" where it is the default; "orbit tests have been sped up" undersells a 100x on that directory; and the sensor-side observation_kind / reference_direction protocol from #218 is public API worth one line.

@iliketocode2 iliketocode2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just two small 2-3 line changes! This looks so good, can't wait for 0.2.1 to drop!

Comment thread ADCS/satellite_factory/sensors/create_cubesat_sunpair.py
Comment thread ADCS/helpers/math_helpers.py
@nscheuer
nscheuer merged commit 2f4a589 into main Oct 21, 2026
4 checks 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.

3 participants