astrox-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
astrox/propagator.py ADDED
@@ -0,0 +1,1072 @@
1
+ """Orbit propagation functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, TypeAlias
8
+
9
+ from astrox._http import raw
10
+ from astrox.orbits import CartesianState, KeplerianElements
11
+
12
+ __all__ = [
13
+ "HpopAtmosphere",
14
+ "HpopConfig",
15
+ "HpopGravity",
16
+ "HpopIntegrator",
17
+ "HpopJacchiaRoberts",
18
+ "HpopGravityField",
19
+ "HpopRkf78",
20
+ "HpopSrp",
21
+ "HpopSrpSpherical",
22
+ "HpopThirdBody",
23
+ "HpopTwoBodyGravity",
24
+ "PropagatorPosition",
25
+ "ballistic",
26
+ "ballistic_apogee_altitude",
27
+ "ballistic_delta_v",
28
+ "ballistic_delta_v_min_ecc",
29
+ "ballistic_time_of_flight",
30
+ "hpop",
31
+ "hpop_config",
32
+ "hpop_gravity_field",
33
+ "hpop_jacchia_roberts",
34
+ "hpop_rkf78",
35
+ "hpop_srp_spherical",
36
+ "hpop_third_body",
37
+ "hpop_two_body_gravity",
38
+ "j2",
39
+ "multi_j2",
40
+ "multi_sgp4",
41
+ "multi_two_body",
42
+ "sgp4",
43
+ "simple_ascent",
44
+ "two_body",
45
+ ]
46
+
47
+
48
+ @dataclass(frozen=True, kw_only=True)
49
+ class PropagatorPosition:
50
+ """Nested propagated position output."""
51
+
52
+ central_body: str
53
+ epoch: str
54
+ reference_frame: str
55
+ interpolation_algorithm: str
56
+ interpolation_degree: int
57
+ cartesian_velocity: tuple[float, ...]
58
+
59
+ @classmethod
60
+ def from_wire(cls, position_payload: dict[str, Any]) -> PropagatorPosition:
61
+ """Build from the ASTROX nested Position payload."""
62
+ return cls(
63
+ central_body=position_payload["CentralBody"],
64
+ epoch=position_payload["epoch"],
65
+ reference_frame=position_payload["referenceFrame"],
66
+ interpolation_algorithm=position_payload["interpolationAlgorithm"],
67
+ interpolation_degree=position_payload["interpolationDegree"],
68
+ cartesian_velocity=tuple(position_payload["cartesianVelocity"]),
69
+ )
70
+
71
+
72
+ def _success_path(result: dict[str, Any]) -> tuple[float, PropagatorPosition]:
73
+ return result["Period"], PropagatorPosition.from_wire(result["Position"])
74
+
75
+
76
+ def _include_if_supplied(payload: dict[str, Any], wire_key: str, value: Any) -> None:
77
+ if value is not None:
78
+ payload[wire_key] = value
79
+
80
+
81
+ def _sequence_to_list(value: Sequence[str], *, parameter: str) -> list[str]:
82
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
83
+ raise TypeError(f"{parameter} must be a sequence of strings")
84
+ items = list(value)
85
+ if not all(isinstance(item, str) for item in items):
86
+ raise TypeError(f"{parameter} must be a sequence of strings")
87
+ return items
88
+
89
+
90
+ def _hpop_value_to_wire(
91
+ value: Any,
92
+ *,
93
+ expected_type: type | tuple[type, ...],
94
+ parameter: str,
95
+ ) -> dict[str, Any]:
96
+ if not isinstance(value, expected_type):
97
+ raise TypeError(f"{parameter} must be an HPOP config value")
98
+ return value.to_wire()
99
+
100
+
101
+ @dataclass(frozen=True, kw_only=True)
102
+ class HpopRkf78:
103
+ """HPOP RKF7(8) integrator configuration."""
104
+
105
+ name: str | None = None
106
+ description: str | None = None
107
+ user_comment: str | None = None
108
+ use_fixed_step: bool | None = None
109
+ initial_step_s: float | None = None
110
+ max_step_s: float | None = None
111
+ min_step_s: float | None = None
112
+ max_abs_error: float | None = None
113
+ max_rel_error: float | None = None
114
+ max_iterations: int | None = None
115
+
116
+ def to_wire(self) -> dict[str, Any]:
117
+ """Lower to the ASTROX RKF7(8) integrator fragment."""
118
+ payload: dict[str, Any] = {"$type": "RKF7th8th"}
119
+ _include_if_supplied(payload, "Name", self.name)
120
+ _include_if_supplied(payload, "Description", self.description)
121
+ _include_if_supplied(payload, "UserComment", self.user_comment)
122
+ _include_if_supplied(payload, "UseFixedStep", self.use_fixed_step)
123
+ _include_if_supplied(payload, "InitialStep", self.initial_step_s)
124
+ _include_if_supplied(payload, "MaxStep", self.max_step_s)
125
+ _include_if_supplied(payload, "MinStep", self.min_step_s)
126
+ _include_if_supplied(payload, "MaxAbsErr", self.max_abs_error)
127
+ _include_if_supplied(payload, "MaxRelErr", self.max_rel_error)
128
+ _include_if_supplied(payload, "MaxIterations", self.max_iterations)
129
+ return payload
130
+
131
+
132
+ @dataclass(frozen=True, kw_only=True)
133
+ class HpopTwoBodyGravity:
134
+ """HPOP two-body gravity configuration."""
135
+
136
+ name: str | None = None
137
+ description: str | None = None
138
+ user_comment: str | None = None
139
+
140
+ def to_wire(self) -> dict[str, Any]:
141
+ """Lower to the ASTROX two-body gravity fragment."""
142
+ payload: dict[str, Any] = {"$type": "TwoBody"}
143
+ _include_if_supplied(payload, "Name", self.name)
144
+ _include_if_supplied(payload, "Description", self.description)
145
+ _include_if_supplied(payload, "UserComment", self.user_comment)
146
+ return payload
147
+
148
+
149
+ @dataclass(frozen=True, kw_only=True)
150
+ class HpopGravityField:
151
+ """HPOP gravity-field configuration."""
152
+
153
+ gravity_file_name: str
154
+ degree: int
155
+ order: int
156
+ name: str | None = None
157
+ description: str | None = None
158
+ user_comment: str | None = None
159
+ use_secular_variations: bool | None = None
160
+ solid_tide_type: str | None = None
161
+ eop_file_path: str | None = None
162
+
163
+ def to_wire(self) -> dict[str, Any]:
164
+ """Lower to the ASTROX gravity-field fragment."""
165
+ payload: dict[str, Any] = {
166
+ "$type": "GravityField",
167
+ "GravityFileName": self.gravity_file_name,
168
+ "Degree": self.degree,
169
+ "Order": self.order,
170
+ }
171
+ _include_if_supplied(payload, "Name", self.name)
172
+ _include_if_supplied(payload, "Description", self.description)
173
+ _include_if_supplied(payload, "UserComment", self.user_comment)
174
+ _include_if_supplied(payload, "UseSecularVariations", self.use_secular_variations)
175
+ _include_if_supplied(payload, "SolidTideType", self.solid_tide_type)
176
+ _include_if_supplied(payload, "EOPfilePath", self.eop_file_path)
177
+ return payload
178
+
179
+
180
+ @dataclass(frozen=True, kw_only=True)
181
+ class HpopJacchiaRoberts:
182
+ """HPOP Jacchia-Roberts atmosphere configuration."""
183
+
184
+ name: str | None = None
185
+ description: str | None = None
186
+ user_comment: str | None = None
187
+ drag_model_type: str | None = None
188
+ atmos_data_source: str | None = None
189
+ f10p7: float | None = None
190
+ f10p7_avg: float | None = None
191
+ kp: float | None = None
192
+
193
+ def to_wire(self) -> dict[str, Any]:
194
+ """Lower to the ASTROX Jacchia-Roberts atmosphere fragment."""
195
+ payload: dict[str, Any] = {"$type": "JacchiaRoberts"}
196
+ _include_if_supplied(payload, "Name", self.name)
197
+ _include_if_supplied(payload, "Description", self.description)
198
+ _include_if_supplied(payload, "UserComment", self.user_comment)
199
+ _include_if_supplied(payload, "DragModelType", self.drag_model_type)
200
+ _include_if_supplied(payload, "AtmosDataSource", self.atmos_data_source)
201
+ _include_if_supplied(payload, "F10p7", self.f10p7)
202
+ _include_if_supplied(payload, "F10p7Avg", self.f10p7_avg)
203
+ _include_if_supplied(payload, "Kp", self.kp)
204
+ return payload
205
+
206
+
207
+ @dataclass(frozen=True, kw_only=True)
208
+ class HpopSrpSpherical:
209
+ """HPOP spherical solar-radiation-pressure configuration."""
210
+
211
+ name: str | None = None
212
+ description: str | None = None
213
+ user_comment: str | None = None
214
+ shadow_model: str | None = None
215
+ sun_position: str | None = None
216
+ eclipsing_bodies: tuple[str, ...] | None = None
217
+
218
+ def to_wire(self) -> dict[str, Any]:
219
+ """Lower to the ASTROX spherical SRP fragment."""
220
+ payload: dict[str, Any] = {"$type": "SRPSpherical"}
221
+ _include_if_supplied(payload, "Name", self.name)
222
+ _include_if_supplied(payload, "Description", self.description)
223
+ _include_if_supplied(payload, "UserComment", self.user_comment)
224
+ _include_if_supplied(payload, "ShadowModel", self.shadow_model)
225
+ _include_if_supplied(payload, "SunPosition", self.sun_position)
226
+ if self.eclipsing_bodies is not None:
227
+ payload["EclipsingBodies"] = list(self.eclipsing_bodies)
228
+ return payload
229
+
230
+
231
+ @dataclass(frozen=True, kw_only=True)
232
+ class HpopThirdBody:
233
+ """HPOP third-body force configuration."""
234
+
235
+ third_body_name: str
236
+ name: str | None = None
237
+ description: str | None = None
238
+ user_comment: str | None = None
239
+ mode_type: str | None = None
240
+ ephem_source: str | None = None
241
+ grav_source: str | None = None
242
+ mu_m3_s2: float | None = None
243
+
244
+ def to_wire(self) -> dict[str, Any]:
245
+ """Lower to the ASTROX third-body force fragment."""
246
+ payload: dict[str, Any] = {"ThirdBodyName": self.third_body_name}
247
+ _include_if_supplied(payload, "Name", self.name)
248
+ _include_if_supplied(payload, "Description", self.description)
249
+ _include_if_supplied(payload, "UserComment", self.user_comment)
250
+ _include_if_supplied(payload, "ModeType", self.mode_type)
251
+ _include_if_supplied(payload, "EphemSource", self.ephem_source)
252
+ _include_if_supplied(payload, "GravSource", self.grav_source)
253
+ _include_if_supplied(payload, "Mu", self.mu_m3_s2)
254
+ return payload
255
+
256
+
257
+ HpopIntegrator: TypeAlias = HpopRkf78
258
+ HpopGravity: TypeAlias = HpopTwoBodyGravity | HpopGravityField
259
+ HpopAtmosphere: TypeAlias = HpopJacchiaRoberts
260
+ HpopSrp: TypeAlias = HpopSrpSpherical
261
+
262
+
263
+ @dataclass(frozen=True, kw_only=True)
264
+ class HpopConfig:
265
+ """HPOP propagator configuration."""
266
+
267
+ name: str | None = None
268
+ description: str | None = None
269
+ user_comment: str | None = None
270
+ central_body: str | None = None
271
+ integrator: HpopIntegrator | None = None
272
+ gravity: HpopGravity | None = None
273
+ atmosphere: HpopAtmosphere | None = None
274
+ srp: HpopSrp | None = None
275
+ third_bodies: tuple[HpopThirdBody, ...] | None = None
276
+
277
+ def to_wire(self) -> dict[str, Any]:
278
+ """Lower to the ASTROX HPOP propagator fragment."""
279
+ payload: dict[str, Any] = {}
280
+ _include_if_supplied(payload, "Name", self.name)
281
+ _include_if_supplied(payload, "Description", self.description)
282
+ _include_if_supplied(payload, "UserComment", self.user_comment)
283
+ _include_if_supplied(payload, "CentralBodyName", self.central_body)
284
+ if self.integrator is not None:
285
+ payload["NumericalIntegrator"] = self.integrator.to_wire()
286
+ if self.gravity is not None:
287
+ payload["GravityModel"] = self.gravity.to_wire()
288
+ if self.atmosphere is not None:
289
+ payload["AtmosphericModel"] = self.atmosphere.to_wire()
290
+ if self.srp is not None:
291
+ payload["SRPModel"] = self.srp.to_wire()
292
+ if self.third_bodies is not None:
293
+ payload["ThirdBodyForce"] = [
294
+ third_body.to_wire()
295
+ for third_body in self.third_bodies
296
+ ]
297
+ return payload
298
+
299
+
300
+ def _keplerian_from_elements_object(payload: dict[str, Any]) -> KeplerianElements:
301
+ return KeplerianElements(
302
+ semi_major_axis_m=payload["SemimajorAxis"],
303
+ eccentricity=payload["Eccentricity"],
304
+ inclination_deg=payload["Inclination"],
305
+ argument_of_periapsis_deg=payload["ArgumentOfPeriapsis"],
306
+ raan_deg=payload["RightAscensionOfAscendingNode"],
307
+ true_anomaly_deg=payload["TrueAnomaly"],
308
+ )
309
+
310
+
311
+ def _batch_success_path(result: dict[str, Any]) -> tuple[KeplerianElements, ...]:
312
+ return tuple(
313
+ _keplerian_from_elements_object(item)
314
+ for item in result["AllElementsAtEpoch"]
315
+ )
316
+
317
+
318
+ def _state_item_to_wire(
319
+ item: Sequence[object],
320
+ *,
321
+ gravitational_parameter_m3_s2: float | None,
322
+ ) -> dict[str, Any]:
323
+ if not isinstance(item, (list, tuple)) or len(item) != 2:
324
+ raise TypeError("states items must be two-item sequences of orbit epoch and KeplerianElements")
325
+ orbit_epoch, orbit = item
326
+ if not isinstance(orbit_epoch, str):
327
+ raise TypeError("states item orbit epoch must be a string")
328
+ if not isinstance(orbit, KeplerianElements):
329
+ raise TypeError("states item orbit must be a KeplerianElements instance")
330
+
331
+ payload: dict[str, Any] = {
332
+ "OrbitEpoch": orbit_epoch,
333
+ "SemimajorAxis": orbit.semi_major_axis_m,
334
+ "Eccentricity": orbit.eccentricity,
335
+ "Inclination": orbit.inclination_deg,
336
+ "ArgumentOfPeriapsis": orbit.argument_of_periapsis_deg,
337
+ "RightAscensionOfAscendingNode": orbit.raan_deg,
338
+ "TrueAnomaly": orbit.true_anomaly_deg,
339
+ }
340
+ if gravitational_parameter_m3_s2 is not None:
341
+ payload["GravitationalParameter"] = gravitational_parameter_m3_s2
342
+ return payload
343
+
344
+
345
+ def _states_to_wire(
346
+ states: Sequence[Sequence[object]],
347
+ *,
348
+ gravitational_parameter_m3_s2: float | None = None,
349
+ ) -> list[dict[str, Any]]:
350
+ if not isinstance(states, Sequence) or isinstance(states, (str, bytes)):
351
+ raise TypeError("states must be a sequence of two-item state sequences")
352
+ return [
353
+ _state_item_to_wire(
354
+ item,
355
+ gravitational_parameter_m3_s2=gravitational_parameter_m3_s2,
356
+ )
357
+ for item in states
358
+ ]
359
+
360
+
361
+ def _tle_set_to_wire(item: Sequence[object]) -> str:
362
+ if not isinstance(item, (list, tuple)) or len(item) != 2:
363
+ raise TypeError("tle_sets items must be two-item sequences of TLE strings")
364
+ line1, line2 = item
365
+ if not isinstance(line1, str) or not isinstance(line2, str):
366
+ raise TypeError("tle_sets items must contain TLE strings")
367
+ return f"{line1}\n{line2}"
368
+
369
+
370
+ def _tle_sets_to_wire(tle_sets: Sequence[Sequence[object]]) -> list[str]:
371
+ if not isinstance(tle_sets, Sequence) or isinstance(tle_sets, (str, bytes)):
372
+ raise TypeError("tle_sets must be a sequence of two-item TLE string sequences")
373
+ return [_tle_set_to_wire(item) for item in tle_sets]
374
+
375
+
376
+ def hpop_rkf78(
377
+ *,
378
+ name: str | None = None,
379
+ description: str | None = None,
380
+ user_comment: str | None = None,
381
+ use_fixed_step: bool | None = None,
382
+ initial_step_s: float | None = None,
383
+ max_step_s: float | None = None,
384
+ min_step_s: float | None = None,
385
+ max_abs_error: float | None = None,
386
+ max_rel_error: float | None = None,
387
+ max_iterations: int | None = None,
388
+ ) -> HpopIntegrator:
389
+ """Create an HPOP RKF7(8) integrator fragment."""
390
+ return HpopRkf78(
391
+ name=name,
392
+ description=description,
393
+ user_comment=user_comment,
394
+ use_fixed_step=use_fixed_step,
395
+ initial_step_s=initial_step_s,
396
+ max_step_s=max_step_s,
397
+ min_step_s=min_step_s,
398
+ max_abs_error=max_abs_error,
399
+ max_rel_error=max_rel_error,
400
+ max_iterations=max_iterations,
401
+ )
402
+
403
+
404
+ def hpop_two_body_gravity(
405
+ *,
406
+ name: str | None = None,
407
+ description: str | None = None,
408
+ user_comment: str | None = None,
409
+ ) -> HpopGravity:
410
+ """Create an HPOP two-body gravity fragment.
411
+
412
+ ASTROX owns the two-body gravity constants for this branch; use
413
+ ``hpop(...)`` top-level scalar arguments for spacecraft and central-body
414
+ propagation knobs exposed by the curated SDK.
415
+ """
416
+ return HpopTwoBodyGravity(
417
+ name=name,
418
+ description=description,
419
+ user_comment=user_comment,
420
+ )
421
+
422
+
423
+ def hpop_gravity_field(
424
+ *,
425
+ gravity_file_name: str,
426
+ degree: int,
427
+ order: int,
428
+ name: str | None = None,
429
+ description: str | None = None,
430
+ user_comment: str | None = None,
431
+ use_secular_variations: bool | None = None,
432
+ solid_tide_type: str | None = None,
433
+ eop_file_path: str | None = None,
434
+ ) -> HpopGravity:
435
+ """Create an HPOP gravity-field fragment."""
436
+ return HpopGravityField(
437
+ gravity_file_name=gravity_file_name,
438
+ degree=degree,
439
+ order=order,
440
+ name=name,
441
+ description=description,
442
+ user_comment=user_comment,
443
+ use_secular_variations=use_secular_variations,
444
+ solid_tide_type=solid_tide_type,
445
+ eop_file_path=eop_file_path,
446
+ )
447
+
448
+
449
+ def hpop_jacchia_roberts(
450
+ *,
451
+ name: str | None = None,
452
+ description: str | None = None,
453
+ user_comment: str | None = None,
454
+ drag_model_type: str | None = None,
455
+ atmos_data_source: str | None = None,
456
+ f10p7: float | None = None,
457
+ f10p7_avg: float | None = None,
458
+ kp: float | None = None,
459
+ ) -> HpopAtmosphere:
460
+ """Create an HPOP Jacchia-Roberts atmosphere fragment."""
461
+ return HpopJacchiaRoberts(
462
+ name=name,
463
+ description=description,
464
+ user_comment=user_comment,
465
+ drag_model_type=drag_model_type,
466
+ atmos_data_source=atmos_data_source,
467
+ f10p7=f10p7,
468
+ f10p7_avg=f10p7_avg,
469
+ kp=kp,
470
+ )
471
+
472
+
473
+ def hpop_srp_spherical(
474
+ *,
475
+ name: str | None = None,
476
+ description: str | None = None,
477
+ user_comment: str | None = None,
478
+ shadow_model: str | None = None,
479
+ sun_position: str | None = None,
480
+ eclipsing_bodies: Sequence[str] | None = None,
481
+ ) -> HpopSrp:
482
+ """Create an HPOP spherical solar-radiation-pressure fragment."""
483
+ bodies = None
484
+ if eclipsing_bodies is not None:
485
+ bodies = tuple(
486
+ _sequence_to_list(
487
+ eclipsing_bodies,
488
+ parameter="eclipsing_bodies",
489
+ )
490
+ )
491
+ return HpopSrpSpherical(
492
+ name=name,
493
+ description=description,
494
+ user_comment=user_comment,
495
+ shadow_model=shadow_model,
496
+ sun_position=sun_position,
497
+ eclipsing_bodies=bodies,
498
+ )
499
+
500
+
501
+ def hpop_third_body(
502
+ third_body_name: str,
503
+ *,
504
+ name: str | None = None,
505
+ description: str | None = None,
506
+ user_comment: str | None = None,
507
+ mode_type: str | None = None,
508
+ ephem_source: str | None = None,
509
+ grav_source: str | None = None,
510
+ mu_m3_s2: float | None = None,
511
+ ) -> HpopThirdBody:
512
+ """Create an HPOP third-body force fragment."""
513
+ return HpopThirdBody(
514
+ third_body_name=third_body_name,
515
+ name=name,
516
+ description=description,
517
+ user_comment=user_comment,
518
+ mode_type=mode_type,
519
+ ephem_source=ephem_source,
520
+ grav_source=grav_source,
521
+ mu_m3_s2=mu_m3_s2,
522
+ )
523
+
524
+
525
+ def hpop_config(
526
+ *,
527
+ name: str | None = None,
528
+ description: str | None = None,
529
+ user_comment: str | None = None,
530
+ central_body: str | None = None,
531
+ integrator: HpopIntegrator | None = None,
532
+ gravity: HpopGravity | None = None,
533
+ atmosphere: HpopAtmosphere | None = None,
534
+ srp: HpopSrp | None = None,
535
+ third_bodies: Sequence[HpopThirdBody] | None = None,
536
+ ) -> HpopConfig:
537
+ """Create an HPOP propagator configuration fragment."""
538
+ if integrator is not None:
539
+ _hpop_value_to_wire(
540
+ integrator,
541
+ expected_type=HpopRkf78,
542
+ parameter="integrator",
543
+ )
544
+ if gravity is not None:
545
+ _hpop_value_to_wire(
546
+ gravity,
547
+ expected_type=(HpopTwoBodyGravity, HpopGravityField),
548
+ parameter="gravity",
549
+ )
550
+ if atmosphere is not None:
551
+ _hpop_value_to_wire(
552
+ atmosphere,
553
+ expected_type=HpopJacchiaRoberts,
554
+ parameter="atmosphere",
555
+ )
556
+ if srp is not None:
557
+ _hpop_value_to_wire(
558
+ srp,
559
+ expected_type=HpopSrpSpherical,
560
+ parameter="srp",
561
+ )
562
+ body_values = None
563
+ if third_bodies is not None:
564
+ if isinstance(third_bodies, (str, bytes)) or not isinstance(
565
+ third_bodies,
566
+ Sequence,
567
+ ):
568
+ raise TypeError("third_bodies must be a sequence of HPOP config values")
569
+ body_values = tuple(third_bodies)
570
+ if not all(isinstance(body, HpopThirdBody) for body in body_values):
571
+ raise TypeError("third_bodies must be a sequence of HPOP config values")
572
+ return HpopConfig(
573
+ name=name,
574
+ description=description,
575
+ user_comment=user_comment,
576
+ central_body=central_body,
577
+ integrator=integrator,
578
+ gravity=gravity,
579
+ atmosphere=atmosphere,
580
+ srp=srp,
581
+ third_bodies=body_values,
582
+ )
583
+
584
+
585
+ def hpop(
586
+ *,
587
+ start: str,
588
+ stop: str,
589
+ orbit_epoch: str,
590
+ orbit: KeplerianElements | None = None,
591
+ state: CartesianState | None = None,
592
+ config: HpopConfig | Mapping[str, Any] | None = None,
593
+ coord_system: str | None = None,
594
+ coord_epoch: str | None = None,
595
+ gravitational_parameter_m3_s2: float | None = None,
596
+ coefficient_of_drag: float | None = None,
597
+ area_mass_ratio_drag_m2_kg: float | None = None,
598
+ coefficient_of_srp: float | None = None,
599
+ area_mass_ratio_srp_m2_kg: float | None = None,
600
+ ) -> tuple[float, PropagatorPosition]:
601
+ """Propagate Classical or Cartesian state with ASTROX HPOP."""
602
+ if (orbit is None) == (state is None):
603
+ raise ValueError("exactly one of orbit or state must be provided")
604
+ if orbit is not None:
605
+ if not isinstance(orbit, KeplerianElements):
606
+ raise TypeError("orbit must be a KeplerianElements instance")
607
+ coord_type = "Classical"
608
+ orbital_elements = orbit.to_wire()
609
+ else:
610
+ if not isinstance(state, CartesianState):
611
+ raise TypeError("state must be a CartesianState instance")
612
+ coord_type = "Cartesian"
613
+ orbital_elements = state.to_wire()
614
+
615
+ payload: dict[str, Any] = {
616
+ "Start": start,
617
+ "Stop": stop,
618
+ "OrbitEpoch": orbit_epoch,
619
+ "CoordType": coord_type,
620
+ "OrbitalElements": orbital_elements,
621
+ }
622
+ _include_if_supplied(payload, "CoordSystem", coord_system)
623
+ _include_if_supplied(payload, "CoordEpoch", coord_epoch)
624
+ _include_if_supplied(payload, "GravitationalParameter", gravitational_parameter_m3_s2)
625
+ _include_if_supplied(payload, "CoefficientOfDrag", coefficient_of_drag)
626
+ _include_if_supplied(payload, "AreaMassRatioDrag", area_mass_ratio_drag_m2_kg)
627
+ _include_if_supplied(payload, "CoefficientOfSRP", coefficient_of_srp)
628
+ _include_if_supplied(payload, "AreaMassRatioSRP", area_mass_ratio_srp_m2_kg)
629
+ if config is not None:
630
+ if isinstance(config, HpopConfig):
631
+ payload["HpopPropagator"] = config.to_wire()
632
+ elif isinstance(config, Mapping):
633
+ payload["HpopPropagator"] = dict(config)
634
+ else:
635
+ raise TypeError("config must be an HpopConfig value or mapping fragment")
636
+
637
+ result = raw.post("/Propagator/HPOP", json=payload)
638
+ return _success_path(result)
639
+
640
+
641
+ def two_body(
642
+ *,
643
+ start: str,
644
+ stop: str,
645
+ orbit_epoch: str,
646
+ orbit: KeplerianElements,
647
+ step_s: float | None = None,
648
+ central_body: str | None = None,
649
+ gravitational_parameter_m3_s2: float | None = None,
650
+ coord_system: str | None = None,
651
+ ) -> tuple[float, PropagatorPosition]:
652
+ """Propagate Classical Keplerian elements using two-body dynamics."""
653
+ if not isinstance(orbit, KeplerianElements):
654
+ raise TypeError("orbit must be a KeplerianElements instance")
655
+
656
+ payload: dict[str, Any] = {
657
+ "Start": start,
658
+ "Stop": stop,
659
+ "OrbitEpoch": orbit_epoch,
660
+ "CoordType": "Classical",
661
+ "OrbitalElements": orbit.to_wire(),
662
+ }
663
+ if step_s is not None:
664
+ payload["Step"] = step_s
665
+ if central_body is not None:
666
+ payload["CentralBody"] = central_body
667
+ if gravitational_parameter_m3_s2 is not None:
668
+ payload["GravitationalParameter"] = gravitational_parameter_m3_s2
669
+ if coord_system is not None:
670
+ payload["CoordSystem"] = coord_system
671
+
672
+ result = raw.post("/Propagator/TwoBody", json=payload)
673
+ return _success_path(result)
674
+
675
+
676
+ def j2(
677
+ *,
678
+ start: str,
679
+ stop: str,
680
+ orbit_epoch: str,
681
+ orbit: KeplerianElements,
682
+ step_s: float | None = None,
683
+ central_body: str | None = None,
684
+ gravitational_parameter_m3_s2: float | None = None,
685
+ coord_system: str | None = None,
686
+ j2_normalized_value: float | None = None,
687
+ ref_distance_m: float | None = None,
688
+ ) -> tuple[float, PropagatorPosition]:
689
+ """Propagate Classical Keplerian elements using the J2 model."""
690
+ if not isinstance(orbit, KeplerianElements):
691
+ raise TypeError("orbit must be a KeplerianElements instance")
692
+
693
+ payload: dict[str, Any] = {
694
+ "Start": start,
695
+ "Stop": stop,
696
+ "OrbitEpoch": orbit_epoch,
697
+ "CoordType": "Classical",
698
+ "OrbitalElements": orbit.to_wire(),
699
+ }
700
+ if step_s is not None:
701
+ payload["Step"] = step_s
702
+ if central_body is not None:
703
+ payload["CentralBody"] = central_body
704
+ if gravitational_parameter_m3_s2 is not None:
705
+ payload["GravitationalParameter"] = gravitational_parameter_m3_s2
706
+ if coord_system is not None:
707
+ payload["CoordSystem"] = coord_system
708
+ if j2_normalized_value is not None:
709
+ payload["J2NormalizedValue"] = j2_normalized_value
710
+ if ref_distance_m is not None:
711
+ payload["RefDistance"] = ref_distance_m
712
+
713
+ result = raw.post("/Propagator/J2", json=payload)
714
+ return _success_path(result)
715
+
716
+
717
+ def multi_two_body(
718
+ *,
719
+ epoch: str,
720
+ states: Sequence[tuple[str, KeplerianElements]],
721
+ gravitational_parameter_m3_s2: float | None = None,
722
+ ) -> tuple[KeplerianElements, ...]:
723
+ """Propagate multiple Classical states to one target epoch using two-body dynamics.
724
+
725
+ ASTROX raw batch responses include ``GravitationalParameter`` on each returned
726
+ element. The curated return intentionally omits it because live behavior shows
727
+ that field is not a reliable echo of the propagation parameter used for the
728
+ result; use ``astrox.raw`` for the full raw envelope.
729
+ """
730
+ payload = {
731
+ "Epoch": epoch,
732
+ "AllSateElements": _states_to_wire(
733
+ states,
734
+ gravitational_parameter_m3_s2=gravitational_parameter_m3_s2,
735
+ ),
736
+ }
737
+
738
+ result = raw.post("/Propagator/MultiTwoBody", json=payload)
739
+ return _batch_success_path(result)
740
+
741
+
742
+ def multi_j2(
743
+ *,
744
+ epoch: str,
745
+ states: Sequence[tuple[str, KeplerianElements]],
746
+ gravitational_parameter_m3_s2: float | None = None,
747
+ ) -> tuple[KeplerianElements, ...]:
748
+ """Propagate multiple Classical states to one target epoch using ASTROX J2.
749
+
750
+ The batch ASTROX route owns its J2 constants; the curated SDK does not expose
751
+ J2 constants for this function because live behavior does not show those
752
+ inputs affecting the endpoint. ASTROX raw batch responses include
753
+ ``GravitationalParameter`` on each returned element. The curated return
754
+ intentionally omits it because live behavior shows that field is not a
755
+ reliable echo of the propagation parameter used for the result; use
756
+ ``astrox.raw`` for the full raw envelope.
757
+ """
758
+ payload = {
759
+ "Epoch": epoch,
760
+ "AllSateElements": _states_to_wire(
761
+ states,
762
+ gravitational_parameter_m3_s2=gravitational_parameter_m3_s2,
763
+ ),
764
+ }
765
+
766
+ result = raw.post("/Propagator/MultiJ2", json=payload)
767
+ return _batch_success_path(result)
768
+
769
+
770
+ def multi_sgp4(
771
+ *,
772
+ epoch: str,
773
+ tle_sets: Sequence[tuple[str, str]],
774
+ ) -> tuple[KeplerianElements, ...]:
775
+ """Propagate multiple TLEs to one target epoch using SGP4.
776
+
777
+ Each public ``tle_sets`` item is a two-line TLE sequence. The SDK lowers it
778
+ to the ASTROX batch route's newline-joined string format. ASTROX raw batch
779
+ responses include ``GravitationalParameter`` on each returned element. The
780
+ curated return intentionally omits it because live behavior shows that field
781
+ is not a reliable echo of the propagation parameter used for the result; use
782
+ ``astrox.raw`` for the full raw envelope.
783
+ """
784
+ payload = {
785
+ "Epoch": epoch,
786
+ "TLEs": _tle_sets_to_wire(tle_sets),
787
+ }
788
+
789
+ result = raw.post("/Propagator/MultiSgp4", json=payload)
790
+ return _batch_success_path(result)
791
+
792
+
793
+ def sgp4(
794
+ *,
795
+ start: str,
796
+ stop: str,
797
+ tle_lines: tuple[str, str] | list[str],
798
+ step_s: float | None = None,
799
+ satellite_number: str | None = None,
800
+ ) -> tuple[float, PropagatorPosition]:
801
+ """Propagate a satellite from two-line element data using SGP4."""
802
+ if (
803
+ not isinstance(tle_lines, (list, tuple))
804
+ or len(tle_lines) != 2
805
+ or not all(isinstance(line, str) for line in tle_lines)
806
+ ):
807
+ raise TypeError("tle_lines must be a two-item sequence of TLE strings")
808
+
809
+ payload: dict[str, Any] = {
810
+ "Start": start,
811
+ "Stop": stop,
812
+ "TLEs": list(tle_lines),
813
+ }
814
+ if step_s is not None:
815
+ payload["Step"] = step_s
816
+ if satellite_number is not None:
817
+ payload["SatelliteNumber"] = satellite_number
818
+
819
+ result = raw.post("/Propagator/sgp4", json=payload)
820
+ return _success_path(result)
821
+
822
+
823
+ def simple_ascent(
824
+ *,
825
+ start: str,
826
+ stop: str,
827
+ launch_latitude_deg: float,
828
+ launch_longitude_deg: float,
829
+ launch_altitude_m: float,
830
+ burnout_velocity_m_s: float,
831
+ burnout_latitude_deg: float,
832
+ burnout_longitude_deg: float,
833
+ burnout_altitude_m: float,
834
+ step_s: float | None = None,
835
+ central_body: str | None = None,
836
+ ) -> tuple[float, PropagatorPosition]:
837
+ """Propagate a simple ascent from launch point to burnout point."""
838
+ payload: dict[str, Any] = {
839
+ "Start": start,
840
+ "Stop": stop,
841
+ "LaunchLatitude": launch_latitude_deg,
842
+ "LaunchLongitude": launch_longitude_deg,
843
+ "LaunchAltitude": launch_altitude_m,
844
+ "BurnoutVelocity": burnout_velocity_m_s,
845
+ "BurnoutLatitude": burnout_latitude_deg,
846
+ "BurnoutLongitude": burnout_longitude_deg,
847
+ "BurnoutAltitude": burnout_altitude_m,
848
+ }
849
+ if step_s is not None:
850
+ payload["Step"] = step_s
851
+ if central_body is not None:
852
+ payload["CentralBody"] = central_body
853
+
854
+ result = raw.post("/Propagator/SimpleAscent", json=payload)
855
+ return _success_path(result)
856
+
857
+
858
+ def ballistic(
859
+ *,
860
+ start: str,
861
+ impact_latitude_deg: float,
862
+ impact_longitude_deg: float,
863
+ stop: str | None = None,
864
+ step_s: float | None = None,
865
+ central_body: str | None = None,
866
+ gravitational_parameter_m3_s2: float | None = None,
867
+ launch_latitude_deg: float | None = None,
868
+ launch_longitude_deg: float | None = None,
869
+ launch_altitude_m: float | None = None,
870
+ impact_altitude_m: float | None = None,
871
+ ) -> tuple[float, PropagatorPosition]:
872
+ """Propagate the verified nominal ballistic trajectory shape."""
873
+ payload: dict[str, Any] = {
874
+ "Start": start,
875
+ "ImpactLatitude": impact_latitude_deg,
876
+ "ImpactLongitude": impact_longitude_deg,
877
+ }
878
+ if step_s is not None:
879
+ payload["Step"] = step_s
880
+ if central_body is not None:
881
+ payload["CentralBody"] = central_body
882
+ if gravitational_parameter_m3_s2 is not None:
883
+ payload["GravitationalParameter"] = gravitational_parameter_m3_s2
884
+ if launch_latitude_deg is not None:
885
+ payload["LaunchLatitude"] = launch_latitude_deg
886
+ if launch_longitude_deg is not None:
887
+ payload["LaunchLongitude"] = launch_longitude_deg
888
+ if launch_altitude_m is not None:
889
+ payload["LaunchAltitude"] = launch_altitude_m
890
+ if impact_altitude_m is not None:
891
+ payload["ImpactAltitude"] = impact_altitude_m
892
+ if stop is not None:
893
+ payload["Stop"] = stop
894
+
895
+ result = raw.post("/Propagator/Ballistic", json=payload)
896
+ return _success_path(result)
897
+
898
+
899
+ def ballistic_delta_v(
900
+ *,
901
+ start: str,
902
+ impact_latitude_deg: float,
903
+ impact_longitude_deg: float,
904
+ delta_v_m_s: float,
905
+ stop: str | None = None,
906
+ step_s: float | None = None,
907
+ central_body: str | None = None,
908
+ gravitational_parameter_m3_s2: float | None = None,
909
+ launch_latitude_deg: float | None = None,
910
+ launch_longitude_deg: float | None = None,
911
+ launch_altitude_m: float | None = None,
912
+ impact_altitude_m: float | None = None,
913
+ ) -> tuple[float, PropagatorPosition]:
914
+ """Propagate a ballistic trajectory using the DeltaV branch."""
915
+ payload: dict[str, Any] = {
916
+ "Start": start,
917
+ "ImpactLatitude": impact_latitude_deg,
918
+ "ImpactLongitude": impact_longitude_deg,
919
+ "BallisticType": "DeltaV",
920
+ "BallisticTypeValue": delta_v_m_s,
921
+ }
922
+ if step_s is not None:
923
+ payload["Step"] = step_s
924
+ if central_body is not None:
925
+ payload["CentralBody"] = central_body
926
+ if gravitational_parameter_m3_s2 is not None:
927
+ payload["GravitationalParameter"] = gravitational_parameter_m3_s2
928
+ if launch_latitude_deg is not None:
929
+ payload["LaunchLatitude"] = launch_latitude_deg
930
+ if launch_longitude_deg is not None:
931
+ payload["LaunchLongitude"] = launch_longitude_deg
932
+ if launch_altitude_m is not None:
933
+ payload["LaunchAltitude"] = launch_altitude_m
934
+ if impact_altitude_m is not None:
935
+ payload["ImpactAltitude"] = impact_altitude_m
936
+ if stop is not None:
937
+ payload["Stop"] = stop
938
+
939
+ result = raw.post("/Propagator/Ballistic", json=payload)
940
+ return _success_path(result)
941
+
942
+
943
+ def ballistic_delta_v_min_ecc(
944
+ *,
945
+ start: str,
946
+ impact_latitude_deg: float,
947
+ impact_longitude_deg: float,
948
+ delta_v_m_s: float,
949
+ stop: str | None = None,
950
+ step_s: float | None = None,
951
+ central_body: str | None = None,
952
+ gravitational_parameter_m3_s2: float | None = None,
953
+ launch_latitude_deg: float | None = None,
954
+ launch_longitude_deg: float | None = None,
955
+ launch_altitude_m: float | None = None,
956
+ impact_altitude_m: float | None = None,
957
+ ) -> tuple[float, PropagatorPosition]:
958
+ """Propagate a ballistic trajectory using the DeltaV_MinEcc branch."""
959
+ payload: dict[str, Any] = {
960
+ "Start": start,
961
+ "ImpactLatitude": impact_latitude_deg,
962
+ "ImpactLongitude": impact_longitude_deg,
963
+ "BallisticType": "DeltaV_MinEcc",
964
+ "BallisticTypeValue": delta_v_m_s,
965
+ }
966
+ if step_s is not None:
967
+ payload["Step"] = step_s
968
+ if central_body is not None:
969
+ payload["CentralBody"] = central_body
970
+ if gravitational_parameter_m3_s2 is not None:
971
+ payload["GravitationalParameter"] = gravitational_parameter_m3_s2
972
+ if launch_latitude_deg is not None:
973
+ payload["LaunchLatitude"] = launch_latitude_deg
974
+ if launch_longitude_deg is not None:
975
+ payload["LaunchLongitude"] = launch_longitude_deg
976
+ if launch_altitude_m is not None:
977
+ payload["LaunchAltitude"] = launch_altitude_m
978
+ if impact_altitude_m is not None:
979
+ payload["ImpactAltitude"] = impact_altitude_m
980
+ if stop is not None:
981
+ payload["Stop"] = stop
982
+
983
+ result = raw.post("/Propagator/Ballistic", json=payload)
984
+ return _success_path(result)
985
+
986
+
987
+ def ballistic_apogee_altitude(
988
+ *,
989
+ start: str,
990
+ impact_latitude_deg: float,
991
+ impact_longitude_deg: float,
992
+ apogee_altitude_m: float,
993
+ stop: str | None = None,
994
+ step_s: float | None = None,
995
+ central_body: str | None = None,
996
+ gravitational_parameter_m3_s2: float | None = None,
997
+ launch_latitude_deg: float | None = None,
998
+ launch_longitude_deg: float | None = None,
999
+ launch_altitude_m: float | None = None,
1000
+ impact_altitude_m: float | None = None,
1001
+ ) -> tuple[float, PropagatorPosition]:
1002
+ """Propagate a ballistic trajectory using the ApogeeAlt branch."""
1003
+ payload: dict[str, Any] = {
1004
+ "Start": start,
1005
+ "ImpactLatitude": impact_latitude_deg,
1006
+ "ImpactLongitude": impact_longitude_deg,
1007
+ "BallisticType": "ApogeeAlt",
1008
+ "BallisticTypeValue": apogee_altitude_m,
1009
+ }
1010
+ if step_s is not None:
1011
+ payload["Step"] = step_s
1012
+ if central_body is not None:
1013
+ payload["CentralBody"] = central_body
1014
+ if gravitational_parameter_m3_s2 is not None:
1015
+ payload["GravitationalParameter"] = gravitational_parameter_m3_s2
1016
+ if launch_latitude_deg is not None:
1017
+ payload["LaunchLatitude"] = launch_latitude_deg
1018
+ if launch_longitude_deg is not None:
1019
+ payload["LaunchLongitude"] = launch_longitude_deg
1020
+ if launch_altitude_m is not None:
1021
+ payload["LaunchAltitude"] = launch_altitude_m
1022
+ if impact_altitude_m is not None:
1023
+ payload["ImpactAltitude"] = impact_altitude_m
1024
+ if stop is not None:
1025
+ payload["Stop"] = stop
1026
+
1027
+ result = raw.post("/Propagator/Ballistic", json=payload)
1028
+ return _success_path(result)
1029
+
1030
+
1031
+ def ballistic_time_of_flight(
1032
+ *,
1033
+ start: str,
1034
+ impact_latitude_deg: float,
1035
+ impact_longitude_deg: float,
1036
+ time_of_flight_s: float,
1037
+ stop: str | None = None,
1038
+ step_s: float | None = None,
1039
+ central_body: str | None = None,
1040
+ gravitational_parameter_m3_s2: float | None = None,
1041
+ launch_latitude_deg: float | None = None,
1042
+ launch_longitude_deg: float | None = None,
1043
+ launch_altitude_m: float | None = None,
1044
+ impact_altitude_m: float | None = None,
1045
+ ) -> tuple[float, PropagatorPosition]:
1046
+ """Propagate a ballistic trajectory using the TimeOfFlight branch."""
1047
+ payload: dict[str, Any] = {
1048
+ "Start": start,
1049
+ "ImpactLatitude": impact_latitude_deg,
1050
+ "ImpactLongitude": impact_longitude_deg,
1051
+ "BallisticType": "TimeOfFlight",
1052
+ "BallisticTypeValue": time_of_flight_s,
1053
+ }
1054
+ if step_s is not None:
1055
+ payload["Step"] = step_s
1056
+ if central_body is not None:
1057
+ payload["CentralBody"] = central_body
1058
+ if gravitational_parameter_m3_s2 is not None:
1059
+ payload["GravitationalParameter"] = gravitational_parameter_m3_s2
1060
+ if launch_latitude_deg is not None:
1061
+ payload["LaunchLatitude"] = launch_latitude_deg
1062
+ if launch_longitude_deg is not None:
1063
+ payload["LaunchLongitude"] = launch_longitude_deg
1064
+ if launch_altitude_m is not None:
1065
+ payload["LaunchAltitude"] = launch_altitude_m
1066
+ if impact_altitude_m is not None:
1067
+ payload["ImpactAltitude"] = impact_altitude_m
1068
+ if stop is not None:
1069
+ payload["Stop"] = stop
1070
+
1071
+ result = raw.post("/Propagator/Ballistic", json=payload)
1072
+ return _success_path(result)