imap-processing 0.12.0__py3-none-any.whl → 0.13.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.

Potentially problematic release.


This version of imap-processing might be problematic. Click here for more details.

Files changed (272) hide show
  1. imap_processing/__init__.py +1 -0
  2. imap_processing/_version.py +2 -2
  3. imap_processing/ccsds/ccsds_data.py +1 -2
  4. imap_processing/ccsds/excel_to_xtce.py +1 -2
  5. imap_processing/cdf/config/imap_codice_global_cdf_attrs.yaml +18 -12
  6. imap_processing/cdf/config/imap_codice_l1a_variable_attrs.yaml +569 -0
  7. imap_processing/cdf/config/imap_codice_l1b_variable_attrs.yaml +1846 -128
  8. imap_processing/cdf/config/imap_hit_global_cdf_attrs.yaml +5 -5
  9. imap_processing/cdf/config/imap_idex_global_cdf_attrs.yaml +20 -1
  10. imap_processing/cdf/config/imap_idex_l1a_variable_attrs.yaml +6 -4
  11. imap_processing/cdf/config/imap_idex_l1b_variable_attrs.yaml +3 -3
  12. imap_processing/cdf/config/imap_mag_global_cdf_attrs.yaml +15 -0
  13. imap_processing/cdf/config/imap_swapi_variable_attrs.yaml +22 -0
  14. imap_processing/cdf/config/imap_swe_l1b_variable_attrs.yaml +16 -0
  15. imap_processing/cdf/config/imap_ultra_global_cdf_attrs.yaml +178 -5
  16. imap_processing/cdf/config/imap_ultra_l1a_variable_attrs.yaml +5045 -41
  17. imap_processing/cdf/config/imap_ultra_l1b_variable_attrs.yaml +33 -19
  18. imap_processing/cdf/config/imap_ultra_l1c_variable_attrs.yaml +8 -48
  19. imap_processing/cdf/utils.py +41 -33
  20. imap_processing/cli.py +463 -234
  21. imap_processing/codice/codice_l1a.py +260 -47
  22. imap_processing/codice/codice_l1b.py +51 -152
  23. imap_processing/codice/constants.py +38 -1
  24. imap_processing/ena_maps/ena_maps.py +658 -65
  25. imap_processing/ena_maps/utils/coordinates.py +1 -1
  26. imap_processing/ena_maps/utils/spatial_utils.py +10 -5
  27. imap_processing/glows/l1a/glows_l1a.py +28 -99
  28. imap_processing/glows/l1a/glows_l1a_data.py +2 -2
  29. imap_processing/glows/l1b/glows_l1b.py +1 -4
  30. imap_processing/glows/l1b/glows_l1b_data.py +1 -3
  31. imap_processing/glows/l2/glows_l2.py +2 -5
  32. imap_processing/hi/l1a/hi_l1a.py +31 -12
  33. imap_processing/hi/l1b/hi_l1b.py +80 -43
  34. imap_processing/hi/l1c/hi_l1c.py +12 -16
  35. imap_processing/hit/ancillary/imap_hit_l1b-to-l2-sector-dt0-factors_20250219_v002.csv +81 -0
  36. imap_processing/hit/hit_utils.py +93 -35
  37. imap_processing/hit/l0/decom_hit.py +3 -1
  38. imap_processing/hit/l1a/hit_l1a.py +30 -25
  39. imap_processing/hit/l1b/constants.py +6 -2
  40. imap_processing/hit/l1b/hit_l1b.py +279 -318
  41. imap_processing/hit/l2/constants.py +37 -0
  42. imap_processing/hit/l2/hit_l2.py +373 -264
  43. imap_processing/ialirt/l0/parse_mag.py +138 -10
  44. imap_processing/ialirt/l0/process_swapi.py +69 -0
  45. imap_processing/ialirt/l0/process_swe.py +318 -22
  46. imap_processing/ialirt/packet_definitions/ialirt.xml +216 -212
  47. imap_processing/ialirt/packet_definitions/ialirt_codicehi.xml +1 -1
  48. imap_processing/ialirt/packet_definitions/ialirt_codicelo.xml +1 -1
  49. imap_processing/ialirt/packet_definitions/ialirt_swapi.xml +14 -14
  50. imap_processing/ialirt/utils/grouping.py +1 -1
  51. imap_processing/idex/idex_constants.py +9 -1
  52. imap_processing/idex/idex_l0.py +22 -8
  53. imap_processing/idex/idex_l1a.py +75 -44
  54. imap_processing/idex/idex_l1b.py +9 -8
  55. imap_processing/idex/idex_l2a.py +79 -45
  56. imap_processing/idex/idex_l2b.py +120 -0
  57. imap_processing/idex/idex_variable_unpacking_and_eu_conversion.csv +33 -39
  58. imap_processing/idex/packet_definitions/idex_housekeeping_packet_definition.xml +9130 -0
  59. imap_processing/lo/l0/lo_science.py +1 -2
  60. imap_processing/lo/l1a/lo_l1a.py +1 -4
  61. imap_processing/lo/l1b/lo_l1b.py +527 -6
  62. imap_processing/lo/l1b/tof_conversions.py +11 -0
  63. imap_processing/lo/l1c/lo_l1c.py +1 -4
  64. imap_processing/mag/constants.py +43 -0
  65. imap_processing/mag/imap_mag_sdc_configuration_v001.py +8 -0
  66. imap_processing/mag/l1a/mag_l1a.py +2 -9
  67. imap_processing/mag/l1a/mag_l1a_data.py +10 -10
  68. imap_processing/mag/l1b/mag_l1b.py +84 -17
  69. imap_processing/mag/l1c/interpolation_methods.py +180 -3
  70. imap_processing/mag/l1c/mag_l1c.py +236 -70
  71. imap_processing/mag/l2/mag_l2.py +140 -0
  72. imap_processing/mag/l2/mag_l2_data.py +288 -0
  73. imap_processing/spacecraft/quaternions.py +1 -3
  74. imap_processing/spice/geometry.py +3 -3
  75. imap_processing/spice/kernels.py +0 -276
  76. imap_processing/spice/pointing_frame.py +257 -0
  77. imap_processing/spice/repoint.py +48 -19
  78. imap_processing/spice/spin.py +38 -33
  79. imap_processing/spice/time.py +24 -0
  80. imap_processing/swapi/l1/swapi_l1.py +16 -12
  81. imap_processing/swapi/l2/swapi_l2.py +116 -4
  82. imap_processing/swapi/swapi_utils.py +32 -0
  83. imap_processing/swe/l1a/swe_l1a.py +2 -9
  84. imap_processing/swe/l1a/swe_science.py +8 -11
  85. imap_processing/swe/l1b/swe_l1b.py +898 -23
  86. imap_processing/swe/l2/swe_l2.py +21 -77
  87. imap_processing/swe/utils/swe_constants.py +1 -0
  88. imap_processing/tests/ccsds/test_excel_to_xtce.py +1 -1
  89. imap_processing/tests/cdf/test_utils.py +14 -16
  90. imap_processing/tests/codice/conftest.py +44 -33
  91. imap_processing/tests/codice/data/validation/imap_codice_l1a_hi-pha_20241110193700_v0.0.0.cdf +0 -0
  92. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-pha_20241110193700_v0.0.0.cdf +0 -0
  93. imap_processing/tests/codice/test_codice_l1a.py +20 -11
  94. imap_processing/tests/codice/test_codice_l1b.py +6 -7
  95. imap_processing/tests/conftest.py +78 -22
  96. imap_processing/tests/ena_maps/test_ena_maps.py +462 -33
  97. imap_processing/tests/ena_maps/test_spatial_utils.py +1 -1
  98. imap_processing/tests/glows/conftest.py +10 -14
  99. imap_processing/tests/glows/test_glows_decom.py +4 -4
  100. imap_processing/tests/glows/test_glows_l1a_cdf.py +6 -27
  101. imap_processing/tests/glows/test_glows_l1a_data.py +6 -8
  102. imap_processing/tests/glows/test_glows_l1b.py +11 -11
  103. imap_processing/tests/glows/test_glows_l1b_data.py +5 -5
  104. imap_processing/tests/glows/test_glows_l2.py +2 -8
  105. imap_processing/tests/hi/conftest.py +1 -1
  106. imap_processing/tests/hi/test_hi_l1b.py +10 -12
  107. imap_processing/tests/hi/test_hi_l1c.py +27 -24
  108. imap_processing/tests/hi/test_l1a.py +7 -9
  109. imap_processing/tests/hi/test_science_direct_event.py +2 -2
  110. imap_processing/tests/hit/helpers/l1_validation.py +44 -43
  111. imap_processing/tests/hit/test_decom_hit.py +1 -1
  112. imap_processing/tests/hit/test_hit_l1a.py +9 -9
  113. imap_processing/tests/hit/test_hit_l1b.py +172 -217
  114. imap_processing/tests/hit/test_hit_l2.py +380 -118
  115. imap_processing/tests/hit/test_hit_utils.py +122 -55
  116. imap_processing/tests/hit/validation_data/hit_l1b_standard_sample2_nsrl_v4_3decimals.csv +62 -62
  117. imap_processing/tests/hit/validation_data/sci_sample_raw.csv +1 -1
  118. imap_processing/tests/ialirt/unit/test_decom_ialirt.py +16 -81
  119. imap_processing/tests/ialirt/unit/test_grouping.py +2 -2
  120. imap_processing/tests/ialirt/unit/test_parse_mag.py +71 -16
  121. imap_processing/tests/ialirt/unit/test_process_codicehi.py +3 -3
  122. imap_processing/tests/ialirt/unit/test_process_codicelo.py +3 -10
  123. imap_processing/tests/ialirt/unit/test_process_ephemeris.py +4 -4
  124. imap_processing/tests/ialirt/unit/test_process_hit.py +3 -3
  125. imap_processing/tests/ialirt/unit/test_process_swapi.py +24 -16
  126. imap_processing/tests/ialirt/unit/test_process_swe.py +115 -7
  127. imap_processing/tests/idex/conftest.py +72 -7
  128. imap_processing/tests/idex/test_data/imap_idex_l0_raw_20241206_v001.pkts +0 -0
  129. imap_processing/tests/idex/test_data/imap_idex_l0_raw_20250108_v001.pkts +0 -0
  130. imap_processing/tests/idex/test_idex_l0.py +33 -11
  131. imap_processing/tests/idex/test_idex_l1a.py +50 -23
  132. imap_processing/tests/idex/test_idex_l1b.py +104 -25
  133. imap_processing/tests/idex/test_idex_l2a.py +48 -32
  134. imap_processing/tests/idex/test_idex_l2b.py +93 -0
  135. imap_processing/tests/lo/test_lo_l1a.py +3 -3
  136. imap_processing/tests/lo/test_lo_l1b.py +371 -6
  137. imap_processing/tests/lo/test_lo_l1c.py +1 -1
  138. imap_processing/tests/lo/test_lo_science.py +6 -7
  139. imap_processing/tests/lo/test_star_sensor.py +1 -1
  140. imap_processing/tests/mag/conftest.py +58 -9
  141. imap_processing/tests/mag/test_mag_decom.py +4 -3
  142. imap_processing/tests/mag/test_mag_l1a.py +13 -7
  143. imap_processing/tests/mag/test_mag_l1b.py +9 -9
  144. imap_processing/tests/mag/test_mag_l1c.py +151 -47
  145. imap_processing/tests/mag/test_mag_l2.py +130 -0
  146. imap_processing/tests/mag/test_mag_validation.py +144 -7
  147. imap_processing/tests/mag/validation/L1c/T013/mag-l1b-l1c-t013-magi-normal-in.csv +1217 -0
  148. imap_processing/tests/mag/validation/L1c/T013/mag-l1b-l1c-t013-magi-normal-out.csv +1857 -0
  149. imap_processing/tests/mag/validation/L1c/T013/mag-l1b-l1c-t013-mago-normal-in.csv +1217 -0
  150. imap_processing/tests/mag/validation/L1c/T013/mag-l1b-l1c-t013-mago-normal-out.csv +1857 -0
  151. imap_processing/tests/mag/validation/L1c/T014/mag-l1b-l1c-t014-magi-normal-in.csv +1217 -0
  152. imap_processing/tests/mag/validation/L1c/T014/mag-l1b-l1c-t014-magi-normal-out.csv +1793 -0
  153. imap_processing/tests/mag/validation/L1c/T014/mag-l1b-l1c-t014-mago-normal-in.csv +1217 -0
  154. imap_processing/tests/mag/validation/L1c/T014/mag-l1b-l1c-t014-mago-normal-out.csv +1793 -0
  155. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-magi-burst-in.csv +2561 -0
  156. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-magi-normal-in.csv +961 -0
  157. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-magi-normal-out.csv +1539 -0
  158. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-mago-normal-in.csv +1921 -0
  159. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-mago-normal-out.csv +2499 -0
  160. imap_processing/tests/mag/validation/L1c/T016/mag-l1b-l1c-t016-magi-normal-in.csv +865 -0
  161. imap_processing/tests/mag/validation/L1c/T016/mag-l1b-l1c-t016-magi-normal-out.csv +1196 -0
  162. imap_processing/tests/mag/validation/L1c/T016/mag-l1b-l1c-t016-mago-normal-in.csv +1729 -0
  163. imap_processing/tests/mag/validation/L1c/T016/mag-l1b-l1c-t016-mago-normal-out.csv +3053 -0
  164. imap_processing/tests/mag/validation/L2/imap_mag_l1b_norm-mago_20251017_v002.cdf +0 -0
  165. imap_processing/tests/mag/validation/calibration/imap_mag_l2-calibration-matrices_20251017_v004.cdf +0 -0
  166. imap_processing/tests/mag/validation/calibration/imap_mag_l2-offsets-norm_20251017_20251017_v001.cdf +0 -0
  167. imap_processing/tests/spacecraft/test_quaternions.py +1 -1
  168. imap_processing/tests/spice/test_data/fake_repoint_data.csv +4 -4
  169. imap_processing/tests/spice/test_data/fake_spin_data.csv +11 -11
  170. imap_processing/tests/spice/test_geometry.py +3 -3
  171. imap_processing/tests/spice/test_kernels.py +1 -200
  172. imap_processing/tests/spice/test_pointing_frame.py +185 -0
  173. imap_processing/tests/spice/test_repoint.py +20 -10
  174. imap_processing/tests/spice/test_spin.py +50 -9
  175. imap_processing/tests/spice/test_time.py +14 -0
  176. imap_processing/tests/swapi/lut/imap_swapi_esa-unit-conversion_20250211_v000.csv +73 -0
  177. imap_processing/tests/swapi/lut/imap_swapi_lut-notes_20250211_v000.csv +1025 -0
  178. imap_processing/tests/swapi/test_swapi_l1.py +7 -9
  179. imap_processing/tests/swapi/test_swapi_l2.py +180 -8
  180. imap_processing/tests/swe/lut/checker-board-indices.csv +24 -0
  181. imap_processing/tests/swe/lut/imap_swe_esa-lut_20250301_v000.csv +385 -0
  182. imap_processing/tests/swe/lut/imap_swe_l1b-in-flight-cal_20240510_20260716_v000.csv +3 -0
  183. imap_processing/tests/swe/test_swe_l1a.py +6 -6
  184. imap_processing/tests/swe/test_swe_l1a_science.py +3 -3
  185. imap_processing/tests/swe/test_swe_l1b.py +162 -24
  186. imap_processing/tests/swe/test_swe_l2.py +82 -102
  187. imap_processing/tests/test_cli.py +171 -88
  188. imap_processing/tests/test_utils.py +2 -1
  189. imap_processing/tests/ultra/data/mock_data.py +49 -21
  190. imap_processing/tests/ultra/unit/conftest.py +53 -70
  191. imap_processing/tests/ultra/unit/test_badtimes.py +2 -4
  192. imap_processing/tests/ultra/unit/test_cullingmask.py +4 -6
  193. imap_processing/tests/ultra/unit/test_de.py +3 -10
  194. imap_processing/tests/ultra/unit/test_decom_apid_880.py +27 -76
  195. imap_processing/tests/ultra/unit/test_decom_apid_881.py +15 -16
  196. imap_processing/tests/ultra/unit/test_decom_apid_883.py +12 -10
  197. imap_processing/tests/ultra/unit/test_decom_apid_896.py +202 -55
  198. imap_processing/tests/ultra/unit/test_lookup_utils.py +23 -1
  199. imap_processing/tests/ultra/unit/test_spacecraft_pset.py +3 -4
  200. imap_processing/tests/ultra/unit/test_ultra_l1a.py +84 -307
  201. imap_processing/tests/ultra/unit/test_ultra_l1b.py +30 -12
  202. imap_processing/tests/ultra/unit/test_ultra_l1b_annotated.py +2 -2
  203. imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py +4 -1
  204. imap_processing/tests/ultra/unit/test_ultra_l1b_extended.py +163 -29
  205. imap_processing/tests/ultra/unit/test_ultra_l1c.py +5 -5
  206. imap_processing/tests/ultra/unit/test_ultra_l1c_pset_bins.py +32 -43
  207. imap_processing/tests/ultra/unit/test_ultra_l2.py +230 -0
  208. imap_processing/ultra/constants.py +1 -1
  209. imap_processing/ultra/l0/decom_tools.py +21 -34
  210. imap_processing/ultra/l0/decom_ultra.py +168 -204
  211. imap_processing/ultra/l0/ultra_utils.py +152 -136
  212. imap_processing/ultra/l1a/ultra_l1a.py +55 -243
  213. imap_processing/ultra/l1b/badtimes.py +1 -4
  214. imap_processing/ultra/l1b/cullingmask.py +2 -6
  215. imap_processing/ultra/l1b/de.py +62 -47
  216. imap_processing/ultra/l1b/extendedspin.py +8 -4
  217. imap_processing/ultra/l1b/lookup_utils.py +72 -9
  218. imap_processing/ultra/l1b/ultra_l1b.py +3 -8
  219. imap_processing/ultra/l1b/ultra_l1b_culling.py +4 -4
  220. imap_processing/ultra/l1b/ultra_l1b_extended.py +236 -78
  221. imap_processing/ultra/l1c/histogram.py +2 -6
  222. imap_processing/ultra/l1c/spacecraft_pset.py +2 -4
  223. imap_processing/ultra/l1c/ultra_l1c.py +1 -5
  224. imap_processing/ultra/l1c/ultra_l1c_pset_bins.py +107 -60
  225. imap_processing/ultra/l2/ultra_l2.py +299 -0
  226. imap_processing/ultra/lookup_tables/Angular_Profiles_FM45_LeftSlit.csv +526 -0
  227. imap_processing/ultra/lookup_tables/Angular_Profiles_FM45_RightSlit.csv +526 -0
  228. imap_processing/ultra/lookup_tables/Angular_Profiles_FM90_LeftSlit.csv +526 -0
  229. imap_processing/ultra/lookup_tables/Angular_Profiles_FM90_RightSlit.csv +526 -0
  230. imap_processing/ultra/lookup_tables/FM45_Startup1_ULTRA_IMGPARAMS_20240719.csv +2 -2
  231. imap_processing/ultra/lookup_tables/FM90_Startup1_ULTRA_IMGPARAMS_20240719.csv +2 -0
  232. imap_processing/ultra/packet_definitions/README.md +38 -0
  233. imap_processing/ultra/packet_definitions/ULTRA_SCI_COMBINED.xml +15302 -482
  234. imap_processing/ultra/utils/ultra_l1_utils.py +13 -12
  235. imap_processing/utils.py +1 -1
  236. {imap_processing-0.12.0.dist-info → imap_processing-0.13.0.dist-info}/METADATA +3 -2
  237. {imap_processing-0.12.0.dist-info → imap_processing-0.13.0.dist-info}/RECORD +264 -225
  238. imap_processing/hi/l1b/hi_eng_unit_convert_table.csv +0 -154
  239. imap_processing/mag/imap_mag_sdc-configuration_v001.yaml +0 -6
  240. imap_processing/mag/l1b/__init__.py +0 -0
  241. imap_processing/swe/l1b/swe_esa_lookup_table.csv +0 -1441
  242. imap_processing/swe/l1b/swe_l1b_science.py +0 -699
  243. imap_processing/tests/swe/test_swe_l1b_science.py +0 -103
  244. imap_processing/ultra/lookup_tables/dps_sensitivity45.cdf +0 -0
  245. imap_processing/ultra/lookup_tables/ultra_90_dps_exposure_compressed.cdf +0 -0
  246. /imap_processing/idex/packet_definitions/{idex_packet_definition.xml → idex_science_packet_definition.xml} +0 -0
  247. /imap_processing/tests/ialirt/{test_data → data}/l0/20240827095047_SWE_IALIRT_packet.bin +0 -0
  248. /imap_processing/tests/ialirt/{test_data → data}/l0/461971383-404.bin +0 -0
  249. /imap_processing/tests/ialirt/{test_data → data}/l0/461971384-405.bin +0 -0
  250. /imap_processing/tests/ialirt/{test_data → data}/l0/461971385-406.bin +0 -0
  251. /imap_processing/tests/ialirt/{test_data → data}/l0/461971386-407.bin +0 -0
  252. /imap_processing/tests/ialirt/{test_data → data}/l0/461971387-408.bin +0 -0
  253. /imap_processing/tests/ialirt/{test_data → data}/l0/461971388-409.bin +0 -0
  254. /imap_processing/tests/ialirt/{test_data → data}/l0/461971389-410.bin +0 -0
  255. /imap_processing/tests/ialirt/{test_data → data}/l0/461971390-411.bin +0 -0
  256. /imap_processing/tests/ialirt/{test_data → data}/l0/461971391-412.bin +0 -0
  257. /imap_processing/tests/ialirt/{test_data → data}/l0/BinLog CCSDS_FRAG_TLM_20240826_152323Z_IALIRT_data_for_SDC.bin +0 -0
  258. /imap_processing/tests/ialirt/{test_data → data}/l0/IALiRT Raw Packet Telemetry.txt +0 -0
  259. /imap_processing/tests/ialirt/{test_data → data}/l0/apid01152.tlm +0 -0
  260. /imap_processing/tests/ialirt/{test_data → data}/l0/eu_SWP_IAL_20240826_152033.csv +0 -0
  261. /imap_processing/tests/ialirt/{test_data → data}/l0/hi_fsw_view_1_ccsds.bin +0 -0
  262. /imap_processing/tests/ialirt/{test_data → data}/l0/hit_ialirt_sample.ccsds +0 -0
  263. /imap_processing/tests/ialirt/{test_data → data}/l0/hit_ialirt_sample.csv +0 -0
  264. /imap_processing/tests/ialirt/{test_data → data}/l0/idle_export_eu.SWE_IALIRT_20240827_093852.csv +0 -0
  265. /imap_processing/tests/ialirt/{test_data → data}/l0/imap_codice_l1a_hi-ialirt_20240523200000_v0.0.0.cdf +0 -0
  266. /imap_processing/tests/ialirt/{test_data → data}/l0/imap_codice_l1a_lo-ialirt_20241110193700_v0.0.0.cdf +0 -0
  267. /imap_processing/tests/ialirt/{test_data → data}/l0/sample_decoded_i-alirt_data.csv +0 -0
  268. /imap_processing/tests/mag/validation/{imap_calibration_mag_20240229_v01.cdf → calibration/imap_mag_l1b-calibration_20240229_v001.cdf} +0 -0
  269. /imap_processing/{swe/l1b/engineering_unit_convert_table.csv → tests/swe/lut/imap_swe_eu-conversion_20240510_v000.csv} +0 -0
  270. {imap_processing-0.12.0.dist-info → imap_processing-0.13.0.dist-info}/LICENSE +0 -0
  271. {imap_processing-0.12.0.dist-info → imap_processing-0.13.0.dist-info}/WHEEL +0 -0
  272. {imap_processing-0.12.0.dist-info → imap_processing-0.13.0.dist-info}/entry_points.txt +0 -0
@@ -2,10 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import logging
6
- import pathlib
7
7
  from abc import ABC, abstractmethod
8
8
  from enum import Enum
9
+ from pathlib import Path
10
+ from typing import TypeVar
9
11
 
10
12
  import astropy_healpix.healpy as hp
11
13
  import numpy as np
@@ -23,6 +25,9 @@ from imap_processing.spice.time import ttj2000ns_to_et
23
25
 
24
26
  logger = logging.getLogger(__name__)
25
27
 
28
+ # Set the maximum recursion depth for the conversion from Healpix to rectangular SkyMap.
29
+ MAX_SUBDIV_RECURSION_DEPTH = 8
30
+
26
31
 
27
32
  class SkyTilingType(Enum):
28
33
  """Enumeration of the types of tiling used in the ENA maps."""
@@ -133,9 +138,9 @@ def match_coords_to_indices(
133
138
  # which must be converted to ephemeris time (ET) for SPICE.
134
139
  if event_et is None:
135
140
  if isinstance(input_object, PointingSet):
136
- event_et = ttj2000ns_to_et(input_object.data["epoch"].values)
141
+ event_et = ttj2000ns_to_et(input_object.epoch)
137
142
  elif isinstance(output_object, PointingSet):
138
- event_et = ttj2000ns_to_et(output_object.data["epoch"].values)
143
+ event_et = ttj2000ns_to_et(output_object.epoch)
139
144
  else:
140
145
  raise ValueError(
141
146
  "Event time must be specified if both objects are SkyMaps."
@@ -199,6 +204,11 @@ def match_coords_to_indices(
199
204
  return flat_indices_input_grid_output_frame
200
205
 
201
206
 
207
+ # Define a TypeVar type to dynamically hint the return type of the base PointingSet
208
+ # class classmethod
209
+ T = TypeVar("T", bound="PointingSet")
210
+
211
+
202
212
  # Define the pointing set classes
203
213
  class PointingSet(ABC):
204
214
  """
@@ -209,20 +219,69 @@ class PointingSet(ABC):
209
219
 
210
220
  Parameters
211
221
  ----------
212
- dataset : xr.Dataset
213
- Dataset containing the pointing set data.
222
+ dataset : xr.Dataset | str | Path
223
+ Dataset or path to CDF file containing the pointing set data.
214
224
  spice_reference_frame : geometry.SpiceFrame
215
225
  The reference Spice frame of the pointing set.
216
226
  """
217
227
 
228
+ # The minimum set of class attributes for any PointingSet to function with
229
+ # a SkyMap using only the PUSH method of projecting are defined here.
230
+
231
+ # ======== Attributes that are set in the ABC __init__ method ========
232
+ # The xarray.Dataset containing the data from the PSET CDF
233
+ data: xr.Dataset
234
+ # The spice frame that the az_el_points are expressed in
235
+ spice_reference_frame: geometry.SpiceFrame
236
+
237
+ # ======== Attributes required to be set in a subclass ========
238
+ # Azimuth and elevation coordinates of each spatial pixel. The ndarray should
239
+ # have the shape (n, 2) where n is the number of spatial pixels
240
+ az_el_points: np.ndarray
241
+ # Tuple containing the names of each spatial coordinate of the xarray.Dataset
242
+ # stored in the data attribute
243
+ spatial_coords: tuple[str, ...]
244
+
218
245
  @abstractmethod
219
- def __init__(self, dataset: xr.Dataset, spice_reference_frame: geometry.SpiceFrame):
246
+ def __init__(
247
+ self,
248
+ dataset: xr.Dataset | str | Path,
249
+ spice_reference_frame: geometry.SpiceFrame = geometry.SpiceFrame.IMAP_DPS,
250
+ ):
220
251
  """Abstract method to initialize the pointing set object."""
221
252
  self.spice_reference_frame = spice_reference_frame
222
- self.num_points = 0
223
- self.az_el_points = np.zeros((self.num_points, 2))
224
- self.data = xr.Dataset()
225
- self.spatial_coords: tuple[str, ...] = ()
253
+
254
+ if isinstance(dataset, (str, Path)):
255
+ dataset = load_cdf(dataset)
256
+ self.data = dataset
257
+
258
+ # A PSET must have a single epoch
259
+ if len(np.unique(self.data["epoch"].values)) > 1:
260
+ raise ValueError("Multiple epochs found in the dataset.")
261
+
262
+ @property
263
+ def num_points(self) -> int:
264
+ """
265
+ The number of spatial pixels in the pointing set.
266
+
267
+ Returns
268
+ -------
269
+ num_points: int
270
+ The number of spatial pixels in the pointing set.
271
+ """
272
+ return self.az_el_points.shape[0]
273
+
274
+ @property
275
+ def epoch(self) -> int:
276
+ """
277
+ The singular epoch value from the xarray.Dataset.
278
+
279
+ Returns
280
+ -------
281
+ epoch: int
282
+ The epoch value [J2000 TT ns] of the pointing set.
283
+ """
284
+ return self.data["epoch"].values[0]
226
285
 
227
286
  @property
228
287
  def unwrapped_dims_dict(self) -> dict[str, tuple[str, ...]]:
@@ -234,7 +293,7 @@ class PointingSet(ABC):
234
293
  unwrapped_dims_dict : dict[str, tuple[str, ...]]
235
294
  Dictionary of variable names and their dimensions, with only 1 spatial dim.
236
295
  The generic pixel dimension is always included.
237
- E.g.: {"counts": ("epoch", "energy_bin_center", "pixel")} .
296
+ E.g.: {"counts": ("epoch", "energy", "pixel")} .
238
297
  """
239
298
  variable_dims = {}
240
299
  for var_name in self.data.data_vars:
@@ -287,8 +346,8 @@ class RectangularPointingSet(PointingSet):
287
346
 
288
347
  Parameters
289
348
  ----------
290
- l1c_dataset : xr.Dataset | pathlib.Path | str
291
- L1c xarray dataset containing the pointing set data or the path to the dataset.
349
+ dataset : xr.Dataset | str | Path
350
+ Dataset or path to CDF file containing the pointing set data.
292
351
  Currently, the dataset is expected to be tiled in a rectangular grid,
293
352
  with data_vars indexed along the coordinates:
294
353
  - 'epoch' : time value (1 value per PSET)
@@ -306,26 +365,19 @@ class RectangularPointingSet(PointingSet):
306
365
  If multiple epochs are found in the dataset.
307
366
  """
308
367
 
368
+ # In addition to the required attributes defined in the base PointingSet
369
+ # class, the following attributes are required for a RectangularPointingSet
370
+ # to be projected using the PULL method.
371
+ tiling_type: SkyTilingType = SkyTilingType.RECTANGULAR
372
+ sky_grid: spatial_utils.AzElSkyGrid
373
+
309
374
  def __init__(
310
375
  self,
311
- l1c_dataset: xr.Dataset | pathlib.Path | str,
376
+ dataset: xr.Dataset | str | Path,
312
377
  spice_reference_frame: geometry.SpiceFrame = geometry.SpiceFrame.IMAP_DPS,
313
378
  ):
314
- # Store the reference frame of the pointing set
315
- self.spice_reference_frame = spice_reference_frame
316
-
317
- # Read in the data and store the xarray dataset as data attr
318
- if isinstance(l1c_dataset, (str, pathlib.Path)):
319
- self.data = load_cdf(pathlib.Path(l1c_dataset))
320
- elif isinstance(l1c_dataset, xr.Dataset):
321
- self.data = l1c_dataset
322
-
323
- # A PSET must have a single epoch
324
- self.epoch = self.data["epoch"].values
325
- if len(np.unique(self.epoch)) > 1:
326
- raise ValueError("Multiple epochs found in the dataset.")
379
+ super().__init__(dataset, spice_reference_frame)
327
380
 
328
- self.tiling_type = SkyTilingType.RECTANGULAR
329
381
  self.spatial_coords = (
330
382
  CoordNames.AZIMUTH_L1C.value,
331
383
  CoordNames.ELEVATION_L1C.value,
@@ -344,12 +396,12 @@ class RectangularPointingSet(PointingSet):
344
396
  "Azimuth and elevation bin spacing do not match: "
345
397
  f"az {az_bin_delta[0]} != el {el_bin_delta[0]}."
346
398
  )
347
- self.spacing_deg = az_bin_delta[0]
399
+ spacing_deg = az_bin_delta[0]
348
400
 
349
401
  # Build the az/azimuth and el/elevation grids with an AzElSkyGrid object
350
402
  # and check that the 1D axes match the dataset's az and el.
351
403
  self.sky_grid = spatial_utils.AzElSkyGrid(
352
- spacing_deg=self.spacing_deg,
404
+ spacing_deg=spacing_deg,
353
405
  )
354
406
 
355
407
  for dim, constructed_bins in zip(
@@ -378,23 +430,47 @@ class RectangularPointingSet(PointingSet):
378
430
  self.sky_grid.el_grid.ravel(),
379
431
  )
380
432
  )
381
- self.num_points = self.az_el_points.shape[0]
382
433
 
383
- # Also store the bin edges for the pointing set to allow for "pull" method
384
- # of index matching (not yet implemented).
385
- # These are 1D arrays of different lengths and cannot be stacked.
386
- self.az_bin_edges = self.sky_grid.az_bin_edges
387
- self.el_bin_edges = self.sky_grid.el_bin_edges
388
434
 
435
+ class HealpixPointingSet(PointingSet, ABC):
436
+ """
437
+ Abstract base class for Healpix pointing sets.
438
+
439
+ Defines additional properties and absract properties that are required
440
+ for a PointingSet instance to be used with the match_coords_to_indices
441
+ function.
442
+ """
443
+
444
+ tiling_type: SkyTilingType = SkyTilingType.HEALPIX
445
+
446
+ @property
447
+ def nside(self) -> int:
448
+ """
449
+ Number of pixels on the side of one of the 12 top-level healpix tiles.
450
+
451
+ Returns
452
+ -------
453
+ npix: int
454
+ The number of pixels on the side of one of the 12 ‘top-level’ healpix
455
+ tiles.
456
+ """
457
+ return hp.npix_to_nside(self.num_points)
389
458
 
390
- class UltraPointingSet(PointingSet):
459
+ @property
460
+ @abstractmethod
461
+ def nested(self) -> bool:
462
+ """Abstract property for getting nested boolean."""
463
+ raise NotImplementedError
464
+
465
+
466
+ class UltraPointingSet(HealpixPointingSet):
391
467
  """
392
468
  Pointing set object specifically for Healpix-tiled ULTRA data, nominally at Level1C.
393
469
 
394
470
  Parameters
395
471
  ----------
396
- l1c_dataset : xr.Dataset | pathlib.Path | str
397
- L1c xarray dataset containing the pointing set data or the path to the dataset.
472
+ dataset : xr.Dataset | str | Path
473
+ Dataset or path to CDF file containing the pointing set data.
398
474
  Currently, the dataset is expected to be tiled in a HEALPix tessellation,
399
475
  with data_vars indexed along the coordinates:
400
476
  - 'epoch' : time value (1 value per PSET, from the mean of the PSET)
@@ -415,36 +491,19 @@ class UltraPointingSet(PointingSet):
415
491
 
416
492
  def __init__(
417
493
  self,
418
- l1c_dataset: xr.Dataset | pathlib.Path | str,
494
+ dataset: xr.Dataset | str | Path,
419
495
  spice_reference_frame: geometry.SpiceFrame = geometry.SpiceFrame.IMAP_DPS,
420
496
  ):
421
- # Store the reference frame of the pointing set
422
- self.spice_reference_frame = spice_reference_frame
423
-
424
- # Read in the data and store the xarray dataset as data attr
425
- if isinstance(l1c_dataset, (str, pathlib.Path)):
426
- self.data = load_cdf(pathlib.Path(l1c_dataset))
427
- elif isinstance(l1c_dataset, xr.Dataset):
428
- self.data = l1c_dataset
497
+ super().__init__(dataset, spice_reference_frame)
429
498
 
430
- # A PSET must have a single epoch
431
- self.epoch = self.data["epoch"].values
432
- if len(np.unique(self.epoch)) > 1:
433
- raise ValueError("Multiple epochs found in the dataset.")
434
-
435
- # Set the tiling type and number of points
436
- self.tiling_type = SkyTilingType.HEALPIX
499
+ # Set the spatial coordinates and number of points
437
500
  self.spatial_coords = (CoordNames.HEALPIX_INDEX.value,)
438
- self.num_points = self.data[CoordNames.HEALPIX_INDEX.value].size
439
- self.nside = hp.npix_to_nside(self.num_points)
440
501
 
441
- # Determine if the HEALPix tessellation is nested, default is False
442
- self.nested = bool(
443
- self.data[CoordNames.HEALPIX_INDEX.value].attrs.get("nested", False)
444
- )
502
+ # Tracks Per-Pixel Solid Angle in steradians.
503
+ self.solid_angle = hp.nside2pixarea(self.nside, degrees=False)
445
504
 
446
505
  # Get the azimuth and elevation coordinates of the healpix pixel centers (deg)
447
- self.azimuth_pixel_center, self.elevation_pixel_center = hp.pix2ang(
506
+ azimuth_pixel_center, elevation_pixel_center = hp.pix2ang(
448
507
  nside=self.nside,
449
508
  ipix=np.arange(self.num_points),
450
509
  nest=self.nested,
@@ -457,7 +516,7 @@ class UltraPointingSet(PointingSet):
457
516
  # (e.g. "longitude"/"latitude" vs "azimuth"/"elevation").
458
517
  for dim, constructed_bins in zip(
459
518
  [CoordNames.AZIMUTH_L1C.value, CoordNames.ELEVATION_L1C.value],
460
- [self.azimuth_pixel_center, self.elevation_pixel_center],
519
+ [azimuth_pixel_center, elevation_pixel_center],
461
520
  ):
462
521
  if not np.allclose(
463
522
  self.data[dim],
@@ -475,7 +534,33 @@ class UltraPointingSet(PointingSet):
475
534
  # of shape (num_points, 2) where column 0 is the lon/az
476
535
  # and column 1 is the lat/el.
477
536
  self.az_el_points = np.column_stack(
478
- (self.azimuth_pixel_center, self.elevation_pixel_center)
537
+ (azimuth_pixel_center, elevation_pixel_center)
538
+ )
539
+
540
+ @property
541
+ def num_points(self) -> int:
542
+ """
543
+ Override the base class property to get the number from the dataset.
544
+
545
+ Returns
546
+ -------
547
+ num_points: int
548
+ The number of healpix pixels in the pointing set.
549
+ """
550
+ return self.data[CoordNames.HEALPIX_INDEX.value].size
551
+
552
+ @property
553
+ def nested(self) -> bool:
554
+ """
555
+ Whether the healpix tessellation is nested.
556
+
557
+ Returns
558
+ -------
559
+ nested: bool
560
+ Whether the healpix tessellation is nested.
561
+ """
562
+ return bool(
563
+ self.data[CoordNames.HEALPIX_INDEX.value].attrs.get("nested", False)
479
564
  )
480
565
 
481
566
  def __repr__(self) -> str:
@@ -494,6 +579,27 @@ class UltraPointingSet(PointingSet):
494
579
  )
495
580
 
496
581
 
582
+ class HiPointingSet(PointingSet):
583
+ """
584
+ PointingSet object specific to Hi L1C PSet data.
585
+
586
+ Parameters
587
+ ----------
588
+ dataset : xarray.Dataset
589
+ Hi L1C pointing set data loaded in an xarray.DataArray.
590
+ """
591
+
592
+ def __init__(self, dataset: xr.Dataset):
593
+ super().__init__(dataset, spice_reference_frame=geometry.SpiceFrame.ECLIPJ2000)
594
+ self.az_el_points = np.column_stack(
595
+ (
596
+ np.squeeze(self.data["hae_longitude"]),
597
+ np.squeeze(self.data["hae_latitude"]),
598
+ )
599
+ )
600
+ self.spatial_coords = ("spin_angle_bin",)
601
+
602
+
497
603
  # Define the Map classes
498
604
  class AbstractSkyMap(ABC):
499
605
  """
@@ -519,6 +625,10 @@ class AbstractSkyMap(ABC):
519
625
  self.binning_grid_shape: tuple[int, ...]
520
626
  self.data_1d: xr.Dataset
521
627
 
628
+ # Initialize values to be used by the instrument code to push/pull
629
+ self.values_to_push_project: list[str] = []
630
+ self.values_to_pull_project: list[str] = []
631
+
522
632
  def to_dataset(self) -> xr.Dataset:
523
633
  """
524
634
  Get the SkyMap data as a formatted xarray Dataset.
@@ -685,8 +795,164 @@ class AbstractSkyMap(ABC):
685
795
  "Only PUSH and PULL index matching methods are supported."
686
796
  )
687
797
 
798
+ # TODO: we may need to allow for unweighted/weighted means here by
799
+ # dividing pointing_projected_values by some binned weights.
800
+ # For unweighted means, we could use the number of pointing set pixels
801
+ # that correspond to each map pixel as the weights.
688
802
  self.data_1d[value_key] += pointing_projected_values
689
803
 
804
+ @classmethod
805
+ def from_json(cls, json_path: str | Path) -> RectangularSkyMap | HealpixSkyMap:
806
+ """
807
+ Create a SkyMap object from a JSON configuration file.
808
+
809
+ Parameters
810
+ ----------
811
+ json_path : str | Path
812
+ Path to the JSON configuration file.
813
+
814
+ Returns
815
+ -------
816
+ RectangularSkyMap | HealpixSkyMap
817
+ An instance of a SkyMap object with the specified properties.
818
+ """
819
+ with open(json_path) as f:
820
+ properties = json.load(f)
821
+ return cls.from_dict(properties)
822
+
823
+ @classmethod
824
+ def from_dict(cls, properties: dict) -> RectangularSkyMap | HealpixSkyMap:
825
+ """
826
+ Create a SkyMap object from a dictionary of properties.
827
+
828
+ Parameters
829
+ ----------
830
+ properties : dict
831
+ Dictionary containing the map properties. The required keys are:
832
+ - "spice_reference_frame" : str
833
+ The reference Spice frame of the map as a string. The available
834
+ options are defined in the spice geometry module:
835
+ `imap_processing.geometry.spice.SpiceFrame`. Example: "ECLIPJ2000".
836
+ - "sky_tiling_type" : str
837
+ The type of sky tiling, either "HEALPIX" or "RECTANGULAR".
838
+ - if "HEALPIX":
839
+ - "nside" : int
840
+ The nside parameter for the Healpix tessellation.
841
+ - "nested" : bool
842
+ Whether the Healpix tessellation is nested or not.
843
+ - if "RECTANGULAR":
844
+ - "spacing_deg" : float
845
+ The spacing of the rectangular grid in degrees.
846
+ - "values_to_push_project" : list[str], optional
847
+ The names of the variables to project to the map with the PUSH method.
848
+ NOTE: The projection is done by the instrument code, so this value can
849
+ only be used to inform that code. No values are projected automatically.
850
+ - "values_to_pull_project" : list[str], optional
851
+ The names of the variables to project to the map with the PULL method.
852
+ See the above note for more details.
853
+
854
+ See example dictionary in notes section.
855
+
856
+ Returns
857
+ -------
858
+ RectangularSkyMap | HealpixSkyMap
859
+ An instance of a SkyMap object with the specified properties.
860
+
861
+ Raises
862
+ ------
863
+ ValueError
864
+ If the sky tiling type is not recognized.
865
+
866
+ Notes
867
+ -----
868
+ Example dictionary:
869
+
870
+ ```python
871
+ properties = {
872
+ "spice_reference_frame": "ECLIPJ2000",
873
+ "sky_tiling_type": "HEALPIX",
874
+ "nside": 32,
875
+ "nested": False,
876
+ "values_to_push_project": ['counts', 'flux'],
877
+ "values_to_pull_project": []
878
+ }
879
+ ```
880
+ """
881
+ sky_tiling_type = SkyTilingType[properties["sky_tiling_type"].upper()]
882
+ spice_reference_frame = geometry.SpiceFrame[properties["spice_reference_frame"]]
883
+
884
+ skymap: RectangularSkyMap | HealpixSkyMap # Mypy gets confused by if/elif types
885
+ if sky_tiling_type is SkyTilingType.HEALPIX:
886
+ skymap = HealpixSkyMap(
887
+ nside=properties["nside"],
888
+ nested=properties["nested"],
889
+ spice_frame=spice_reference_frame,
890
+ )
891
+ elif sky_tiling_type is SkyTilingType.RECTANGULAR:
892
+ skymap = RectangularSkyMap(
893
+ spacing_deg=properties["spacing_deg"],
894
+ spice_frame=spice_reference_frame,
895
+ )
896
+ else:
897
+ raise ValueError(
898
+ f"Unknown sky tiling type: {sky_tiling_type}. "
899
+ f"Must be one of: {SkyTilingType.__members__.keys()}"
900
+ )
901
+
902
+ # Store requested variables to push/pull, which will be done by the instrument
903
+ # code which creates and uses the SkyMap object.
904
+ skymap.values_to_push_project = properties.get("values_to_push_project", [])
905
+ skymap.values_to_pull_project = properties.get("values_to_pull_project", [])
906
+ return skymap
907
+
908
+ def to_dict(self) -> dict:
909
+ """
910
+ Convert the SkyMap object to a dictionary of properties.
911
+
912
+ Returns
913
+ -------
914
+ dict
915
+ Dictionary containing the map properties.
916
+ """
917
+ if isinstance(self, HealpixSkyMap):
918
+ map_properties_dict = {
919
+ "sky_tiling_type": "HEALPIX",
920
+ "spice_reference_frame": self.spice_reference_frame.name,
921
+ "nside": self.nside,
922
+ "nested": self.nested,
923
+ }
924
+ elif isinstance(self, RectangularSkyMap):
925
+ map_properties_dict = {
926
+ "sky_tiling_type": "RECTANGULAR",
927
+ "spice_reference_frame": self.spice_reference_frame.name,
928
+ "spacing_deg": self.spacing_deg,
929
+ }
930
+ else:
931
+ raise ValueError(
932
+ f"Unknown SkyMap type: {self.__class__.__name__}. "
933
+ f"Must be one of: {AbstractSkyMap.__subclasses__()}"
934
+ )
935
+
936
+ map_properties_dict["values_to_push_project"] = (
937
+ self.values_to_push_project if self.values_to_push_project else []
938
+ )
939
+ map_properties_dict["values_to_pull_project"] = (
940
+ self.values_to_pull_project if self.values_to_pull_project else []
941
+ )
942
+ return map_properties_dict
943
+
944
+ def to_json(self, json_path: str | Path) -> None:
945
+ """
946
+ Save the SkyMap object to a JSON configuration file.
947
+
948
+ Parameters
949
+ ----------
950
+ json_path : str | Path
951
+ Path to the JSON file where the properties will be saved.
952
+ """
953
+ with open(json_path, "w") as f:
954
+ json.dump(self.to_dict(), f, indent=4)
955
+
690
956
 
691
957
  class RectangularSkyMap(AbstractSkyMap):
692
958
  """
@@ -720,7 +986,7 @@ class RectangularSkyMap(AbstractSkyMap):
720
986
  in degrees, with a spacing of 30 degrees. There will be 12 azimuth bins and 6
721
987
  elevation bins in this example, resulting in 72 pixels in the map.
722
988
 
723
- A multidimentional value (e.g. counts, with energy levels at each pixel)
989
+ A multidimensional value (e.g. counts, with energy levels at each pixel)
724
990
  will be stored as a 2D array with the first axis as the energy dimension and the
725
991
  second axis as the pixel index.
726
992
 
@@ -749,6 +1015,10 @@ class RectangularSkyMap(AbstractSkyMap):
749
1015
  # The reference Spice frame of the map, in which angles are defined
750
1016
  self.spice_reference_frame = spice_frame
751
1017
 
1018
+ # Initialize values to be used by the instrument code to push/pull
1019
+ self.values_to_push_project: list[str] = []
1020
+ self.values_to_pull_project: list[str] = []
1021
+
752
1022
  # Angular spacing of the map grid (degrees) defines the number, size of pixels.
753
1023
  self.spacing_deg = spacing_deg
754
1024
  self.sky_grid = spatial_utils.AzElSkyGrid(
@@ -829,6 +1099,10 @@ class HealpixSkyMap(AbstractSkyMap):
829
1099
  self.tiling_type = SkyTilingType.HEALPIX
830
1100
  self.spice_reference_frame = spice_frame
831
1101
 
1102
+ # Initialize values to be used by the instrument code to push/pull
1103
+ self.values_to_push_project: list[str] = []
1104
+ self.values_to_pull_project: list[str] = []
1105
+
832
1106
  # Tile the sky with a Healpix tessellation. Defined by nside, nested parameters.
833
1107
  self.nside = nside
834
1108
  self.nested = nested
@@ -867,6 +1141,325 @@ class HealpixSkyMap(AbstractSkyMap):
867
1141
  }
868
1142
  )
869
1143
 
1144
+ # Define several methods for converting a Healpix map to a Rectangular map:
1145
+ def calculate_rect_pixel_value_from_healpix_map_n_subdivisions(
1146
+ self,
1147
+ rect_pix_center_lon_lat: np.typing.NDArray | tuple[float, float],
1148
+ rect_pix_spacing_deg: float,
1149
+ value_array: xr.DataArray,
1150
+ num_subdivisions: int,
1151
+ ) -> np.typing.NDArray:
1152
+ """
1153
+ Interpolate the value of a rectangular pixel from a healpix map w/ subdivisions.
1154
+
1155
+ This function splits a single rectangular pixel into smaller subpixels
1156
+ and calculates the solid angle weighted mean value of
1157
+ the healpix map at all of the subpixel centers.
1158
+
1159
+ Parameters
1160
+ ----------
1161
+ rect_pix_center_lon_lat : np.typing.NDArray | tuple[float, float]
1162
+ The center longitude and latitude of the rectangular pixel.
1163
+ rect_pix_spacing_deg : float
1164
+ The spacing of the rectangular pixel in degrees.
1165
+ value_array : xr.DataArray
1166
+ The data array containing the healpix map values.
1167
+ num_subdivisions : int
1168
+ The number of subdivisions to create for the rectangular pixel.
1169
+ The more subdivisions, the more accurate the interpolation, but also
1170
+ the more computationally expensive it is.
1171
+
1172
+ Returns
1173
+ -------
1174
+ np.typing.NDArray
1175
+ The mean value of the healpix map at the subpixel centers.
1176
+
1177
+ If value_array has a single value at each pixel, the output
1178
+ will be a single value, but if there are other dimensions,
1179
+ (e.g., if self.data_1d['flux'].sizes =
1180
+ {"epoch": 1, "energy": 24, "pixel": 16200}),
1181
+ the output will be an array with the same dims except the pixel dimension
1182
+ (e.g., (1, 24)).
1183
+ """
1184
+ # Assumes that you already checked the pixel doesn't fall entirely in an HP pix
1185
+ # TODO: Ask Nick if we need to add this here to mimic his code.
1186
+ # It shouldn't really be necessary, as the next function
1187
+ # get_pixel_value_recursive_subdivs will finish at 1 subdivision
1188
+
1189
+ # Ensure input contains lon in the first column and lat in the second column
1190
+ rect_pix_center_lon_lat = np.array(rect_pix_center_lon_lat).reshape(-1, 2)
1191
+
1192
+ # Calculate the number of subdivisions and the spacing of the subpixels
1193
+ # Then calculate the subpixel centers
1194
+ n_subpix_side = 2**num_subdivisions
1195
+ subpix_spacing = rect_pix_spacing_deg / n_subpix_side
1196
+ left_edge_lon = rect_pix_center_lon_lat[:, 0] - rect_pix_spacing_deg / 2
1197
+ bottom_edge_lat = rect_pix_center_lon_lat[:, 1] - rect_pix_spacing_deg / 2
1198
+
1199
+ rect_subpix_lon_ctrs = (
1200
+ left_edge_lon
1201
+ + subpix_spacing * np.arange(n_subpix_side)
1202
+ + subpix_spacing / 2
1203
+ )
1204
+ rect_subpix_lat_ctrs = (
1205
+ bottom_edge_lat
1206
+ + subpix_spacing * np.arange(n_subpix_side)
1207
+ + subpix_spacing / 2
1208
+ )
1209
+
1210
+ # We must weight by solid angle, which is not exactly equal for all subpixels
1211
+ # Calculate the solid angle of the full rectangular pixel (sterad)
1212
+ full_rect_pixel_solid_angle = np.deg2rad(rect_pix_spacing_deg) * (
1213
+ np.sin(np.deg2rad(bottom_edge_lat + rect_pix_spacing_deg))
1214
+ - np.sin(np.deg2rad(bottom_edge_lat))
1215
+ )
1216
+
1217
+ # Calculate solid angle of each subpix from the rect_subpix_lat_ctrs (sterad)
1218
+ all_edges_lat = bottom_edge_lat + np.arange(n_subpix_side + 1) * subpix_spacing
1219
+ sine_all_edges_lat = np.sin(np.deg2rad(all_edges_lat))
1220
+ rect_subpix_solid_angle_by_lat = np.diff(sine_all_edges_lat) * np.deg2rad(
1221
+ subpix_spacing
1222
+ )
1223
+ rect_subpix_solid_angle_by_lat = np.repeat(
1224
+ rect_subpix_solid_angle_by_lat[np.newaxis, :], n_subpix_side, axis=0
1225
+ ).reshape(-1)
1226
+
1227
+ rect_subpix_ctrs = (
1228
+ np.array(
1229
+ np.meshgrid(rect_subpix_lon_ctrs, rect_subpix_lat_ctrs, indexing="ij")
1230
+ )
1231
+ .reshape(2, -1)
1232
+ .T
1233
+ )
1234
+
1235
+ # Get the healpix pixel indices at the rectangular subpixel centers
1236
+ hp_pix_at_rect_subpix_ctrs = hp.ang2pix(
1237
+ nside=self.nside,
1238
+ nest=self.nested,
1239
+ theta=rect_subpix_ctrs[:, 0],
1240
+ phi=rect_subpix_ctrs[:, 1],
1241
+ lonlat=True,
1242
+ )
1243
+ # Get the healpix values at the rectangular subpixel centers
1244
+ hp_vals_at_rect_pix_ctrs = value_array.values[..., hp_pix_at_rect_subpix_ctrs]
1245
+
1246
+ # Weighted mean (weighted by solid angle) of these values over the pixel axis,
1247
+ # which is the last axis of this array
1248
+ weighted_hp_vals_at_rect_pix_ctrs = (
1249
+ hp_vals_at_rect_pix_ctrs * rect_subpix_solid_angle_by_lat
1250
+ )
1251
+ mean_pixel_value = (
1252
+ weighted_hp_vals_at_rect_pix_ctrs.sum(axis=-1) / full_rect_pixel_solid_angle
1253
+ )
1254
+ # Log the mean pixel value and the number of subdivisions for debugging
1255
+ logger.debug(
1256
+ f" Mean pixel value at Number of subdivisions: {num_subdivisions}: "
1257
+ f"array of shape {mean_pixel_value.shape}: {mean_pixel_value}"
1258
+ )
1259
+ return mean_pixel_value
1260
+
1261
+ def get_rect_pixel_value_recursive_subdivs(
1262
+ self,
1263
+ rect_pix_center_lon_lat: np.typing.NDArray | tuple[float, float],
1264
+ rect_pix_spacing_deg: float,
1265
+ value_array: xr.DataArray,
1266
+ *,
1267
+ rtol: float = 1e-3,
1268
+ atol: float = 1e-12,
1269
+ max_subdivision_depth: int = MAX_SUBDIV_RECURSION_DEPTH,
1270
+ ) -> tuple[np.typing.NDArray, int]:
1271
+ """
1272
+ Recursively subdivide a rectangular pixel to get a mean value within tolerances.
1273
+
1274
+ Takes a rectangular pixel, and recursively breaks it up into
1275
+ smaller and smaller subpixels, then calculates the solid-angle weighted mean
1276
+ of the healpix map's value at this pixel, until the difference
1277
+ between the mean values of two consecutive subdivisions is within the
1278
+ specified tolerances. The function returns the mean value at the final level
1279
+ of subdivision and the depth of recursion.
1280
+
1281
+ Parameters
1282
+ ----------
1283
+ rect_pix_center_lon_lat : np.typing.NDArray | tuple[float, float]
1284
+ The center longitude and latitude of the rectangular pixel.
1285
+ rect_pix_spacing_deg : float
1286
+ The spacing of the rectangular pixel in degrees.
1287
+ value_array : xr.DataArray
1288
+ The data array containing the healpix map values to interpolate from.
1289
+ rtol : float, optional
1290
+ The relative tolerance for convergence, by default 1e-3.
1291
+ atol : float, optional
1292
+ The absolute tolerance for convergence, by default 1e-12.
1293
+ max_subdivision_depth : int, optional
1294
+ The maximum depth of recursion for subdivision,
1295
+ by default MAX_SUBDIV_RECURSION_DEPTH.
1296
+ Computation grows exponentially with depth, but only where the value
1297
+ has a significant gradient between adjacent healpix pixels.
1298
+ If the value is smooth, the recursion depth will be low.
1299
+
1300
+ Returns
1301
+ -------
1302
+ tuple[list[float], int]
1303
+ The mean value at the final level of subdivision and the depth of recursion.
1304
+ """
1305
+ # Recursively subdivide a pixel and calculate its mean value until either the
1306
+ # difference between consecutive levels is within the specified tolerances
1307
+ # or the maximum recursion depth is reached
1308
+ depth = 0
1309
+ previous_mean_pixel_value: NDArray = np.full((1,), np.nan)
1310
+ while depth < max_subdivision_depth:
1311
+ mean_pixel_value = (
1312
+ self.calculate_rect_pixel_value_from_healpix_map_n_subdivisions(
1313
+ rect_pix_center_lon_lat=rect_pix_center_lon_lat,
1314
+ rect_pix_spacing_deg=rect_pix_spacing_deg,
1315
+ value_array=value_array,
1316
+ num_subdivisions=depth,
1317
+ )
1318
+ )
1319
+
1320
+ # Determine if tolerance is met
1321
+ # (skip on the 0th iteration, as there's no delta)
1322
+ if depth > 0:
1323
+ # TODO: Ask Nick/Ultra Instrument team if we need to compare each value
1324
+ # in the pixel's array, or just the mean value.
1325
+ if np.isclose(
1326
+ mean_pixel_value.mean(),
1327
+ previous_mean_pixel_value.mean(),
1328
+ rtol=rtol,
1329
+ atol=atol,
1330
+ ):
1331
+ break
1332
+ depth += 1
1333
+ previous_mean_pixel_value = mean_pixel_value
1334
+
1335
+ logger.debug(
1336
+ f"Pixel at ({rect_pix_center_lon_lat} deg size={rect_pix_spacing_deg} deg,)"
1337
+ f" converged to {mean_pixel_value.mean()} in {depth} subdivisions."
1338
+ f" Previous mean was {previous_mean_pixel_value.mean()}."
1339
+ )
1340
+ # Only keep the last (best) mean pixel value
1341
+ return mean_pixel_value, depth
1342
+
1343
+ def to_rectangular_skymap(
1344
+ self,
1345
+ rect_spacing_deg: float,
1346
+ value_keys: list[str],
1347
+ max_subdivision_depth: int = MAX_SUBDIV_RECURSION_DEPTH,
1348
+ ) -> tuple[RectangularSkyMap, dict[str, np.typing.NDArray]]:
1349
+ """
1350
+ Interpolate a healpix map to a rectangular map using recursive subdivision.
1351
+
1352
+ Parameters
1353
+ ----------
1354
+ rect_spacing_deg : float
1355
+ The spacing of the rectangular map in degrees.
1356
+ value_keys : list[str]
1357
+ The names of the values to interpolate from the healpix map.
1358
+ Each must be independently interpolated because the subdivision depth
1359
+ depends on the gradient of the value between adjacent healpix pixels.
1360
+ max_subdivision_depth : int, optional
1361
+ The maximum depth of recursion for subdivision,
1362
+ by default MAX_SUBDIV_RECURSION_DEPTH.
1363
+
1364
+ Returns
1365
+ -------
1366
+ tuple[RectangularSkyMap, dict[str, np.typing.NDArray]]
1367
+ A RectangularSkyMap containing the interpolated values, and a dictionary of
1368
+ each value and its corresponding subdivision depth by pixel.
1369
+ """
1370
+ # Begin by defining the rectangular map we want to create, which must be
1371
+ # in the same spice reference frame as the healpix map
1372
+ rect_map = RectangularSkyMap(
1373
+ spacing_deg=rect_spacing_deg,
1374
+ spice_frame=self.spice_reference_frame,
1375
+ )
1376
+
1377
+ # Depending on the maximum recursion depth, the number of pixels in the
1378
+ # RectangularSkyMap, and the number of value keys, and especially on the
1379
+ # gradients of the values, the number of operations can be very large, so
1380
+ # log key information about the expected number of operations.
1381
+ approx_max_operations = (
1382
+ (4**max_subdivision_depth) * self.num_points * len(value_keys)
1383
+ )
1384
+ logger.info(
1385
+ f"Converting from a HealpixSkyMap(nside={self.nside}) to a "
1386
+ f"RectangularSkyMap(spacing_deg={rect_spacing_deg}) with recursive "
1387
+ "subdivision.\n The maximum recursion depth is "
1388
+ f"{max_subdivision_depth}, yielding a maximum number of healpix calls"
1389
+ f" of {approx_max_operations:.3e}."
1390
+ )
1391
+
1392
+ # Dict to hold the subdivision depth by pixel for each value key
1393
+ subdiv_depth_dict = {}
1394
+ for value_key in value_keys:
1395
+ # For each of the values, calculate each pixel's value with
1396
+ # recursive subdivision. Unfortunately, this must be done independently
1397
+ # for each value key.
1398
+
1399
+ # Yields a list of tuple (mean_value, depth) for each pixel in the map
1400
+ healpix_values_array = self.data_1d[value_key]
1401
+ best_value_and_recursion_depth_by_pixel = [
1402
+ self.get_rect_pixel_value_recursive_subdivs(
1403
+ rect_pix_center_lon_lat=lon_lat,
1404
+ rect_pix_spacing_deg=rect_map.spacing_deg,
1405
+ value_array=healpix_values_array,
1406
+ max_subdivision_depth=max_subdivision_depth,
1407
+ )
1408
+ for lon_lat in rect_map.az_el_points
1409
+ ]
1410
+
1411
+ # Separate the best value and the recursion depth for each pixel
1412
+ # into two lists, then convert both to numpy arrays
1413
+ # and move the pixel dim to the last dim of values
1414
+ interpolated_data_by_rect_pixel, subdiv_depth_of_value_by_pixel = zip(
1415
+ *best_value_and_recursion_depth_by_pixel
1416
+ )
1417
+ interpolated_data_by_rect_pixel = np.moveaxis(
1418
+ np.array(interpolated_data_by_rect_pixel), 0, -1
1419
+ )
1420
+ subdiv_depth_of_value_by_pixel = np.array(subdiv_depth_of_value_by_pixel)
1421
+
1422
+ # This can introduce an extra dim as the last dim of the array
1423
+ # to values with only one dimension
1424
+ if len(healpix_values_array.dims) == 1:
1425
+ interpolated_data_by_rect_pixel = np.squeeze(
1426
+ interpolated_data_by_rect_pixel,
1427
+ )
1428
+
1429
+ # Store the best value(s) of each pixel in the rectangular map with the
1430
+ # leading coordinates of the healpix map, and the pixel coordinate last
1431
+ rect_map.data_1d[value_key] = xr.DataArray(
1432
+ data=interpolated_data_by_rect_pixel,
1433
+ dims=(*healpix_values_array.dims[:-1], CoordNames.GENERIC_PIXEL.value),
1434
+ )
1435
+
1436
+ # Update the coordinates of the rectangular map with any new coordinates
1437
+ # from the healpix map except the pixel coord,
1438
+ # which will be different in the rectangular map.
1439
+ for coord in healpix_values_array.coords:
1440
+ if coord not in (
1441
+ CoordNames.GENERIC_PIXEL.value,
1442
+ CoordNames.HEALPIX_INDEX.value,
1443
+ ):
1444
+ rect_map.data_1d.coords[coord] = healpix_values_array.coords[coord]
1445
+
1446
+ # Add the subdivision depth by pixel of this value_key to the dictionary
1447
+ # This may be necessary for uncertainty estimation
1448
+ subdiv_depth_dict[value_key] = subdiv_depth_of_value_by_pixel
1449
+ logger.info(
1450
+ f"Summary of subdivision depth for {value_key}:\n"
1451
+ "Mean +/- std number of subdivisions for the "
1452
+ f"{rect_map.num_points} pixels of {value_key} is:\n"
1453
+ f" {np.mean(subdiv_depth_of_value_by_pixel):.6f}."
1454
+ f" +/- {np.std(subdiv_depth_of_value_by_pixel):.6f}.\n"
1455
+ "Min / Max number of subdivisions: \n"
1456
+ f" {np.min(subdiv_depth_of_value_by_pixel):.6f} / "
1457
+ f"{np.max(subdiv_depth_of_value_by_pixel):.6f}.\n"
1458
+ f"The maximum allowed depth is {max_subdivision_depth}."
1459
+ )
1460
+
1461
+ return rect_map, subdiv_depth_dict
1462
+
870
1463
  def __repr__(self) -> str:
871
1464
  """
872
1465
  Return a string representation of the HealpixSkyMap.