astrox-python 0.2.0__py3-none-any.whl → 1.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/__init__.py CHANGED
@@ -4,7 +4,7 @@ Functions are organized by domain modules and can use package-level
4
4
  configuration without requiring explicit client management:
5
5
 
6
6
  import astrox
7
- from astrox import access, celestial, catalog, components, coverage, lighting, orbits, propagator, terrain
7
+ from astrox import access, celestial, catalog, components, coverage, libration, lighting, orbits, propagator, terrain
8
8
 
9
9
  astrox.configure(base_url="http://custom:8765", timeout=120)
10
10
  orbit = orbits.keplerian(...)
@@ -35,6 +35,7 @@ from astrox import (
35
35
  conjunction,
36
36
  components,
37
37
  coverage,
38
+ libration,
38
39
  lighting,
39
40
  orbits,
40
41
  propagator,
@@ -60,6 +61,7 @@ __all__ = [
60
61
  "conjunction",
61
62
  "coverage",
62
63
  "components",
64
+ "libration",
63
65
  "lighting",
64
66
  "orbits",
65
67
  "propagator",
astrox/catalog.py CHANGED
@@ -43,6 +43,16 @@ def _optional_boolean_query(value: bool | None, *, parameter: str) -> str | None
43
43
  return "true" if value else "false"
44
44
 
45
45
 
46
+ def _without_status_fields(value: Any, *, endpoint: str) -> dict[str, Any]:
47
+ if not isinstance(value, dict):
48
+ raise TypeError(f"{endpoint} response must be an object")
49
+ return {
50
+ key: item
51
+ for key, item in value.items()
52
+ if key not in {"IsSuccess", "Message"}
53
+ }
54
+
55
+
46
56
  def query_cities(
47
57
  *,
48
58
  city_name: str | None = None,
@@ -72,7 +82,10 @@ def query_cities(
72
82
  "typeOfCity",
73
83
  _optional_string(city_type, parameter="city_type"),
74
84
  )
75
- return raw.get("/city", params=params)
85
+ return _without_status_fields(
86
+ raw.get("/city", params=params),
87
+ endpoint="/city",
88
+ )
76
89
 
77
90
 
78
91
  def query_facilities(
@@ -92,7 +105,10 @@ def query_facilities(
92
105
  "networkName",
93
106
  _optional_string(network_name, parameter="network_name"),
94
107
  )
95
- return raw.get("/facility", params=params)
108
+ return _without_status_fields(
109
+ raw.get("/facility", params=params),
110
+ endpoint="/facility",
111
+ )
96
112
 
97
113
 
98
114
  def query_satellites(
@@ -172,4 +188,7 @@ def query_satellites(
172
188
  parameter="maximum_inclination_deg",
173
189
  ),
174
190
  )
175
- return raw.get("/ssc", params=params)
191
+ return _without_status_fields(
192
+ raw.get("/ssc", params=params),
193
+ endpoint="/ssc",
194
+ )
astrox/celestial.py CHANGED
@@ -1,16 +1,20 @@
1
- """Read-like celestial endpoint functions."""
1
+ """Celestial read-like and computational endpoint functions."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from dataclasses import dataclass
5
6
  from numbers import Real
6
7
  from typing import Any
7
8
 
8
9
  from astrox._http import raw
9
10
 
10
11
  __all__ = [
12
+ "MpcOrbitalElements",
11
13
  "cb_axes_rotation",
12
14
  "ephemeris",
15
+ "lambert_transfer_window",
13
16
  "mpc_ephemeris",
17
+ "mpc_orbital_elements",
14
18
  ]
15
19
 
16
20
 
@@ -47,6 +51,235 @@ def _optional_integer(value: int | None, *, parameter: str) -> int | None:
47
51
  return value
48
52
 
49
53
 
54
+ def _without_status_fields(value: Any, *, endpoint: str) -> dict[str, Any]:
55
+ if not isinstance(value, dict):
56
+ raise TypeError(f"{endpoint} response must be an object")
57
+ return {
58
+ key: item
59
+ for key, item in value.items()
60
+ if key not in {"IsSuccess", "Message"}
61
+ }
62
+
63
+
64
+ @dataclass(frozen=True, kw_only=True)
65
+ class MpcOrbitalElements:
66
+ """MPC heliocentric orbital elements used by celestial transfer routes."""
67
+
68
+ epoch_mjd_tdt: float | None = None
69
+ periapsis_time_mjd_tdt: float | None = None
70
+ periapsis_distance_au: float | None = None
71
+ semi_major_axis_au: float | None = None
72
+ eccentricity: float | None = None
73
+ inclination_deg: float | None = None
74
+ raan_deg: float | None = None
75
+ argument_of_periapsis_deg: float | None = None
76
+ mean_anomaly_deg: float | None = None
77
+ reference_frame: str | None = None
78
+ """Heliocentric mean-ecliptic variant: ``MeanEclpJ2000`` (JPL) or
79
+ ``EclpJ2000ICRF`` (MPC, server default)."""
80
+
81
+ def __post_init__(self) -> None:
82
+ for field_name in (
83
+ "epoch_mjd_tdt",
84
+ "periapsis_time_mjd_tdt",
85
+ "periapsis_distance_au",
86
+ "semi_major_axis_au",
87
+ "eccentricity",
88
+ "inclination_deg",
89
+ "raan_deg",
90
+ "argument_of_periapsis_deg",
91
+ "mean_anomaly_deg",
92
+ ):
93
+ object.__setattr__(
94
+ self,
95
+ field_name,
96
+ _optional_number(
97
+ getattr(self, field_name),
98
+ parameter=field_name,
99
+ ),
100
+ )
101
+ object.__setattr__(
102
+ self,
103
+ "reference_frame",
104
+ _optional_string(self.reference_frame, parameter="reference_frame"),
105
+ )
106
+
107
+ def to_wire(self) -> dict[str, Any]:
108
+ payload: dict[str, Any] = {}
109
+ _include_if_supplied(payload, "EpochMjdTdt", self.epoch_mjd_tdt)
110
+ _include_if_supplied(payload, "PeriTimeMjdTdt", self.periapsis_time_mjd_tdt)
111
+ _include_if_supplied(payload, "Q", self.periapsis_distance_au)
112
+ _include_if_supplied(payload, "SemimajorAxis", self.semi_major_axis_au)
113
+ _include_if_supplied(payload, "Eccentricity", self.eccentricity)
114
+ _include_if_supplied(payload, "Inclination", self.inclination_deg)
115
+ _include_if_supplied(payload, "Raan", self.raan_deg)
116
+ _include_if_supplied(
117
+ payload,
118
+ "ArgOfPeriapsis",
119
+ self.argument_of_periapsis_deg,
120
+ )
121
+ _include_if_supplied(payload, "MeanAnomaly", self.mean_anomaly_deg)
122
+ _include_if_supplied(payload, "ReferenceFrame", self.reference_frame)
123
+ return payload
124
+
125
+
126
+ def mpc_orbital_elements(
127
+ *,
128
+ epoch_mjd_tdt: float | None = None,
129
+ periapsis_time_mjd_tdt: float | None = None,
130
+ periapsis_distance_au: float | None = None,
131
+ semi_major_axis_au: float | None = None,
132
+ eccentricity: float | None = None,
133
+ inclination_deg: float | None = None,
134
+ raan_deg: float | None = None,
135
+ argument_of_periapsis_deg: float | None = None,
136
+ mean_anomaly_deg: float | None = None,
137
+ reference_frame: str | None = None,
138
+ ) -> MpcOrbitalElements:
139
+ """Build an MPC orbital-element fragment without adding physics policy."""
140
+ return MpcOrbitalElements(
141
+ epoch_mjd_tdt=epoch_mjd_tdt,
142
+ periapsis_time_mjd_tdt=periapsis_time_mjd_tdt,
143
+ periapsis_distance_au=periapsis_distance_au,
144
+ semi_major_axis_au=semi_major_axis_au,
145
+ eccentricity=eccentricity,
146
+ inclination_deg=inclination_deg,
147
+ raan_deg=raan_deg,
148
+ argument_of_periapsis_deg=argument_of_periapsis_deg,
149
+ mean_anomaly_deg=mean_anomaly_deg,
150
+ reference_frame=reference_frame,
151
+ )
152
+
153
+
154
+ def _interval(start: str, stop: str, *, parameter: str) -> str:
155
+ return "/".join(
156
+ (
157
+ _string(start, parameter=f"{parameter}_start"),
158
+ _string(stop, parameter=f"{parameter}_stop"),
159
+ )
160
+ )
161
+
162
+
163
+ def _elements_to_wire(
164
+ value: MpcOrbitalElements | None,
165
+ *,
166
+ parameter: str,
167
+ ) -> dict[str, Any] | None:
168
+ if value is None:
169
+ return None
170
+ if not isinstance(value, MpcOrbitalElements):
171
+ raise TypeError(f"{parameter} must be an MpcOrbitalElements value")
172
+ return value.to_wire()
173
+
174
+
175
+ def lambert_transfer_window(
176
+ *,
177
+ departure_body: str,
178
+ arrival_body: str,
179
+ departure_start: str,
180
+ departure_stop: str,
181
+ arrival_start: str,
182
+ arrival_stop: str,
183
+ sun_frame: str | None = None,
184
+ min_time_of_flight_days: int | None = None,
185
+ departure_step_days: float | None = None,
186
+ arrival_step_days: float | None = None,
187
+ departure_elements: MpcOrbitalElements | None = None,
188
+ arrival_elements: MpcOrbitalElements | None = None,
189
+ max_departure_delta_v_m_s: int | None = None,
190
+ max_arrival_delta_v_m_s: int | None = None,
191
+ max_time_of_flight_days: int | None = None,
192
+ ) -> dict[str, Any]:
193
+ """Return a Lambert transfer-window scan from ASTROX.
194
+
195
+ The returned dictionary preserves every server field except the transport
196
+ success envelope fields ``IsSuccess`` and ``Message``. The transfer route
197
+ itself returns one result for each sampled departure/arrival time pair that
198
+ it evaluates; it is not the single-case ``orbits.lambert_delta_v`` route.
199
+ """
200
+ payload: dict[str, Any] = {
201
+ "DepartureCbName": _string(departure_body, parameter="departure_body"),
202
+ "ArrivalCbName": _string(arrival_body, parameter="arrival_body"),
203
+ "DepartureInterval": _interval(
204
+ departure_start,
205
+ departure_stop,
206
+ parameter="departure_interval",
207
+ ),
208
+ "ArrivalInterval": _interval(
209
+ arrival_start,
210
+ arrival_stop,
211
+ parameter="arrival_interval",
212
+ ),
213
+ }
214
+ _include_if_supplied(
215
+ payload,
216
+ "SunFrameName",
217
+ _optional_string(sun_frame, parameter="sun_frame"),
218
+ )
219
+ _include_if_supplied(
220
+ payload,
221
+ "MinTofDays",
222
+ _optional_integer(
223
+ min_time_of_flight_days,
224
+ parameter="min_time_of_flight_days",
225
+ ),
226
+ )
227
+ _include_if_supplied(
228
+ payload,
229
+ "DepartureStepDay",
230
+ _optional_number(
231
+ departure_step_days,
232
+ parameter="departure_step_days",
233
+ ),
234
+ )
235
+ _include_if_supplied(
236
+ payload,
237
+ "ArrivalStepDay",
238
+ _optional_number(
239
+ arrival_step_days,
240
+ parameter="arrival_step_days",
241
+ ),
242
+ )
243
+ _include_if_supplied(
244
+ payload,
245
+ "DepartureElements",
246
+ _elements_to_wire(departure_elements, parameter="departure_elements"),
247
+ )
248
+ _include_if_supplied(
249
+ payload,
250
+ "ArrivalElements",
251
+ _elements_to_wire(arrival_elements, parameter="arrival_elements"),
252
+ )
253
+ _include_if_supplied(
254
+ payload,
255
+ "MaxDepartureDV",
256
+ _optional_integer(
257
+ max_departure_delta_v_m_s,
258
+ parameter="max_departure_delta_v_m_s",
259
+ ),
260
+ )
261
+ _include_if_supplied(
262
+ payload,
263
+ "MaxArrivalDV",
264
+ _optional_integer(
265
+ max_arrival_delta_v_m_s,
266
+ parameter="max_arrival_delta_v_m_s",
267
+ ),
268
+ )
269
+ _include_if_supplied(
270
+ payload,
271
+ "MaxTofDays",
272
+ _optional_integer(
273
+ max_time_of_flight_days,
274
+ parameter="max_time_of_flight_days",
275
+ ),
276
+ )
277
+ return _without_status_fields(
278
+ raw.post("/celestial/transfer", json=payload),
279
+ endpoint="/celestial/transfer",
280
+ )
281
+
282
+
50
283
  def ephemeris(
51
284
  *,
52
285
  target_name: str,
@@ -85,7 +318,10 @@ def ephemeris(
85
318
  "Step",
86
319
  _optional_number(step_s, parameter="step_s"),
87
320
  )
88
- return raw.post("/celestial/ephemeris", json=payload)
321
+ return _without_status_fields(
322
+ raw.post("/celestial/ephemeris", json=payload),
323
+ endpoint="/celestial/ephemeris",
324
+ )
89
325
 
90
326
 
91
327
  def cb_axes_rotation(
@@ -121,7 +357,10 @@ def cb_axes_rotation(
121
357
  "Order",
122
358
  _optional_integer(order, parameter="order"),
123
359
  )
124
- return raw.post("/celestial/CbAxesRotation", json=payload)
360
+ return _without_status_fields(
361
+ raw.post("/celestial/CbAxesRotation", json=payload),
362
+ endpoint="/celestial/CbAxesRotation",
363
+ )
125
364
 
126
365
 
127
366
  def mpc_ephemeris(
@@ -130,8 +369,14 @@ def mpc_ephemeris(
130
369
  observer_frame: str | None = None,
131
370
  start: str | None = None,
132
371
  stop: str | None = None,
372
+ target_elements: MpcOrbitalElements | None = None,
133
373
  ) -> dict[str, Any]:
134
- """Return ASTROX minor-planet ephemeris output from its MPC-backed route."""
374
+ """Return ASTROX minor-planet ephemeris output from its MPC-backed route.
375
+
376
+ When ``target_elements`` is supplied, the server integrates those MPC
377
+ orbital elements directly instead of resolving ``target_name`` through the
378
+ MPC network query.
379
+ """
135
380
  payload: dict[str, Any] = {
136
381
  "TargetName": _string(target_name, parameter="target_name"),
137
382
  }
@@ -150,4 +395,12 @@ def mpc_ephemeris(
150
395
  "Stop",
151
396
  _optional_string(stop, parameter="stop"),
152
397
  )
153
- return raw.post("/celestial/mpc", json=payload)
398
+ _include_if_supplied(
399
+ payload,
400
+ "TargetElements",
401
+ _elements_to_wire(target_elements, parameter="target_elements"),
402
+ )
403
+ return _without_status_fields(
404
+ raw.post("/celestial/mpc", json=payload),
405
+ endpoint="/celestial/mpc",
406
+ )
astrox/libration.py ADDED
@@ -0,0 +1,448 @@
1
+ """CRTBP and Earth-Moon libration-dynamics functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from numbers import Real
9
+ from typing import Any
10
+
11
+ from astrox._http import raw
12
+
13
+ __all__ = [
14
+ "CrtbpSample",
15
+ "CrtbpState",
16
+ "CrtbpTrajectory",
17
+ "LibrationPoint",
18
+ "LibrationPoints",
19
+ "LibrationUnitSystem",
20
+ "PeriodicOrbit",
21
+ "correct_periodic_orbit_fixed_x",
22
+ "crtbp_state",
23
+ "crtbp_trajectory",
24
+ "earth_moon_dro",
25
+ "earth_moon_l1_halo",
26
+ "earth_moon_l2_halo",
27
+ "positions",
28
+ "units",
29
+ ]
30
+
31
+
32
+ def _number(value: Any, *, field: str) -> float:
33
+ if isinstance(value, bool) or not isinstance(value, Real):
34
+ raise TypeError(f"{field} must be a number")
35
+ result = float(value)
36
+ if not math.isfinite(result):
37
+ raise ValueError(f"{field} must be finite")
38
+ return result
39
+
40
+
41
+ def _optional_number(value: float | None, *, parameter: str) -> float | None:
42
+ if value is None:
43
+ return None
44
+ return _number(value, field=parameter)
45
+
46
+
47
+ def _optional_boolean(value: bool | None, *, parameter: str) -> bool | None:
48
+ if value is None:
49
+ return None
50
+ if not isinstance(value, bool):
51
+ raise TypeError(f"{parameter} must be a boolean")
52
+ return value
53
+
54
+
55
+ def _mapping(value: Any, *, field: str) -> Mapping[str, Any]:
56
+ if not isinstance(value, Mapping):
57
+ raise TypeError(f"{field} must be an object")
58
+ return value
59
+
60
+
61
+ def _sequence(value: Any, *, field: str) -> Sequence[Any]:
62
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
63
+ raise TypeError(f"{field} must be an array")
64
+ return value
65
+
66
+
67
+ def _boolean(value: Any, *, field: str) -> bool:
68
+ if not isinstance(value, bool):
69
+ raise TypeError(f"{field} must be a boolean")
70
+ return value
71
+
72
+
73
+ def _include_if_supplied(payload: dict[str, Any], wire_key: str, value: Any) -> None:
74
+ if value is not None:
75
+ payload[wire_key] = value
76
+
77
+
78
+ def _boolean_query(value: bool | None, *, parameter: str) -> str | None:
79
+ parsed = _optional_boolean(value, parameter=parameter)
80
+ if parsed is None:
81
+ return None
82
+ return "true" if parsed else "false"
83
+
84
+
85
+ @dataclass(frozen=True, kw_only=True)
86
+ class LibrationPoint:
87
+ """One nondimensional CRTBP equilibrium-point coordinate."""
88
+
89
+ x: float
90
+ y: float
91
+
92
+
93
+ @dataclass(frozen=True, kw_only=True)
94
+ class LibrationPoints:
95
+ """The five CRTBP equilibrium points and collinear-point distances."""
96
+
97
+ l1: LibrationPoint
98
+ l2: LibrationPoint
99
+ l3: LibrationPoint
100
+ l4: LibrationPoint
101
+ l5: LibrationPoint
102
+ l1_distance_to_secondary: float
103
+ l2_distance_to_secondary: float
104
+ l3_distance_to_primary: float
105
+
106
+
107
+ @dataclass(frozen=True, kw_only=True)
108
+ class LibrationUnitSystem:
109
+ """Dimensional scales for a nondimensional CRTBP system."""
110
+
111
+ primary_gravitational_parameter_m3_s2: float
112
+ secondary_gravitational_parameter_m3_s2: float
113
+ mass_ratio: float
114
+ length_unit_m: float
115
+ time_unit_s: float
116
+ velocity_unit_m_s: float
117
+
118
+
119
+ @dataclass(frozen=True, kw_only=True)
120
+ class CrtbpState:
121
+ """Nondimensional CRTBP position and rotating-frame velocity."""
122
+
123
+ x: float
124
+ y: float
125
+ z: float
126
+ vx: float
127
+ vy: float
128
+ vz: float
129
+
130
+ def __post_init__(self) -> None:
131
+ for field_name in ("x", "y", "z", "vx", "vy", "vz"):
132
+ object.__setattr__(
133
+ self,
134
+ field_name,
135
+ _number(getattr(self, field_name), field=field_name),
136
+ )
137
+
138
+ def to_wire(self) -> list[float]:
139
+ """Lower to ASTROX ``[x, y, z, vx, vy, vz]`` order."""
140
+ return [self.x, self.y, self.z, self.vx, self.vy, self.vz]
141
+
142
+
143
+ @dataclass(frozen=True, kw_only=True)
144
+ class CrtbpSample:
145
+ """One nondimensional CRTBP state at a nondimensional time."""
146
+
147
+ time: float
148
+ state: CrtbpState
149
+
150
+
151
+ @dataclass(frozen=True, kw_only=True)
152
+ class CrtbpTrajectory:
153
+ """A sampled nondimensional CRTBP trajectory."""
154
+
155
+ mass_ratio: float
156
+ is_barycentric: bool
157
+ samples: tuple[CrtbpSample, ...]
158
+
159
+
160
+ @dataclass(frozen=True, kw_only=True)
161
+ class PeriodicOrbit:
162
+ """A corrected periodic CRTBP orbit and one sampled period."""
163
+
164
+ is_barycentric: bool
165
+ period: float
166
+ initial_state: CrtbpState
167
+ corrected_state: CrtbpState
168
+ samples: tuple[CrtbpSample, ...]
169
+
170
+
171
+ def crtbp_state(
172
+ *,
173
+ x: float,
174
+ y: float,
175
+ z: float,
176
+ vx: float,
177
+ vy: float,
178
+ vz: float,
179
+ ) -> CrtbpState:
180
+ """Create a nondimensional rotating-frame CRTBP state."""
181
+ return CrtbpState(x=x, y=y, z=z, vx=vx, vy=vy, vz=vz)
182
+
183
+
184
+ def _state_to_wire(value: CrtbpState, *, parameter: str) -> list[float]:
185
+ if not isinstance(value, CrtbpState):
186
+ raise TypeError(f"{parameter} must be a CrtbpState value")
187
+ return value.to_wire()
188
+
189
+
190
+ def _state_from_wire(value: Any, *, field: str) -> CrtbpState:
191
+ values = _sequence(value, field=field)
192
+ if len(values) != 6:
193
+ raise TypeError(f"{field} must contain six numbers")
194
+ return CrtbpState(
195
+ x=_number(values[0], field=f"{field}[0]"),
196
+ y=_number(values[1], field=f"{field}[1]"),
197
+ z=_number(values[2], field=f"{field}[2]"),
198
+ vx=_number(values[3], field=f"{field}[3]"),
199
+ vy=_number(values[4], field=f"{field}[4]"),
200
+ vz=_number(values[5], field=f"{field}[5]"),
201
+ )
202
+
203
+
204
+ def _points_from_wire(value: Any) -> LibrationPoints:
205
+ values = _sequence(value, field="positions response")
206
+ if len(values) != 10:
207
+ raise TypeError("positions response must contain ten numbers")
208
+ numbers = tuple(
209
+ _number(item, field=f"positions response[{index}]")
210
+ for index, item in enumerate(values)
211
+ )
212
+ return LibrationPoints(
213
+ l1=LibrationPoint(x=numbers[3], y=0.0),
214
+ l2=LibrationPoint(x=numbers[4], y=0.0),
215
+ l3=LibrationPoint(x=numbers[5], y=0.0),
216
+ l4=LibrationPoint(x=numbers[6], y=numbers[8]),
217
+ l5=LibrationPoint(x=numbers[7], y=numbers[9]),
218
+ l1_distance_to_secondary=numbers[0],
219
+ l2_distance_to_secondary=numbers[1],
220
+ l3_distance_to_primary=numbers[2],
221
+ )
222
+
223
+
224
+ def _units_from_wire(value: Any) -> LibrationUnitSystem:
225
+ payload = _mapping(value, field="unit response")
226
+ return LibrationUnitSystem(
227
+ primary_gravitational_parameter_m3_s2=_number(
228
+ payload["GravitationalParameter1"],
229
+ field="GravitationalParameter1",
230
+ ),
231
+ secondary_gravitational_parameter_m3_s2=_number(
232
+ payload["GravitationalParameter2"],
233
+ field="GravitationalParameter2",
234
+ ),
235
+ mass_ratio=_number(payload["U"], field="U"),
236
+ length_unit_m=_number(payload["UnitL"], field="UnitL"),
237
+ time_unit_s=_number(payload["UnitT"], field="UnitT"),
238
+ velocity_unit_m_s=_number(payload["UnitV"], field="UnitV"),
239
+ )
240
+
241
+
242
+ def _trajectory_from_wire(value: Any) -> CrtbpTrajectory:
243
+ payload = _mapping(value, field="crtbp trajectory response")
244
+ positions = _sequence(payload["Positions"], field="Positions")
245
+ if not positions or len(positions) % 7 != 0:
246
+ raise TypeError("Positions must contain one or more seven-number samples")
247
+ samples = tuple(
248
+ CrtbpSample(
249
+ time=_number(positions[index], field=f"Positions[{index}]"),
250
+ state=_state_from_wire(
251
+ positions[index + 1 : index + 7],
252
+ field=f"Positions[{index + 1}:{index + 7}]",
253
+ ),
254
+ )
255
+ for index in range(0, len(positions), 7)
256
+ )
257
+ return CrtbpTrajectory(
258
+ mass_ratio=_number(payload["U"], field="U"),
259
+ is_barycentric=_boolean(payload["IsBarycentric"], field="IsBarycentric"),
260
+ samples=samples,
261
+ )
262
+
263
+
264
+ def _periodic_orbit_from_wire(value: Any) -> PeriodicOrbit:
265
+ payload = _mapping(value, field="periodic orbit response")
266
+ times = _sequence(payload["ListT"], field="ListT")
267
+ states = _sequence(payload["ListX"], field="ListX")
268
+ if not times or len(times) != len(states):
269
+ raise TypeError("ListT and ListX must be non-empty arrays of equal length")
270
+ samples = tuple(
271
+ CrtbpSample(
272
+ time=_number(time, field=f"ListT[{index}]"),
273
+ state=_state_from_wire(state, field=f"ListX[{index}]"),
274
+ )
275
+ for index, (time, state) in enumerate(zip(times, states, strict=True))
276
+ )
277
+ return PeriodicOrbit(
278
+ is_barycentric=_boolean(payload["IsBarycentric"], field="IsBarycentric"),
279
+ period=_number(payload["Period"], field="Period"),
280
+ initial_state=_state_from_wire(payload["InitialX0"], field="InitialX0"),
281
+ corrected_state=_state_from_wire(payload["X0"], field="X0"),
282
+ samples=samples,
283
+ )
284
+
285
+
286
+ def positions(*, mass_ratio: float) -> LibrationPoints:
287
+ """Return the five barycentric CRTBP equilibrium points."""
288
+ return _points_from_wire(
289
+ raw.get(
290
+ "/libration/positions",
291
+ params={"u": _number(mass_ratio, field="mass_ratio")},
292
+ )
293
+ )
294
+
295
+
296
+ def units(
297
+ *,
298
+ primary_gravitational_parameter_m3_s2: float | None = None,
299
+ secondary_gravitational_parameter_m3_s2: float | None = None,
300
+ mean_separation_m: float | None = None,
301
+ ) -> LibrationUnitSystem:
302
+ """Return the dimensional scales for a CRTBP primary-secondary system."""
303
+ params: dict[str, Any] = {}
304
+ _include_if_supplied(
305
+ params,
306
+ "gm1",
307
+ _optional_number(
308
+ primary_gravitational_parameter_m3_s2,
309
+ parameter="primary_gravitational_parameter_m3_s2",
310
+ ),
311
+ )
312
+ _include_if_supplied(
313
+ params,
314
+ "gm2",
315
+ _optional_number(
316
+ secondary_gravitational_parameter_m3_s2,
317
+ parameter="secondary_gravitational_parameter_m3_s2",
318
+ ),
319
+ )
320
+ _include_if_supplied(
321
+ params,
322
+ "meanRange",
323
+ _optional_number(mean_separation_m, parameter="mean_separation_m"),
324
+ )
325
+ return _units_from_wire(raw.get("/libration/unit", params=params))
326
+
327
+
328
+ def crtbp_trajectory(
329
+ *,
330
+ initial_state: CrtbpState,
331
+ mass_ratio: float,
332
+ start_time: float | None = None,
333
+ end_time: float | None = None,
334
+ barycentric: bool | None = None,
335
+ output_step: float | None = None,
336
+ ) -> CrtbpTrajectory:
337
+ """Integrate a nondimensional CRTBP state in the rotating frame."""
338
+ payload: dict[str, Any] = {
339
+ "RV0": _state_to_wire(initial_state, parameter="initial_state"),
340
+ "U": _number(mass_ratio, field="mass_ratio"),
341
+ }
342
+ _include_if_supplied(
343
+ payload,
344
+ "T0",
345
+ _optional_number(start_time, parameter="start_time"),
346
+ )
347
+ _include_if_supplied(
348
+ payload,
349
+ "TEnd",
350
+ _optional_number(end_time, parameter="end_time"),
351
+ )
352
+ _include_if_supplied(
353
+ payload,
354
+ "IsBarycentric",
355
+ _optional_boolean(barycentric, parameter="barycentric"),
356
+ )
357
+ _include_if_supplied(
358
+ payload,
359
+ "OutStep",
360
+ _optional_number(output_step, parameter="output_step"),
361
+ )
362
+ return _trajectory_from_wire(
363
+ raw.post("/libration/crtbp-trajectory", json=payload)
364
+ )
365
+
366
+
367
+ def earth_moon_l1_halo(
368
+ *,
369
+ z_amplitude: float | None = None,
370
+ southern: bool | None = None,
371
+ ) -> PeriodicOrbit:
372
+ """Return a corrected Earth-Moon L1 Halo orbit."""
373
+ params: dict[str, Any] = {}
374
+ _include_if_supplied(
375
+ params,
376
+ "az",
377
+ _optional_number(z_amplitude, parameter="z_amplitude"),
378
+ )
379
+ _include_if_supplied(
380
+ params,
381
+ "isSouth",
382
+ _boolean_query(southern, parameter="southern"),
383
+ )
384
+ return _periodic_orbit_from_wire(
385
+ raw.get("/libration/em-l1-halo", params=params)
386
+ )
387
+
388
+
389
+ def earth_moon_l2_halo(
390
+ *,
391
+ x_amplitude: float | None = None,
392
+ southern: bool | None = None,
393
+ ) -> PeriodicOrbit:
394
+ """Return a corrected Earth-Moon L2 Halo orbit."""
395
+ params: dict[str, Any] = {}
396
+ _include_if_supplied(
397
+ params,
398
+ "ax",
399
+ _optional_number(x_amplitude, parameter="x_amplitude"),
400
+ )
401
+ _include_if_supplied(
402
+ params,
403
+ "isSouth",
404
+ _boolean_query(southern, parameter="southern"),
405
+ )
406
+ return _periodic_orbit_from_wire(
407
+ raw.get("/libration/em-l2-halo", params=params)
408
+ )
409
+
410
+
411
+ def earth_moon_dro(*, x_amplitude: float | None = None) -> PeriodicOrbit:
412
+ """Return a corrected planar Earth-Moon distant retrograde orbit."""
413
+ params: dict[str, Any] = {}
414
+ _include_if_supplied(
415
+ params,
416
+ "ax",
417
+ _optional_number(x_amplitude, parameter="x_amplitude"),
418
+ )
419
+ return _periodic_orbit_from_wire(raw.get("/libration/em-dro", params=params))
420
+
421
+
422
+ def correct_periodic_orbit_fixed_x(
423
+ *,
424
+ initial_state: CrtbpState,
425
+ period_guess: float,
426
+ mass_ratio: float,
427
+ barycentric: bool | None = None,
428
+ output_step: float | None = None,
429
+ ) -> PeriodicOrbit:
430
+ """Correct an XZ-plane-crossing seed while holding its x coordinate fixed."""
431
+ payload: dict[str, Any] = {
432
+ "RV0": _state_to_wire(initial_state, parameter="initial_state"),
433
+ "TEnd": _number(period_guess, field="period_guess"),
434
+ "U": _number(mass_ratio, field="mass_ratio"),
435
+ }
436
+ _include_if_supplied(
437
+ payload,
438
+ "IsBarycentric",
439
+ _optional_boolean(barycentric, parameter="barycentric"),
440
+ )
441
+ _include_if_supplied(
442
+ payload,
443
+ "OutStep",
444
+ _optional_number(output_step, parameter="output_step"),
445
+ )
446
+ return _periodic_orbit_from_wire(
447
+ raw.post("/libration/crtbp-period-orbit-fixed-x", json=payload)
448
+ )
astrox/orbits.py CHANGED
@@ -11,6 +11,7 @@ from astrox._http import raw
11
11
  __all__ = [
12
12
  "CartesianState",
13
13
  "KeplerianElements",
14
+ "LambertResult",
14
15
  "MeanKeplerianElements",
15
16
  "Tle",
16
17
  "cartesian_state",
@@ -517,14 +518,37 @@ def geo_ym_lambert_delta_v(
517
518
  )
518
519
 
519
520
 
521
+ @dataclass(frozen=True, kw_only=True)
522
+ class LambertResult:
523
+ """Single-case Lambert solution with the sampled transfer positions."""
524
+
525
+ departure_delta_v_m_s: tuple[float, float, float]
526
+ arrival_delta_v_m_s: tuple[float, float, float]
527
+ positions: tuple[float, ...]
528
+ """Flat ``(x, y, z, ...)`` transfer position sequence in meters.
529
+
530
+ The server samples ``path_point_count`` points including the departure and
531
+ arrival positions, so the sequence length is ``path_point_count * 3``.
532
+ """
533
+
534
+
520
535
  def lambert_delta_v(
521
536
  *,
522
537
  departure_state: CartesianState,
523
538
  arrival_state: CartesianState,
524
539
  time_of_flight_s: float,
525
540
  gravitational_parameter_m3_s2: float | None = None,
526
- ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
527
- """Return single-revolution Lambert delta-v between two Cartesian states."""
541
+ get_path_points: bool = False,
542
+ path_point_count: int | None = None,
543
+ ) -> tuple[tuple[float, float, float], tuple[float, float, float]] | LambertResult:
544
+ """Return single-revolution Lambert delta-v between two Cartesian states.
545
+
546
+ By default returns ``(departure_delta_v_m_s, arrival_delta_v_m_s)``. When
547
+ ``get_path_points`` is true, the server also samples the transfer orbit and
548
+ the return is a :class:`LambertResult` carrying the delta-v pair plus the
549
+ flat position sequence; ``path_point_count`` sets the sample count
550
+ (server default 100, including both endpoints).
551
+ """
528
552
  if not isinstance(departure_state, CartesianState):
529
553
  raise TypeError("departure_state must be a CartesianState instance")
530
554
  if not isinstance(arrival_state, CartesianState):
@@ -536,12 +560,20 @@ def lambert_delta_v(
536
560
  "TOF": [time_of_flight_s],
537
561
  }
538
562
  _include_if_supplied(payload, "Gm", gravitational_parameter_m3_s2)
563
+ if get_path_points:
564
+ payload["GetPathPoints"] = True
565
+ _include_if_supplied(payload, "NumberOfPathPoints", path_point_count)
539
566
 
540
567
  result = raw.post("/orbit/lambert", json=payload)
541
- return (
542
- (result["DV1"][0], result["DV1"][1], result["DV1"][2]),
543
- (result["DV2"][0], result["DV2"][1], result["DV2"][2]),
544
- )
568
+ departure_delta_v = (result["DV1"][0], result["DV1"][1], result["DV1"][2])
569
+ arrival_delta_v = (result["DV2"][0], result["DV2"][1], result["DV2"][2])
570
+ if get_path_points:
571
+ return LambertResult(
572
+ departure_delta_v_m_s=departure_delta_v,
573
+ arrival_delta_v_m_s=arrival_delta_v,
574
+ positions=tuple(result["Positions"]),
575
+ )
576
+ return departure_delta_v, arrival_delta_v
545
577
 
546
578
 
547
579
  def convert_czml_position(
astrox/propagator.py CHANGED
@@ -42,6 +42,7 @@ __all__ = [
42
42
  "sgp4",
43
43
  "simple_ascent",
44
44
  "two_body",
45
+ "two_body_rv",
45
46
  ]
46
47
 
47
48
 
@@ -672,6 +673,36 @@ def two_body(
672
673
  return _success_path(result)
673
674
 
674
675
 
676
+ def two_body_rv(
677
+ *,
678
+ state: CartesianState,
679
+ time_of_flight_s: float,
680
+ gravitational_parameter_m3_s2: float | None = None,
681
+ step_s: float | None = None,
682
+ ) -> tuple[float, ...]:
683
+ """Propagate a Cartesian state with two-body dynamics and return the sampled ephemeris.
684
+
685
+ Returns the flat ``[t, x, y, z, vx, vy, vz, ...]`` sequence, where ``t`` is
686
+ seconds relative to the initial epoch, positions are in meters, and
687
+ velocities are in m/s. The sequence includes the start and stop samples;
688
+ intermediate samples follow ``step_s`` (server default 60 s).
689
+ """
690
+ if not isinstance(state, CartesianState):
691
+ raise TypeError("state must be a CartesianState instance")
692
+
693
+ payload: dict[str, Any] = {
694
+ "RV0": state.to_wire(),
695
+ "TimeOfFlight": time_of_flight_s,
696
+ }
697
+ if gravitational_parameter_m3_s2 is not None:
698
+ payload["Gm"] = gravitational_parameter_m3_s2
699
+ if step_s is not None:
700
+ payload["Step"] = step_s
701
+
702
+ result = raw.post("/Propagator/TwoBodyRV", json=payload)
703
+ return tuple(result["Positions"])
704
+
705
+
675
706
  def j2(
676
707
  *,
677
708
  start: str,
astrox/terrain.py CHANGED
@@ -45,6 +45,16 @@ def _optional_integer(value: int | None, *, parameter: str) -> int | None:
45
45
  return value
46
46
 
47
47
 
48
+ def _without_status_fields(value: Any, *, endpoint: str) -> dict[str, Any]:
49
+ if not isinstance(value, dict):
50
+ raise TypeError(f"{endpoint} response must be an object")
51
+ return {
52
+ key: item
53
+ for key, item in value.items()
54
+ if key not in {"IsSuccess", "Message"}
55
+ }
56
+
57
+
48
58
  def _site_position_to_wire(
49
59
  value: components.SitePosition,
50
60
  *,
@@ -164,7 +174,10 @@ def _mask_request(
164
174
  "TerrainMaskPara",
165
175
  _terrain_mask_config_to_wire(config, parameter="config"),
166
176
  )
167
- return raw.post(endpoint, json=payload)
177
+ return _without_status_fields(
178
+ raw.post(endpoint, json=payload),
179
+ endpoint=endpoint,
180
+ )
168
181
 
169
182
 
170
183
  def azimuth_elevation_mask(
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: astrox-python
3
- Version: 0.2.0
3
+ Version: 1.1.0
4
4
  Summary: Python SDK for the ASTROX Web API.
5
5
  Project-URL: Homepage, https://github.com/Mtrya/astrox-python
6
6
  Project-URL: Repository, https://github.com/Mtrya/astrox-python.git
@@ -10,7 +10,7 @@ Author-email: Mtrya <erchanmion20@outlook.com>
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
12
12
  Keywords: astrodynamics,astrox,orbit,propagation,sdk,space
13
- Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Development Status :: 5 - Production/Stable
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: Intended Audience :: Science/Research
16
16
  Classifier: License :: OSI Approved :: MIT License
@@ -1,16 +1,17 @@
1
- astrox/__init__.py,sha256=9wJZtYZQoUqgmSBDMYGXQHskNmZDqcTbGV-KbK0eeIU,1523
1
+ astrox/__init__.py,sha256=2OlHH2Bx83mXoazHMd6hOD3laKNqrYQrgK-JNmRvtAM,1566
2
2
  astrox/_http.py,sha256=ZZDkJ6qPG4VbMDRZO7xonIib88aYS6PPTVRxRYwXU3A,15606
3
3
  astrox/access.py,sha256=pjyCher_yQNmkoojf2sWpG1HRDnLLK9gI90jM1iEoTo,5817
4
4
  astrox/cat.py,sha256=doZqy6lGDtgrVe9QlU57l1lccmzirc14RDSR41tyGUU,15057
5
- astrox/catalog.py,sha256=OK1Qp9J4CJBkD7sn4qpZCuXT78VgAzMY0s1pZLMkPIk,4754
6
- astrox/celestial.py,sha256=rOCRBQukYPJSkUcSHYzDTeSrtj0HmpmKLyoLiBIoefc,4163
5
+ astrox/catalog.py,sha256=WDb3adiDO41YY6IdivjN3ri1vIQMHi6DPI3dBa8W1Gc,5255
6
+ astrox/celestial.py,sha256=ILZjn1MrjYS4cqpp4aBDnwJGBkAv5dW-V87eh6bJFxg,12444
7
7
  astrox/conjunction.py,sha256=sXhTNAlMai27ejuJekksaAw-850nHPimo21YdFpuzQo,9108
8
8
  astrox/exceptions.py,sha256=apClRT1r-N3XY1ibXxkW3oxpsJgiHos4tC0cMJ8eBRk,2592
9
+ astrox/libration.py,sha256=YBgcZi2pTAjL5VUlE8y6jLhVbYnwRxv-PIUxJQmFBSg,13454
9
10
  astrox/lighting.py,sha256=aWMty64rU3wv1H1Q5q-yXG5mINbIY31omyd-pjdNaVs,3876
10
- astrox/orbits.py,sha256=JiSkCyWqYmeV-yX5WBL9554_JNhec1Li8EdmkZmzXQc,17823
11
- astrox/propagator.py,sha256=kiW4kFSBy2_quZPZ2VqP49vfSYDKkJ1dna5rM5bo-9M,37334
11
+ astrox/orbits.py,sha256=SdRE7f9W9zO4N6Xxjg5quHK--gsVh1E5qdvJigpwxLA,19223
12
+ astrox/propagator.py,sha256=GPcg5fpra_jsof4ipxgtaeJNof2PrWBvUTnDG1aH940,38434
12
13
  astrox/rocket.py,sha256=tb6wXc4bZ0EMAd9bVzva_7k3I9S2lsYA7Q2RD6byqfk,2755
13
- astrox/terrain.py,sha256=mAcjIAVjUMch4z8M1asMdmtof-7ox-tpXE218sPR2jY,5777
14
+ astrox/terrain.py,sha256=2v_Ca5OnXjmMtIKNFQJ_Kbdsh42ZuIc4ZDe-0aWnpdg,6146
14
15
  astrox/astrogator/__init__.py,sha256=rgIHos9rHjQ3i-qkpLeLRwZL6zsVthc2bOVbAdwHWHU,267
15
16
  astrox/astrogator/_api.py,sha256=ovfBHzstXmhLj1QKIFFSpraEQb5ZRlMlAwaUCXvIiqA,1882
16
17
  astrox/astrogator/_models.py,sha256=jIFts5jH6RjAb6NneU4WLqpbwQU2IWGX_1h3OMs8Tww,50620
@@ -32,7 +33,7 @@ astrox/coverage/number_of_assets.py,sha256=Bf1OQRu1r9lunyARyQoV4x7KetkgY_7OMNcEc
32
33
  astrox/coverage/response_time.py,sha256=yLnv8eEDL6CS0PJHC1JIKBCoOachOwo3m8dNoHH3HfQ,5164
33
34
  astrox/coverage/revisit_time.py,sha256=R5vF2egixCF9elvsGYXzpgWbD5lfcQW8uZqi1ifIDX8,5155
34
35
  astrox/coverage/simple_coverage.py,sha256=ZPj3HrP3M59rG47pz4eHt1rq-vTlk-oZ7c2ZBmFsz1U,4992
35
- astrox_python-0.2.0.dist-info/METADATA,sha256=wgkeBpIfyYGbDhlj84CC55b2WR84zkKrrIFKAI-ha9c,2508
36
- astrox_python-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
37
- astrox_python-0.2.0.dist-info/licenses/LICENSE,sha256=Fbt55lCQnv_n5yGhbG_VDlowjaV_FPHBQWsJIyuBpDk,1062
38
- astrox_python-0.2.0.dist-info/RECORD,,
36
+ astrox_python-1.1.0.dist-info/METADATA,sha256=2oZePQ_YT4pqPYJP2PqhKlPrLxWt3YfcxUml36z_9pQ,2520
37
+ astrox_python-1.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
38
+ astrox_python-1.1.0.dist-info/licenses/LICENSE,sha256=Fbt55lCQnv_n5yGhbG_VDlowjaV_FPHBQWsJIyuBpDk,1062
39
+ astrox_python-1.1.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any