|
| 1 | +from typing import Any, Dict, List, Literal, Union |
| 2 | + |
| 3 | +import numpy as np |
| 4 | +import numpy.typing as npt |
| 5 | +import quivr as qv |
| 6 | +import requests |
| 7 | + |
| 8 | +from ...coordinates import CoordinateCovariances, KeplerianCoordinates, Origin |
| 9 | +from ...orbits import Orbits |
| 10 | +from ...time import Timestamp |
| 11 | + |
| 12 | + |
| 13 | +def _upper_triangular_to_full( |
| 14 | + upper_triangular: npt.NDArray[np.float64], |
| 15 | +) -> npt.NDArray[np.float64]: |
| 16 | + """ |
| 17 | + Convert an upper triangular matrix containing 21 elements to a full 6x6 matrix. |
| 18 | + """ |
| 19 | + assert len(upper_triangular) == 21 |
| 20 | + |
| 21 | + full = np.zeros((6, 6)) |
| 22 | + full[np.triu_indices(6)] = upper_triangular |
| 23 | + full[np.tril_indices(6, -1)] = full.T[np.tril_indices(6, -1)] |
| 24 | + return full |
| 25 | + |
| 26 | + |
| 27 | +def _parse_oef(data: str) -> Dict[str, Any]: |
| 28 | + """ |
| 29 | + Parse a OEF file and return the stored orbital elements. |
| 30 | +
|
| 31 | + Parameters |
| 32 | + ---------- |
| 33 | + data: str |
| 34 | + The content of the OEF file. |
| 35 | +
|
| 36 | + Returns |
| 37 | + ------- |
| 38 | + Dict[str, Any] |
| 39 | + Dictionary containing the parsed orbital elements and metadata. |
| 40 | +
|
| 41 | +
|
| 42 | + Examples |
| 43 | + -------- |
| 44 | + format = 'OEF2.0' ! file format |
| 45 | + rectype = 'ML' ! record type (1L/ML) |
| 46 | + refsys = ECLM J2000 ! default reference system |
| 47 | + END_OF_HEADER |
| 48 | + 2024YR4 |
| 49 | + ! Keplerian elements: a, e, i, long. node, arg. peric., mean anomaly |
| 50 | + KEP 2.5158127507489616E+00 6.6154036821914619E-01 3.4081393687180 271.3655954496424 134.3614240204325 4.0403920526717883E+01 |
| 51 | + MJD 60800.000000000 TDT |
| 52 | + MAG 23.876 0.150 |
| 53 | + ! Non-gravitational parameters: model used, number of model parameters, dimension |
| 54 | + LSP 0 0 6 |
| 55 | + ! PERIHELION 8.5150105724807035E-01 |
| 56 | + ! APHELION 4.1801244442498522E+00 |
| 57 | + ! ANODE 1.6132920678553648E+00 |
| 58 | + ! DNODE -1.6139338737144644E-02 |
| 59 | + ! MOID 2.8281976977061222E-03 |
| 60 | + ! PERIOD 1.4575246142278593E+03 |
| 61 | + ! PHA F |
| 62 | + ! VINFTY 14.2161102117779 |
| 63 | + ! U_PAR 5.5 |
| 64 | + ! ORB_TYPE Apollo |
| 65 | + ! RMS 1.45945E-04 2.08511E-05 7.80533E-05 1.08159E-05 9.07220E-05 3.57225E-03 |
| 66 | + COV 2.129990103626278E-08 3.043103695236090E-09 1.138994073085263E-08 |
| 67 | + COV -1.297300567885438E-09 -1.321094812357632E-08 -5.213516734886736E-07 |
| 68 | + COV 4.347664336862408E-10 1.627274457493249E-09 -1.853526029216412E-10 |
| 69 | + COV -1.887473042074563E-09 -7.448518590163292E-08 6.092321667952164E-09 |
| 70 | + COV -6.879908552449990E-10 -7.069797588501348E-09 -2.787885242390572E-07 |
| 71 | + COV 1.169847013231039E-10 7.781995171805923E-10 3.175091792990313E-08 |
| 72 | + COV 8.230474520730192E-09 3.233601008844550E-07 1.276097850815426E-05 |
| 73 | + COR 1.000000000000000E+00 9.999998967955239E-01 9.998647520507938E-01 |
| 74 | + COR -8.218400094356861E-01 -9.977753189147101E-01 -9.999999781092901E-01 |
| 75 | + COR 9.999999999999999E-01 9.998650578225570E-01 -8.218757198089662E-01 |
| 76 | + COR -9.977926872410703E-01 -9.999998019539900E-01 9.999999999999998E-01 |
| 77 | + COR -8.149420314930980E-01 -9.983966836512449E-01 -9.998652884032685E-01 |
| 78 | + COR 1.000000000000000E+00 7.930744967922404E-01 8.217690139449345E-01 |
| 79 | + COR 1.000000000000000E+00 9.977735927640640E-01 1.000000000000000E+00 |
| 80 | +
|
| 81 | + """ |
| 82 | + lines = data.strip().split("\n") |
| 83 | + result = {} |
| 84 | + |
| 85 | + # Parse header |
| 86 | + header = {} |
| 87 | + for line in lines: |
| 88 | + line = line.strip() |
| 89 | + if line == "END_OF_HEADER": |
| 90 | + break |
| 91 | + if "=" in line: |
| 92 | + key, value = line.split("=", 1) |
| 93 | + header[key.strip()] = value.split("!")[0].strip().strip("'") |
| 94 | + result["header"] = header |
| 95 | + |
| 96 | + # Find object ID |
| 97 | + for line in lines: |
| 98 | + if not line.startswith( |
| 99 | + ("!", " ", "format", "rectype", "refsys", "END_OF_HEADER") |
| 100 | + ): |
| 101 | + result["object_id"] = line.strip() |
| 102 | + break |
| 103 | + |
| 104 | + # Parse Keplerian elements |
| 105 | + for line in lines: |
| 106 | + if line.strip().startswith("KEP"): |
| 107 | + elements = line.split()[1:] |
| 108 | + result["elements"] = { |
| 109 | + "a": float(elements[0]), # semi-major axis |
| 110 | + "e": float(elements[1]), # eccentricity |
| 111 | + "i": float(elements[2]), # inclination |
| 112 | + "node": float(elements[3]), # longitude of ascending node |
| 113 | + "peri": float(elements[4]), # argument of perihelion |
| 114 | + "M": float(elements[5]), # mean anomaly |
| 115 | + } |
| 116 | + |
| 117 | + # Parse epoch |
| 118 | + for line in lines: |
| 119 | + if line.strip().startswith("MJD"): |
| 120 | + result["epoch"] = float(line.split()[1]) |
| 121 | + result["time_system"] = line.split()[2] |
| 122 | + |
| 123 | + # Parse magnitude |
| 124 | + for line in lines: |
| 125 | + if line.strip().startswith("MAG"): |
| 126 | + mag_data = line.split()[1:] |
| 127 | + result["magnitude"] = { |
| 128 | + "value": float(mag_data[0]), |
| 129 | + "uncertainty": float(mag_data[1]), |
| 130 | + } |
| 131 | + |
| 132 | + # Parse derived parameters (marked with !) |
| 133 | + derived = {} |
| 134 | + for line in lines: |
| 135 | + if line.strip().startswith("!") and len(line.split()) >= 3: |
| 136 | + key = line.split()[1].lower() |
| 137 | + try: |
| 138 | + value = float(line.split()[2]) |
| 139 | + derived[key] = value |
| 140 | + except ValueError: |
| 141 | + derived[key] = line.split()[2] |
| 142 | + result["derived"] = derived |
| 143 | + |
| 144 | + # Parse covariance matrix |
| 145 | + cov_matrix = [] |
| 146 | + for line in lines: |
| 147 | + if line.strip().startswith("COV"): |
| 148 | + cov_matrix.extend([float(x) for x in line.split()[1:]]) |
| 149 | + if cov_matrix: |
| 150 | + # Upper triangular matrix with order |
| 151 | + # (1,1) (1,2) (1,3) |
| 152 | + # (1,4) (1,5) (1,6) |
| 153 | + # (2,2) (2,3) (2,4) |
| 154 | + # (2,5) (2,6) (3,3) |
| 155 | + # (3,4) (3,5) (3,6) |
| 156 | + # (4,4) (4,5) (4,6) |
| 157 | + # (5,5) (5,6) (6,6) |
| 158 | + result["covariance"] = _upper_triangular_to_full(np.array(cov_matrix)) |
| 159 | + |
| 160 | + # Parse correlation matrix |
| 161 | + cor_matrix = [] |
| 162 | + for line in lines: |
| 163 | + if line.strip().startswith("COR"): |
| 164 | + cor_matrix.extend([float(x) for x in line.split()[1:]]) |
| 165 | + if cor_matrix: |
| 166 | + result["correlation"] = _upper_triangular_to_full(np.array(cor_matrix)) |
| 167 | + |
| 168 | + return result |
| 169 | + |
| 170 | + |
| 171 | +def query_neocc( |
| 172 | + object_ids: Union[List, npt.ArrayLike], |
| 173 | + orbit_type: Literal["ke", "eq"] = "ke", |
| 174 | + orbit_epoch: Literal["middle", "present-day"] = "present-day", |
| 175 | +) -> Orbits: |
| 176 | + """ |
| 177 | + Query ESA's Near-Earth Object Coordination Centre (NEOCC) database for orbital elements of the specified NEOs. |
| 178 | +
|
| 179 | + Parameters |
| 180 | + ---------- |
| 181 | + object_ids : Union[List, npt.ArrayLike] |
| 182 | + Object IDs / designations recognizable by NEOCC. |
| 183 | + orbit_type : ["ke", "eq"] |
| 184 | + Type of orbital elements to query. |
| 185 | + orbit_epoch : ["middle", "present-day"] |
| 186 | + Epoch of the orbital elements to query. |
| 187 | +
|
| 188 | + Returns |
| 189 | + ------- |
| 190 | + orbits : `~adam_core.orbits.Orbits` |
| 191 | + Orbits object containing the orbital elements of the specified NEOs. |
| 192 | + """ |
| 193 | + base_url = "https://neo.ssa.esa.int/PSDB-portlet/download" |
| 194 | + |
| 195 | + if orbit_type == "eq": |
| 196 | + raise NotImplementedError("Equinoctial elements are not supported yet.") |
| 197 | + |
| 198 | + if orbit_epoch == "middle": |
| 199 | + orbit_epoch = 0 |
| 200 | + elif orbit_epoch == "present-day": |
| 201 | + orbit_epoch = 1 |
| 202 | + else: |
| 203 | + raise ValueError(f"Invalid orbit epoch: {orbit_epoch}") |
| 204 | + |
| 205 | + orbits = Orbits.empty() |
| 206 | + |
| 207 | + for object_id in object_ids: |
| 208 | + |
| 209 | + # Clean object ID so that there are no spaces |
| 210 | + object_id = object_id.replace(" ", "") |
| 211 | + |
| 212 | + params = {"file": f"{object_id}.{orbit_type}{orbit_epoch}"} |
| 213 | + |
| 214 | + response = requests.get(base_url, params=params) |
| 215 | + response.raise_for_status() |
| 216 | + |
| 217 | + data = _parse_oef(response.text) |
| 218 | + if orbit_type == "ke": |
| 219 | + |
| 220 | + time_scale = data["time_system"] |
| 221 | + if time_scale == "TDT": |
| 222 | + time_scale = "tt" |
| 223 | + else: |
| 224 | + raise ValueError(f"Unsupported time scale: {time_scale}") |
| 225 | + |
| 226 | + if data["header"]["refsys"] != "ECLM J2000": |
| 227 | + raise ValueError( |
| 228 | + f"Unsupported reference system: {data['header']['refsys']}" |
| 229 | + ) |
| 230 | + |
| 231 | + orbit = Orbits.from_kwargs( |
| 232 | + orbit_id=[data["object_id"]], |
| 233 | + object_id=[data["object_id"]], |
| 234 | + coordinates=KeplerianCoordinates.from_kwargs( |
| 235 | + a=[data["elements"]["a"]], |
| 236 | + e=[data["elements"]["e"]], |
| 237 | + i=[data["elements"]["i"]], |
| 238 | + raan=[data["elements"]["node"]], |
| 239 | + ap=[data["elements"]["peri"]], |
| 240 | + M=[data["elements"]["M"]], |
| 241 | + time=Timestamp.from_mjd([data["epoch"]], scale=time_scale), |
| 242 | + covariance=CoordinateCovariances.from_matrix( |
| 243 | + data["covariance"].reshape( |
| 244 | + 1, |
| 245 | + 6, |
| 246 | + 6, |
| 247 | + ) |
| 248 | + ), |
| 249 | + frame="ecliptic", |
| 250 | + origin=Origin.from_kwargs(code=["SUN"]), |
| 251 | + ).to_cartesian(), |
| 252 | + ) |
| 253 | + orbits = qv.concatenate([orbits, orbit]) |
| 254 | + |
| 255 | + return orbits |
0 commit comments