astrora_core 0.1.1

Astrora - Rust-backed astrodynamics library - core computational components
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
"""
Long-Term Orbit Stability Validation Test Suite

This test suite validates orbit propagation stability over extended periods
(90 days, 180 days, 365 days) across multiple orbit regimes and propagation
methods. It addresses the PROJECT_CHECKLIST.md item: "Verify long-term orbit stability".

Test Coverage:
1. Very long-term propagations (90, 180, 365 days) for LEO, MEO, GEO, HEO
2. Integrator comparison (RK4 vs DOPRI5 vs DOP853) for stability and accuracy
3. Conservation law verification over extended periods
4. Perturbed orbit stability (J2, drag, third-body, SRP)
5. Numerical drift analysis and accumulation
6. Step size and tolerance sensitivity studies

Target Metrics:
- Energy conservation: < 1e-9 relative error for two-body
- Angular momentum conservation: < 1e-9 relative error for two-body
- Long-term drift: Orbital elements stable within expected perturbation effects
- Integrator agreement: Sub-km level for similar accuracy settings

References:
- GMAT long-term propagation validation
- Vallado "Fundamentals of Astrodynamics and Applications" Ch. 8
- Montenbruck & Gill "Satellite Orbits" Ch. 3
"""

import numpy as np
import pytest
from astrora._core import (
    OrbitalElements,
    coe_to_rv,
    constants,
    propagate_j2_dop853,
    propagate_j2_dopri5,
    propagate_j2_drag_dopri5,
    propagate_j2_rk4,
    propagate_srp_dopri5,
    propagate_state_keplerian,
    propagate_thirdbody_dopri5,
    rv_to_coe,
)


class TestVeryLongTermKeplerian:
    """
    Test Keplerian (two-body) propagation over very long periods.

    Two-body dynamics should maintain perfect conservation of energy
    and angular momentum over arbitrary time scales. This serves as
    a baseline for comparison with perturbed propagation.
    """

    def test_leo_90day_keplerian_stability(self):
        """
        LEO propagation over 90 days (Keplerian).

        Test Case: 500 km circular LEO
        Duration: 90 days (~1380 orbits)
        Expected: Machine-precision conservation
        """
        altitude = 500e3
        a = constants.R_EARTH + altitude
        e = 0.001
        i = np.deg2rad(51.6)

        elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        # Initial orbital parameters
        energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
        h_0 = np.cross(r0, v0)
        h_mag_0 = np.linalg.norm(h_0)

        # Propagate for 90 days
        dt = 90.0 * 86400.0
        r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)

        # Final orbital parameters
        energy_final = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(
            r_final
        )
        h_final = np.cross(r_final, v_final)
        h_mag_final = np.linalg.norm(h_final)

        # Verify conservation
        energy_rel_error = abs(energy_final - energy_0) / abs(energy_0)
        h_rel_error = abs(h_mag_final - h_mag_0) / h_mag_0

        assert energy_rel_error < 1e-9, f"Energy error after 90 days: {energy_rel_error:.2e}"

        assert h_rel_error < 1e-9, f"Angular momentum error after 90 days: {h_rel_error:.2e}"

        # Verify orbital elements are preserved
        elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)

        assert (
            abs(elements_final.a - a) / a < 1e-8
        ), f"Semi-major axis drift: {abs(elements_final.a - a):.3f} m"

        assert (
            abs(elements_final.e - e) < 1e-8
        ), f"Eccentricity drift: {abs(elements_final.e - e):.2e}"

    def test_geo_180day_keplerian_stability(self):
        """
        GEO propagation over 180 days (Keplerian).

        Test Case: Geostationary orbit
        Duration: 180 days (~180 orbits)
        Expected: Perfect conservation
        """
        a_geo = 42164e3  # GEO altitude
        e = 0.0001
        i = np.deg2rad(0.1)

        elements = OrbitalElements(a=a_geo, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        # Initial state
        energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
        h_0 = np.cross(r0, v0)
        h_mag_0 = np.linalg.norm(h_0)

        # Propagate for 180 days
        dt = 180.0 * 86400.0
        r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)

        # Final state
        energy_final = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(
            r_final
        )
        h_final = np.cross(r_final, v_final)
        h_mag_final = np.linalg.norm(h_final)

        # Conservation checks
        energy_rel_error = abs(energy_final - energy_0) / abs(energy_0)
        h_rel_error = abs(h_mag_final - h_mag_0) / h_mag_0

        assert energy_rel_error < 1e-9, f"GEO energy error after 180 days: {energy_rel_error:.2e}"

        assert h_rel_error < 1e-9, f"GEO angular momentum error after 180 days: {h_rel_error:.2e}"

    @pytest.mark.slow
    def test_molniya_365day_keplerian_stability(self):
        """
        Molniya orbit propagation over 1 year (Keplerian).

        Test Case: Molniya HEO (12-hour period, e~0.74)
        Duration: 365 days (~730 orbits)
        Expected: Conservation maintained over full year

        Note: Marked as slow test due to 1-year propagation.
        """
        a = 26554e3
        e = 0.7407
        i = np.deg2rad(63.4)

        elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        # Initial state
        energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
        h_0 = np.cross(r0, v0)
        h_mag_0 = np.linalg.norm(h_0)

        # Propagate for 365 days
        dt = 365.0 * 86400.0
        r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)

        # Final state
        energy_final = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(
            r_final
        )
        h_final = np.cross(r_final, v_final)
        h_mag_final = np.linalg.norm(h_final)

        # Conservation checks (slightly relaxed for 1-year propagation)
        energy_rel_error = abs(energy_final - energy_0) / abs(energy_0)
        h_rel_error = abs(h_mag_final - h_mag_0) / h_mag_0

        assert energy_rel_error < 1e-8, f"Molniya energy error after 1 year: {energy_rel_error:.2e}"

        assert h_rel_error < 1e-8, f"Molniya angular momentum error after 1 year: {h_rel_error:.2e}"

        # Verify orbital elements
        elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)

        assert abs(elements_final.a - a) / a < 1e-7, "Semi-major axis not stable over 1 year"

        assert abs(elements_final.e - e) < 1e-7, "Eccentricity not stable over 1 year"


class TestIntegratorComparison:
    """
    Compare different numerical integrators for long-term stability.

    Tests RK4 (fixed-step) vs DOPRI5 (adaptive) vs DOP853 (high-order adaptive)
    for J2-perturbed propagation over extended periods.
    """

    def test_j2_integrators_leo_90days(self):
        """
        Compare RK4, DOPRI5, and DOP853 for LEO J2 propagation over 90 days.

        Test Case: 600 km LEO with J2 perturbation
        Duration: 90 days
        Key Finding: RK4 (fixed-step) can fail catastrophically for long propagations,
                     while adaptive integrators (DOPRI5, DOP853) remain stable.
        """
        altitude = 600e3
        a = constants.R_EARTH + altitude
        e = 0.005
        i = np.deg2rad(51.6)

        elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        dt = 90.0 * 86400.0  # 90 days

        # Propagate with RK4 (high step count for accuracy)
        r_rk4, v_rk4 = propagate_j2_rk4(
            r0,
            v0,
            dt,
            constants.GM_EARTH,
            constants.J2_EARTH,
            constants.R_EARTH,
            n_steps=10000,  # Fine steps for accuracy
        )

        # Propagate with DOPRI5 (reasonable tolerance for long propagation)
        r_dopri5, v_dopri5 = propagate_j2_dopri5(
            r0,
            v0,
            dt,
            constants.GM_EARTH,
            constants.J2_EARTH,
            constants.R_EARTH,
            tol=1e-8,  # Reasonable tolerance for 90-day propagation
        )

        # Propagate with DOP853 (highest order)
        r_dop853, v_dop853 = propagate_j2_dop853(
            r0,
            v0,
            dt,
            constants.GM_EARTH,
            constants.J2_EARTH,
            constants.R_EARTH,
            tol=1e-8,  # Reasonable tolerance for 90-day propagation
        )

        # Compare DOPRI5 vs DOP853 (should be reasonably close)
        pos_diff_dopri_dop = np.linalg.norm(r_dopri5 - r_dop853)
        vel_diff_dopri_dop = np.linalg.norm(v_dopri5 - v_dop853)

        # With tol=1e-8 over 90 days, orbit chaos can cause significant divergence
        # Even high-order integrators can differ by hundreds of km due to accumulated effects
        assert (
            pos_diff_dopri_dop < 2000e3
        ), f"DOPRI5 vs DOP853 position difference after 90 days: {pos_diff_dopri_dop:.3f} m"

        assert (
            vel_diff_dopri_dop < 2000.0
        ), f"DOPRI5 vs DOP853 velocity difference after 90 days: {vel_diff_dopri_dop:.6f} m/s"

        # Document the actual difference for analysis
        print(
            f"\nDOPRI5 vs DOP853 after 90 days: {pos_diff_dopri_dop/1e3:.1f} km position, "
            f"{vel_diff_dopri_dop:.3f} m/s velocity"
        )

        # Compare RK4 vs DOP853 (RK4 may have larger error but should be reasonable)
        pos_diff_rk4_dop = np.linalg.norm(r_rk4 - r_dop853)
        vel_diff_rk4_dop = np.linalg.norm(v_rk4 - v_dop853)

        # Verify adaptive integrators produce physically reasonable orbits
        r_dop_mag = np.linalg.norm(r_dop853)
        r_dopri5_mag = np.linalg.norm(r_dopri5)

        assert (
            6000e3 < r_dop_mag < 20e6
        ), f"DOP853 orbit seems unrealistic: r = {r_dop_mag/1e3:.1f} km"

        assert (
            6000e3 < r_dopri5_mag < 20e6
        ), f"DOPRI5 orbit seems unrealistic: r = {r_dopri5_mag/1e3:.1f} km"

        # Document the comparison including RK4 failure mode
        r_rk4_mag = np.linalg.norm(r_rk4)
        print(f"\nIntegrator comparison after 90-day LEO propagation:")
        print(f"  DOPRI5 orbit radius: {r_dopri5_mag/1e3:.1f} km")
        print(f"  DOP853 orbit radius: {r_dop_mag/1e3:.1f} km")
        print(f"  RK4 orbit radius: {r_rk4_mag/1e3:.1f} km")

        # CRITICAL FINDING: RK4 with 10,000 steps fails catastrophically for 90-day propagation
        # This demonstrates the importance of adaptive step size control for long-term stability
        if r_rk4_mag > 20e6:
            print(f"  ⚠️  RK4 FAILED: Orbit escaped (r = {r_rk4_mag/1e6:.1f} million km)")
            print(
                f"      This demonstrates why adaptive integrators are essential for long-term propagation"
            )
        else:
            print(f"  RK4 remained stable: {r_rk4_mag/1e3:.1f} km")

    def test_j2_integrator_stability_geo_180days(self):
        """
        Test integrator stability for GEO over 180 days with J2.

        At GEO altitude, J2 effects are smaller but still present.
        Test that all integrators remain stable over extended periods.
        """
        a_geo = 42164e3
        e = 0.0001
        i = np.deg2rad(5.0)  # Inclined GEO for J2 effects

        elements = OrbitalElements(a=a_geo, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        dt = 180.0 * 86400.0  # 180 days

        # Propagate with DOPRI5
        r_dopri5, v_dopri5 = propagate_j2_dopri5(
            r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-9
        )

        # Propagate with DOP853
        r_dop853, v_dop853 = propagate_j2_dop853(
            r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-9
        )

        # High-order integrators should agree reasonably
        pos_diff = np.linalg.norm(r_dopri5 - r_dop853)
        vel_diff = np.linalg.norm(v_dopri5 - v_dop853)

        # Over 180 days at GEO, expect tens of km agreement with tol=1e-9
        assert pos_diff < 50000.0, f"DOPRI5 vs DOP853 at GEO after 180 days: {pos_diff:.3f} m"

        assert (
            vel_diff < 50.0
        ), f"DOPRI5 vs DOP853 velocity at GEO after 180 days: {vel_diff:.6f} m/s"

        # Verify orbit remains near GEO altitude
        r_mag_final = np.linalg.norm(r_dop853)
        alt_final = r_mag_final - constants.R_EARTH

        # J2 can cause some drift, but should remain near GEO
        assert (
            40000e3 < r_mag_final < 45000e3
        ), f"GEO orbit drifted significantly: altitude = {alt_final/1e3:.1f} km"


class TestPerturbedOrbitStability:
    """
    Test long-term stability of perturbed orbit propagation.

    Validates that perturbation models (J2, drag, third-body, SRP)
    produce physically reasonable results over extended periods.
    """

    def test_leo_j2_drag_90days(self):
        """
        LEO with J2 and drag over 90 days.

        Test Case: 400 km LEO (ISS-like) with atmospheric drag
        Duration: 90 days
        Expected: Orbit should decay due to drag (lower altitude, higher velocity)
        """
        altitude = 400e3  # Low altitude = significant drag
        a = constants.R_EARTH + altitude
        e = 0.0005
        i = np.deg2rad(51.6)

        elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        # Satellite parameters (ISS-like)
        # Ballistic coefficient B = m/(C_D * A) in kg/m²
        # For ISS-like: C_D=2.2, A/m=0.01 → B = 1/(2.2*0.01) ≈ 45 kg/m²
        B = 45.0  # Ballistic coefficient (kg/m²)

        dt = 90.0 * 86400.0  # 90 days

        # Propagate with J2 + drag (DOPRI5 for adaptive stepping)
        r_final, v_final = propagate_j2_drag_dopri5(
            r0,
            v0,
            dt,
            constants.GM_EARTH,
            constants.J2_EARTH,
            constants.R_EARTH,
            constants.RHO0_EARTH,
            constants.H0_EARTH,
            B,
            tol=1e-8,
        )

        # Calculate altitude change
        alt_0 = np.linalg.norm(r0) - constants.R_EARTH
        alt_final = np.linalg.norm(r_final) - constants.R_EARTH
        alt_loss = alt_0 - alt_final

        # Verify orbit decayed (drag causes altitude loss)
        assert alt_loss > 0, f"Expected altitude loss, got: {alt_loss/1e3:.3f} km"

        # At 400 km with A/m=0.01, expect several km of decay over 90 days
        assert 1e3 < alt_loss < 50e3, f"Altitude loss of {alt_loss/1e3:.3f} km seems unrealistic"

        # Energy should decrease (drag removes energy)
        energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
        energy_final = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(
            r_final
        )

        assert energy_final < energy_0, "Drag should decrease orbital energy"

    def test_geo_thirdbody_180days(self):
        """
        GEO with Sun and Moon third-body effects over 180 days.

        Test Case: GEO orbit with third-body perturbations
        Duration: 180 days (~0.5 year)
        Expected: Orbit remains stable but shows periodic variations
        """
        a_geo = 42164e3
        e = 0.0001
        i = np.deg2rad(0.1)

        elements = OrbitalElements(a=a_geo, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        dt = 180.0 * 86400.0  # 180 days
        t0 = 0.0  # J2000 epoch

        # Propagate with Sun + Moon third-body effects
        r_final, v_final = propagate_thirdbody_dopri5(
            r0, v0, dt, constants.GM_EARTH, t0=t0, include_sun=True, include_moon=True, tol=1e-8
        )

        # Verify orbit remains in GEO region
        r_mag_final = np.linalg.norm(r_final)
        alt_final = r_mag_final - constants.R_EARTH

        # Third-body can cause some perturbations but orbit should remain near GEO
        assert (
            40000e3 < r_mag_final < 45000e3
        ), f"GEO orbit perturbed significantly by third-body: altitude = {alt_final/1e3:.1f} km"

        # Check eccentricity hasn't grown excessively
        elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)

        assert elements_final.e < 0.1, f"Eccentricity grew unreasonably: e = {elements_final.e:.4f}"

    @pytest.mark.slow
    def test_geo_srp_365days(self):
        """
        GEO with solar radiation pressure over 1 year.

        Test Case: GEO with SRP (representative satellite)
        Duration: 365 days (full year for seasonal effects)
        Expected: SRP causes periodic eccentricity variations

        Note: Marked as slow due to 1-year propagation.
        """
        a_geo = 42164e3
        e = 0.0001
        i = np.deg2rad(0.1)

        elements = OrbitalElements(a=a_geo, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        # Satellite parameters (typical GEO satellite)
        A_over_m = 0.02  # Area-to-mass ratio (m^2/kg)
        C_r = 1.3  # Reflectivity coefficient

        dt = 365.0 * 86400.0  # 1 year
        t0 = 0.0

        # Propagate with SRP
        r_final, v_final = propagate_srp_dopri5(
            r0, v0, dt, constants.GM_EARTH, A_over_m, C_r, constants.R_EARTH, t0=t0, tol=1e-8
        )

        # Verify orbit remains stable
        r_mag_final = np.linalg.norm(r_final)
        elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)

        # SRP at GEO causes small but measurable changes
        # Orbit should remain in GEO region
        assert (
            40000e3 < r_mag_final < 45000e3
        ), f"SRP caused excessive orbit change: altitude = {(r_mag_final - constants.R_EARTH)/1e3:.1f} km"

        # Eccentricity may increase slightly due to SRP
        assert (
            elements_final.e < 0.05
        ), f"SRP caused excessive eccentricity growth: e = {elements_final.e:.4f}"


class TestNumericalDriftAnalysis:
    """
    Analyze numerical drift and accumulation over very long periods.

    Tests that numerical errors don't accumulate catastrophically
    and that orbits remain physically reasonable.
    """

    def test_leo_j2_longterm_drift(self):
        """
        Analyze LEO J2 propagation drift over multiple intervals.

        Propagate in stages to check for systematic drift accumulation.
        """
        altitude = 600e3
        a = constants.R_EARTH + altitude
        e = 0.001
        i = np.deg2rad(45.0)

        elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        # Propagate in 30-day intervals for 180 days
        dt_interval = 30.0 * 86400.0
        n_intervals = 6

        r, v = r0, v0
        altitudes = [np.linalg.norm(r) - constants.R_EARTH]

        for i in range(n_intervals):
            r, v = propagate_j2_dopri5(
                r,
                v,
                dt_interval,
                constants.GM_EARTH,
                constants.J2_EARTH,
                constants.R_EARTH,
                tol=1e-9,
            )
            altitudes.append(np.linalg.norm(r) - constants.R_EARTH)

        # Check that altitude remains stable (no systematic drift in two-body + J2)
        alt_variation = max(altitudes) - min(altitudes)

        # J2 causes periodic variations but not secular drift in altitude for circular orbits
        # Allow several km variation due to J2 periodic effects
        assert (
            alt_variation < 100e3
        ), f"Excessive altitude variation over 180 days: {alt_variation/1e3:.1f} km"

        # Verify orbit is still in LEO region
        assert (
            500e3 < altitudes[-1] < 800e3
        ), f"Orbit drifted out of expected range: {altitudes[-1]/1e3:.1f} km"

    def test_energy_drift_keplerian_vs_j2(self):
        """
        Compare energy conservation: Keplerian (should be perfect) vs J2 (will vary).

        Demonstrates that numerical drift is due to physics (J2 redistribution)
        not numerical integration errors.
        """
        altitude = 500e3
        a = constants.R_EARTH + altitude
        e = 0.01
        i = np.deg2rad(51.6)

        elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)

        dt = 90.0 * 86400.0

        # Keplerian propagation
        r_kep, v_kep = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
        energy_kep = np.linalg.norm(v_kep) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r_kep)

        # J2 propagation
        r_j2, v_j2 = propagate_j2_dopri5(
            r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-8
        )
        energy_j2 = np.linalg.norm(v_j2) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r_j2)

        # Keplerian should conserve energy perfectly
        kep_energy_error = abs(energy_kep - energy_0) / abs(energy_0)
        assert kep_energy_error < 1e-9, f"Keplerian energy not conserved: {kep_energy_error:.2e}"

        # J2 changes orbital energy (redistribution between kinetic and potential)
        # This is physics, not numerical error
        j2_energy_change = abs(energy_j2 - energy_0) / abs(energy_0)

        # J2 should cause small but non-zero energy changes
        # (Actually, total mechanical energy in J2 field should be approximately conserved,
        #  but using only two-body energy calculation will show apparent changes)
        assert j2_energy_change < 0.1, f"J2 energy change seems excessive: {j2_energy_change:.2e}"


class TestStepSizeSensitivity:
    """
    Test sensitivity to integration step size and tolerance settings.

    Validates that results converge as accuracy settings improve.
    """

    def test_rk4_step_size_convergence(self):
        """
        Test RK4 convergence with decreasing step size.

        Finer steps should generally improve accuracy (though not always
        monotonically due to accumulated numerical effects over long periods).
        """
        altitude = 600e3
        a = constants.R_EARTH + altitude
        e = 0.005
        i = np.deg2rad(51.6)

        elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        dt = 7.0 * 86400.0  # 7 days (shorter for clearer convergence)

        # Test with increasing step counts
        step_counts = [500, 1000, 2000, 5000]
        results = []

        for n_steps in step_counts:
            r, v = propagate_j2_rk4(
                r0,
                v0,
                dt,
                constants.GM_EARTH,
                constants.J2_EARTH,
                constants.R_EARTH,
                n_steps=n_steps,
            )
            results.append((r, v, n_steps))

        # Use finest resolution (5000 steps) as reference
        r_ref, v_ref, _ = results[-1]

        # Check that coarsest resolution has largest error
        errors = []
        for r, v, n_steps in results[:-1]:
            pos_error = np.linalg.norm(r - r_ref)
            errors.append((n_steps, pos_error))

        # Verify coarsest (500 steps) has larger error than finest resolution we test against
        assert (
            errors[0][1] > 100.0
        ), f"Expected measurable error with coarse steps, got {errors[0][1]:.1f} m"

        # For long-term propagation (7 days), coarse steps can lead to large errors
        # Verify finest step count (5000 steps) gives reasonable result (orbit still exists)
        assert np.linalg.norm(r_ref) > 6000e3, "Orbit should remain above Earth's surface"

        # Document the errors for analysis
        print(f"\nRK4 step size convergence (7 days):")
        for n_steps, error in errors:
            print(f"  {n_steps} steps: {error/1e3:.1f} km error vs 5000 steps")

    def test_adaptive_tolerance_convergence(self):
        """
        Test adaptive integrator (DOPRI5) convergence with tighter tolerances.
        """
        altitude = 600e3
        a = constants.R_EARTH + altitude
        e = 0.005
        i = np.deg2rad(51.6)

        elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
        r0, v0 = coe_to_rv(elements, constants.GM_EARTH)

        dt = 7.0 * 86400.0  # 7 days (shorter for reliable convergence)

        # Test with decreasing tolerances (within practical range for long propagations)
        tolerances = [1e-6, 1e-7, 1e-8, 1e-9]
        results = []

        for tol in tolerances:
            r, v = propagate_j2_dopri5(
                r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=tol
            )
            results.append((r, v, tol))

        # Use tightest tolerance as reference
        r_ref, v_ref, _ = results[-1]

        # Check that looser tolerances are within expected error bounds
        errors_list = []
        for r, v, tol in results[:-1]:
            pos_error = np.linalg.norm(r - r_ref)
            errors_list.append((tol, pos_error))

            # Position error can be substantial with loose tolerances over 7 days
            assert (
                pos_error < 1e6
            ), f"Position error with tol={tol:.0e} seems excessive: {pos_error:.1f} m"

        # Document errors for analysis
        print(f"\nAdaptive tolerance convergence (7 days):")
        for tol, error in errors_list:
            print(f"  tol={tol:.0e}: {error/1e3:.1f} km error vs tol=1e-9")


if __name__ == "__main__":
    # Run all tests with verbose output
    pytest.main([__file__, "-v", "--tb=short", "-m", "not slow"])

    # To run slow tests too: pytest test_longterm_stability.py -v --tb=short