imap-processing 0.11.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 (415) hide show
  1. imap_processing/__init__.py +11 -11
  2. imap_processing/_version.py +2 -2
  3. imap_processing/ccsds/ccsds_data.py +1 -2
  4. imap_processing/ccsds/excel_to_xtce.py +66 -18
  5. imap_processing/cdf/config/imap_codice_global_cdf_attrs.yaml +24 -40
  6. imap_processing/cdf/config/imap_codice_l1a_variable_attrs.yaml +934 -42
  7. imap_processing/cdf/config/imap_codice_l1b_variable_attrs.yaml +1846 -128
  8. imap_processing/cdf/config/imap_glows_global_cdf_attrs.yaml +0 -5
  9. imap_processing/cdf/config/imap_hi_global_cdf_attrs.yaml +10 -11
  10. imap_processing/cdf/config/imap_hi_variable_attrs.yaml +17 -19
  11. imap_processing/cdf/config/imap_hit_global_cdf_attrs.yaml +27 -14
  12. imap_processing/cdf/config/imap_hit_l1a_variable_attrs.yaml +106 -116
  13. imap_processing/cdf/config/imap_hit_l1b_variable_attrs.yaml +120 -145
  14. imap_processing/cdf/config/imap_hit_l2_variable_attrs.yaml +14 -0
  15. imap_processing/cdf/config/imap_idex_global_cdf_attrs.yaml +25 -9
  16. imap_processing/cdf/config/imap_idex_l1a_variable_attrs.yaml +6 -4
  17. imap_processing/cdf/config/imap_idex_l1b_variable_attrs.yaml +3 -3
  18. imap_processing/cdf/config/imap_lo_global_cdf_attrs.yaml +0 -12
  19. imap_processing/cdf/config/imap_lo_l1a_variable_attrs.yaml +1 -1
  20. imap_processing/cdf/config/imap_mag_global_cdf_attrs.yaml +23 -20
  21. imap_processing/cdf/config/imap_mag_l1a_variable_attrs.yaml +361 -0
  22. imap_processing/cdf/config/imap_mag_l1b_variable_attrs.yaml +160 -0
  23. imap_processing/cdf/config/imap_mag_l1c_variable_attrs.yaml +160 -0
  24. imap_processing/cdf/config/imap_spacecraft_global_cdf_attrs.yaml +18 -0
  25. imap_processing/cdf/config/imap_spacecraft_variable_attrs.yaml +40 -0
  26. imap_processing/cdf/config/imap_swapi_global_cdf_attrs.yaml +1 -5
  27. imap_processing/cdf/config/imap_swapi_variable_attrs.yaml +22 -0
  28. imap_processing/cdf/config/imap_swe_global_cdf_attrs.yaml +12 -4
  29. imap_processing/cdf/config/imap_swe_l1a_variable_attrs.yaml +16 -2
  30. imap_processing/cdf/config/imap_swe_l1b_variable_attrs.yaml +64 -52
  31. imap_processing/cdf/config/imap_swe_l2_variable_attrs.yaml +71 -47
  32. imap_processing/cdf/config/imap_ultra_global_cdf_attrs.yaml +180 -19
  33. imap_processing/cdf/config/imap_ultra_l1a_variable_attrs.yaml +5045 -41
  34. imap_processing/cdf/config/imap_ultra_l1b_variable_attrs.yaml +80 -17
  35. imap_processing/cdf/config/imap_ultra_l1c_variable_attrs.yaml +32 -57
  36. imap_processing/cdf/utils.py +52 -38
  37. imap_processing/cli.py +477 -233
  38. imap_processing/codice/codice_l1a.py +466 -131
  39. imap_processing/codice/codice_l1b.py +51 -152
  40. imap_processing/codice/constants.py +1360 -569
  41. imap_processing/codice/decompress.py +2 -6
  42. imap_processing/ena_maps/ena_maps.py +1103 -146
  43. imap_processing/ena_maps/utils/coordinates.py +19 -0
  44. imap_processing/ena_maps/utils/map_utils.py +14 -17
  45. imap_processing/ena_maps/utils/spatial_utils.py +55 -52
  46. imap_processing/glows/l1a/glows_l1a.py +28 -99
  47. imap_processing/glows/l1a/glows_l1a_data.py +2 -2
  48. imap_processing/glows/l1b/glows_l1b.py +1 -4
  49. imap_processing/glows/l1b/glows_l1b_data.py +1 -3
  50. imap_processing/glows/l2/glows_l2.py +2 -5
  51. imap_processing/hi/l1a/hi_l1a.py +54 -29
  52. imap_processing/hi/l1a/histogram.py +0 -1
  53. imap_processing/hi/l1a/science_direct_event.py +6 -8
  54. imap_processing/hi/l1b/hi_l1b.py +111 -82
  55. imap_processing/hi/l1c/hi_l1c.py +416 -32
  56. imap_processing/hi/utils.py +58 -12
  57. imap_processing/hit/ancillary/imap_hit_l1b-to-l2-sector-dt0-factors_20250219_v002.csv +81 -0
  58. imap_processing/hit/ancillary/imap_hit_l1b-to-l2-standard-dt0-factors_20250219_v002.csv +205 -0
  59. imap_processing/hit/ancillary/imap_hit_l1b-to-l2-standard-dt1-factors_20250219_v002.csv +205 -0
  60. imap_processing/hit/ancillary/imap_hit_l1b-to-l2-standard-dt2-factors_20250219_v002.csv +205 -0
  61. imap_processing/hit/ancillary/imap_hit_l1b-to-l2-standard-dt3-factors_20250219_v002.csv +205 -0
  62. imap_processing/hit/ancillary/imap_hit_l1b-to-l2-summed-dt0-factors_20250219_v002.csv +68 -0
  63. imap_processing/hit/hit_utils.py +235 -5
  64. imap_processing/hit/l0/constants.py +20 -11
  65. imap_processing/hit/l0/decom_hit.py +21 -5
  66. imap_processing/hit/l1a/hit_l1a.py +71 -75
  67. imap_processing/hit/l1b/constants.py +321 -0
  68. imap_processing/hit/l1b/hit_l1b.py +377 -67
  69. imap_processing/hit/l2/constants.py +318 -0
  70. imap_processing/hit/l2/hit_l2.py +723 -0
  71. imap_processing/hit/packet_definitions/hit_packet_definitions.xml +1323 -71
  72. imap_processing/ialirt/l0/mag_l0_ialirt_data.py +155 -0
  73. imap_processing/ialirt/l0/parse_mag.py +374 -0
  74. imap_processing/ialirt/l0/process_swapi.py +69 -0
  75. imap_processing/ialirt/l0/process_swe.py +548 -0
  76. imap_processing/ialirt/packet_definitions/ialirt.xml +216 -208
  77. imap_processing/ialirt/packet_definitions/ialirt_codicehi.xml +1 -1
  78. imap_processing/ialirt/packet_definitions/ialirt_codicelo.xml +1 -1
  79. imap_processing/ialirt/packet_definitions/ialirt_mag.xml +115 -0
  80. imap_processing/ialirt/packet_definitions/ialirt_swapi.xml +14 -14
  81. imap_processing/ialirt/utils/grouping.py +114 -0
  82. imap_processing/ialirt/utils/time.py +29 -0
  83. imap_processing/idex/atomic_masses.csv +22 -0
  84. imap_processing/idex/decode.py +2 -2
  85. imap_processing/idex/idex_constants.py +33 -0
  86. imap_processing/idex/idex_l0.py +22 -8
  87. imap_processing/idex/idex_l1a.py +81 -51
  88. imap_processing/idex/idex_l1b.py +13 -39
  89. imap_processing/idex/idex_l2a.py +823 -0
  90. imap_processing/idex/idex_l2b.py +120 -0
  91. imap_processing/idex/idex_variable_unpacking_and_eu_conversion.csv +11 -11
  92. imap_processing/idex/packet_definitions/idex_housekeeping_packet_definition.xml +9130 -0
  93. imap_processing/lo/l0/lo_science.py +7 -2
  94. imap_processing/lo/l1a/lo_l1a.py +1 -5
  95. imap_processing/lo/l1b/lo_l1b.py +702 -29
  96. imap_processing/lo/l1b/tof_conversions.py +11 -0
  97. imap_processing/lo/l1c/lo_l1c.py +1 -4
  98. imap_processing/mag/constants.py +51 -0
  99. imap_processing/mag/imap_mag_sdc_configuration_v001.py +8 -0
  100. imap_processing/mag/l0/decom_mag.py +10 -3
  101. imap_processing/mag/l1a/mag_l1a.py +23 -19
  102. imap_processing/mag/l1a/mag_l1a_data.py +35 -10
  103. imap_processing/mag/l1b/mag_l1b.py +259 -50
  104. imap_processing/mag/l1c/interpolation_methods.py +388 -0
  105. imap_processing/mag/l1c/mag_l1c.py +621 -17
  106. imap_processing/mag/l2/mag_l2.py +140 -0
  107. imap_processing/mag/l2/mag_l2_data.py +288 -0
  108. imap_processing/quality_flags.py +1 -0
  109. imap_processing/spacecraft/packet_definitions/scid_x252.xml +538 -0
  110. imap_processing/spacecraft/quaternions.py +121 -0
  111. imap_processing/spice/geometry.py +19 -22
  112. imap_processing/spice/kernels.py +0 -276
  113. imap_processing/spice/pointing_frame.py +257 -0
  114. imap_processing/spice/repoint.py +149 -0
  115. imap_processing/spice/spin.py +38 -33
  116. imap_processing/spice/time.py +24 -0
  117. imap_processing/swapi/l1/swapi_l1.py +20 -12
  118. imap_processing/swapi/l2/swapi_l2.py +116 -5
  119. imap_processing/swapi/swapi_utils.py +32 -0
  120. imap_processing/swe/l1a/swe_l1a.py +44 -12
  121. imap_processing/swe/l1a/swe_science.py +13 -13
  122. imap_processing/swe/l1b/swe_l1b.py +898 -23
  123. imap_processing/swe/l2/swe_l2.py +75 -136
  124. imap_processing/swe/packet_definitions/swe_packet_definition.xml +1121 -1
  125. imap_processing/swe/utils/swe_constants.py +64 -0
  126. imap_processing/swe/utils/swe_utils.py +85 -28
  127. imap_processing/tests/ccsds/test_data/expected_output.xml +40 -1
  128. imap_processing/tests/ccsds/test_excel_to_xtce.py +24 -21
  129. imap_processing/tests/cdf/test_data/imap_instrument2_global_cdf_attrs.yaml +0 -2
  130. imap_processing/tests/cdf/test_utils.py +14 -16
  131. imap_processing/tests/codice/conftest.py +44 -33
  132. imap_processing/tests/codice/data/validation/imap_codice_l1a_hi-counters-aggregated_20241110193700_v0.0.0.cdf +0 -0
  133. imap_processing/tests/codice/data/validation/imap_codice_l1a_hi-counters-singles_20241110193700_v0.0.0.cdf +0 -0
  134. imap_processing/tests/codice/data/validation/imap_codice_l1a_hi-ialirt_20241110193700_v0.0.0.cdf +0 -0
  135. imap_processing/tests/codice/data/validation/imap_codice_l1a_hi-omni_20241110193700_v0.0.0.cdf +0 -0
  136. imap_processing/tests/codice/data/validation/imap_codice_l1a_hi-pha_20241110193700_v0.0.0.cdf +0 -0
  137. imap_processing/tests/codice/data/validation/imap_codice_l1a_hi-priorities_20241110193700_v0.0.0.cdf +0 -0
  138. imap_processing/tests/codice/data/validation/imap_codice_l1a_hi-sectored_20241110193700_v0.0.0.cdf +0 -0
  139. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-counters-aggregated_20241110193700_v0.0.0.cdf +0 -0
  140. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-counters-singles_20241110193700_v0.0.0.cdf +0 -0
  141. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-ialirt_20241110193700_v0.0.0.cdf +0 -0
  142. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-nsw-angular_20241110193700_v0.0.0.cdf +0 -0
  143. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-nsw-priority_20241110193700_v0.0.0.cdf +0 -0
  144. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-nsw-species_20241110193700_v0.0.0.cdf +0 -0
  145. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-pha_20241110193700_v0.0.0.cdf +0 -0
  146. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-sw-angular_20241110193700_v0.0.0.cdf +0 -0
  147. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-sw-priority_20241110193700_v0.0.0.cdf +0 -0
  148. imap_processing/tests/codice/data/validation/imap_codice_l1a_lo-sw-species_20241110193700_v0.0.0.cdf +0 -0
  149. imap_processing/tests/codice/test_codice_l1a.py +126 -53
  150. imap_processing/tests/codice/test_codice_l1b.py +6 -7
  151. imap_processing/tests/codice/test_decompress.py +4 -4
  152. imap_processing/tests/conftest.py +239 -27
  153. imap_processing/tests/ena_maps/conftest.py +51 -0
  154. imap_processing/tests/ena_maps/test_ena_maps.py +1068 -110
  155. imap_processing/tests/ena_maps/test_map_utils.py +66 -43
  156. imap_processing/tests/ena_maps/test_spatial_utils.py +17 -21
  157. imap_processing/tests/glows/conftest.py +10 -14
  158. imap_processing/tests/glows/test_glows_decom.py +4 -4
  159. imap_processing/tests/glows/test_glows_l1a_cdf.py +6 -27
  160. imap_processing/tests/glows/test_glows_l1a_data.py +6 -8
  161. imap_processing/tests/glows/test_glows_l1b.py +11 -11
  162. imap_processing/tests/glows/test_glows_l1b_data.py +5 -5
  163. imap_processing/tests/glows/test_glows_l2.py +2 -8
  164. imap_processing/tests/hi/conftest.py +1 -1
  165. imap_processing/tests/hi/data/l0/H45_diag_fee_20250208.bin +0 -0
  166. imap_processing/tests/hi/data/l0/H45_diag_fee_20250208_verify.csv +205 -0
  167. imap_processing/tests/hi/test_hi_l1b.py +22 -27
  168. imap_processing/tests/hi/test_hi_l1c.py +249 -18
  169. imap_processing/tests/hi/test_l1a.py +35 -7
  170. imap_processing/tests/hi/test_science_direct_event.py +3 -3
  171. imap_processing/tests/hi/test_utils.py +24 -2
  172. imap_processing/tests/hit/helpers/l1_validation.py +74 -73
  173. imap_processing/tests/hit/test_data/hskp_sample.ccsds +0 -0
  174. imap_processing/tests/hit/test_data/imap_hit_l0_raw_20100105_v001.pkts +0 -0
  175. imap_processing/tests/hit/test_decom_hit.py +5 -1
  176. imap_processing/tests/hit/test_hit_l1a.py +32 -36
  177. imap_processing/tests/hit/test_hit_l1b.py +300 -81
  178. imap_processing/tests/hit/test_hit_l2.py +716 -0
  179. imap_processing/tests/hit/test_hit_utils.py +184 -7
  180. imap_processing/tests/hit/validation_data/hit_l1b_standard_sample2_nsrl_v4_3decimals.csv +62 -62
  181. imap_processing/tests/hit/validation_data/hskp_sample_eu_3_6_2025.csv +89 -0
  182. imap_processing/tests/hit/validation_data/hskp_sample_raw.csv +89 -88
  183. imap_processing/tests/hit/validation_data/sci_sample_raw.csv +1 -1
  184. imap_processing/tests/ialirt/data/l0/461971383-404.bin +0 -0
  185. imap_processing/tests/ialirt/data/l0/461971384-405.bin +0 -0
  186. imap_processing/tests/ialirt/data/l0/461971385-406.bin +0 -0
  187. imap_processing/tests/ialirt/data/l0/461971386-407.bin +0 -0
  188. imap_processing/tests/ialirt/data/l0/461971387-408.bin +0 -0
  189. imap_processing/tests/ialirt/data/l0/461971388-409.bin +0 -0
  190. imap_processing/tests/ialirt/data/l0/461971389-410.bin +0 -0
  191. imap_processing/tests/ialirt/data/l0/461971390-411.bin +0 -0
  192. imap_processing/tests/ialirt/data/l0/461971391-412.bin +0 -0
  193. imap_processing/tests/ialirt/data/l0/sample_decoded_i-alirt_data.csv +383 -0
  194. imap_processing/tests/ialirt/unit/test_decom_ialirt.py +16 -81
  195. imap_processing/tests/ialirt/unit/test_grouping.py +81 -0
  196. imap_processing/tests/ialirt/unit/test_parse_mag.py +223 -0
  197. imap_processing/tests/ialirt/unit/test_process_codicehi.py +3 -3
  198. imap_processing/tests/ialirt/unit/test_process_codicelo.py +3 -10
  199. imap_processing/tests/ialirt/unit/test_process_ephemeris.py +4 -4
  200. imap_processing/tests/ialirt/unit/test_process_hit.py +3 -3
  201. imap_processing/tests/ialirt/unit/test_process_swapi.py +24 -16
  202. imap_processing/tests/ialirt/unit/test_process_swe.py +319 -6
  203. imap_processing/tests/ialirt/unit/test_time.py +16 -0
  204. imap_processing/tests/idex/conftest.py +127 -6
  205. imap_processing/tests/idex/test_data/imap_idex_l0_raw_20231218_v001.pkts +0 -0
  206. imap_processing/tests/idex/test_data/imap_idex_l0_raw_20241206_v001.pkts +0 -0
  207. imap_processing/tests/idex/test_data/imap_idex_l0_raw_20250108_v001.pkts +0 -0
  208. imap_processing/tests/idex/test_data/impact_14_tof_high_data.txt +4508 -4508
  209. imap_processing/tests/idex/test_idex_l0.py +33 -11
  210. imap_processing/tests/idex/test_idex_l1a.py +92 -21
  211. imap_processing/tests/idex/test_idex_l1b.py +106 -27
  212. imap_processing/tests/idex/test_idex_l2a.py +399 -0
  213. imap_processing/tests/idex/test_idex_l2b.py +93 -0
  214. imap_processing/tests/lo/test_cdfs/imap_lo_l1a_de_20241022_v002.cdf +0 -0
  215. imap_processing/tests/lo/test_cdfs/imap_lo_l1a_spin_20241022_v002.cdf +0 -0
  216. imap_processing/tests/lo/test_lo_l1a.py +3 -3
  217. imap_processing/tests/lo/test_lo_l1b.py +515 -6
  218. imap_processing/tests/lo/test_lo_l1c.py +1 -1
  219. imap_processing/tests/lo/test_lo_science.py +7 -7
  220. imap_processing/tests/lo/test_star_sensor.py +1 -1
  221. imap_processing/tests/mag/conftest.py +120 -2
  222. imap_processing/tests/mag/test_mag_decom.py +5 -4
  223. imap_processing/tests/mag/test_mag_l1a.py +51 -7
  224. imap_processing/tests/mag/test_mag_l1b.py +40 -59
  225. imap_processing/tests/mag/test_mag_l1c.py +354 -19
  226. imap_processing/tests/mag/test_mag_l2.py +130 -0
  227. imap_processing/tests/mag/test_mag_validation.py +247 -26
  228. imap_processing/tests/mag/validation/L1b/T009/MAGScience-normal-(2,2)-8s-20250204-16h39.csv +17 -0
  229. imap_processing/tests/mag/validation/L1b/T009/mag-l1a-l1b-t009-magi-out.csv +16 -16
  230. imap_processing/tests/mag/validation/L1b/T009/mag-l1a-l1b-t009-mago-out.csv +16 -16
  231. imap_processing/tests/mag/validation/L1b/T010/MAGScience-normal-(2,2)-8s-20250206-12h05.csv +17 -0
  232. imap_processing/tests/mag/validation/L1b/T011/MAGScience-normal-(2,2)-8s-20250204-16h08.csv +17 -0
  233. imap_processing/tests/mag/validation/L1b/T011/mag-l1a-l1b-t011-magi-out.csv +16 -16
  234. imap_processing/tests/mag/validation/L1b/T011/mag-l1a-l1b-t011-mago-out.csv +16 -16
  235. imap_processing/tests/mag/validation/L1b/T012/MAGScience-normal-(2,2)-8s-20250204-16h08.csv +17 -0
  236. imap_processing/tests/mag/validation/L1b/T012/data.bin +0 -0
  237. imap_processing/tests/mag/validation/L1b/T012/field_like_all_ranges.txt +19200 -0
  238. imap_processing/tests/mag/validation/L1b/T012/mag-l1a-l1b-t012-cal.cdf +0 -0
  239. imap_processing/tests/mag/validation/L1b/T012/mag-l1a-l1b-t012-in.csv +17 -0
  240. imap_processing/tests/mag/validation/L1b/T012/mag-l1a-l1b-t012-magi-out.csv +17 -0
  241. imap_processing/tests/mag/validation/L1b/T012/mag-l1a-l1b-t012-mago-out.csv +17 -0
  242. imap_processing/tests/mag/validation/L1c/T013/mag-l1b-l1c-t013-magi-normal-in.csv +1217 -0
  243. imap_processing/tests/mag/validation/L1c/T013/mag-l1b-l1c-t013-magi-normal-out.csv +1857 -0
  244. imap_processing/tests/mag/validation/L1c/T013/mag-l1b-l1c-t013-mago-normal-in.csv +1217 -0
  245. imap_processing/tests/mag/validation/L1c/T013/mag-l1b-l1c-t013-mago-normal-out.csv +1857 -0
  246. imap_processing/tests/mag/validation/L1c/T014/mag-l1b-l1c-t014-magi-normal-in.csv +1217 -0
  247. imap_processing/tests/mag/validation/L1c/T014/mag-l1b-l1c-t014-magi-normal-out.csv +1793 -0
  248. imap_processing/tests/mag/validation/L1c/T014/mag-l1b-l1c-t014-mago-normal-in.csv +1217 -0
  249. imap_processing/tests/mag/validation/L1c/T014/mag-l1b-l1c-t014-mago-normal-out.csv +1793 -0
  250. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-magi-burst-in.csv +2561 -0
  251. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-magi-normal-in.csv +961 -0
  252. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-magi-normal-out.csv +1539 -0
  253. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-mago-normal-in.csv +1921 -0
  254. imap_processing/tests/mag/validation/L1c/T015/mag-l1b-l1c-t015-mago-normal-out.csv +2499 -0
  255. imap_processing/tests/mag/validation/L1c/T016/mag-l1b-l1c-t016-magi-normal-in.csv +865 -0
  256. imap_processing/tests/mag/validation/L1c/T016/mag-l1b-l1c-t016-magi-normal-out.csv +1196 -0
  257. imap_processing/tests/mag/validation/L1c/T016/mag-l1b-l1c-t016-mago-normal-in.csv +1729 -0
  258. imap_processing/tests/mag/validation/L1c/T016/mag-l1b-l1c-t016-mago-normal-out.csv +3053 -0
  259. imap_processing/tests/mag/validation/L2/imap_mag_l1b_norm-mago_20251017_v002.cdf +0 -0
  260. imap_processing/tests/mag/validation/calibration/imap_mag_l1b-calibration_20240229_v001.cdf +0 -0
  261. imap_processing/tests/mag/validation/calibration/imap_mag_l2-calibration-matrices_20251017_v004.cdf +0 -0
  262. imap_processing/tests/mag/validation/calibration/imap_mag_l2-offsets-norm_20251017_20251017_v001.cdf +0 -0
  263. imap_processing/tests/spacecraft/data/SSR_2024_190_20_08_12_0483851794_2_DA_apid0594_1packet.pkts +0 -0
  264. imap_processing/tests/spacecraft/test_quaternions.py +71 -0
  265. imap_processing/tests/spice/test_data/fake_repoint_data.csv +5 -0
  266. imap_processing/tests/spice/test_data/fake_spin_data.csv +11 -11
  267. imap_processing/tests/spice/test_geometry.py +9 -12
  268. imap_processing/tests/spice/test_kernels.py +1 -200
  269. imap_processing/tests/spice/test_pointing_frame.py +185 -0
  270. imap_processing/tests/spice/test_repoint.py +121 -0
  271. imap_processing/tests/spice/test_spin.py +50 -9
  272. imap_processing/tests/spice/test_time.py +14 -0
  273. imap_processing/tests/swapi/lut/imap_swapi_esa-unit-conversion_20250211_v000.csv +73 -0
  274. imap_processing/tests/swapi/lut/imap_swapi_lut-notes_20250211_v000.csv +1025 -0
  275. imap_processing/tests/swapi/test_swapi_l1.py +13 -11
  276. imap_processing/tests/swapi/test_swapi_l2.py +180 -8
  277. imap_processing/tests/swe/l0_data/2024051010_SWE_HK_packet.bin +0 -0
  278. imap_processing/tests/swe/l0_data/2024051011_SWE_CEM_RAW_packet.bin +0 -0
  279. imap_processing/tests/swe/l0_validation_data/idle_export_eu.SWE_APP_HK_20240510_092742.csv +49 -0
  280. imap_processing/tests/swe/l0_validation_data/idle_export_eu.SWE_CEM_RAW_20240510_092742.csv +593 -0
  281. imap_processing/tests/swe/lut/checker-board-indices.csv +24 -0
  282. imap_processing/tests/swe/lut/imap_swe_esa-lut_20250301_v000.csv +385 -0
  283. imap_processing/tests/swe/lut/imap_swe_l1b-in-flight-cal_20240510_20260716_v000.csv +3 -0
  284. imap_processing/tests/swe/test_swe_l1a.py +20 -2
  285. imap_processing/tests/swe/test_swe_l1a_cem_raw.py +52 -0
  286. imap_processing/tests/swe/test_swe_l1a_hk.py +68 -0
  287. imap_processing/tests/swe/test_swe_l1a_science.py +3 -3
  288. imap_processing/tests/swe/test_swe_l1b.py +162 -24
  289. imap_processing/tests/swe/test_swe_l2.py +153 -91
  290. imap_processing/tests/test_cli.py +171 -88
  291. imap_processing/tests/test_utils.py +140 -17
  292. imap_processing/tests/ultra/data/l0/FM45_UltraFM45_Functional_2024-01-22T0105_20240122T010548.CCSDS +0 -0
  293. imap_processing/tests/ultra/data/l0/ultra45_raw_sc_ultraimgrates_20220530_00.csv +164 -0
  294. imap_processing/tests/ultra/{test_data → data}/l0/ultra45_raw_sc_ultrarawimg_withFSWcalcs_FM45_40P_Phi28p5_BeamCal_LinearScan_phi2850_theta-000_20240207T102740.csv +3243 -3243
  295. imap_processing/tests/ultra/data/mock_data.py +369 -0
  296. imap_processing/tests/ultra/unit/conftest.py +115 -89
  297. imap_processing/tests/ultra/unit/test_badtimes.py +4 -4
  298. imap_processing/tests/ultra/unit/test_cullingmask.py +8 -6
  299. imap_processing/tests/ultra/unit/test_de.py +14 -13
  300. imap_processing/tests/ultra/unit/test_decom_apid_880.py +27 -76
  301. imap_processing/tests/ultra/unit/test_decom_apid_881.py +54 -11
  302. imap_processing/tests/ultra/unit/test_decom_apid_883.py +12 -10
  303. imap_processing/tests/ultra/unit/test_decom_apid_896.py +202 -55
  304. imap_processing/tests/ultra/unit/test_lookup_utils.py +23 -1
  305. imap_processing/tests/ultra/unit/test_spacecraft_pset.py +77 -0
  306. imap_processing/tests/ultra/unit/test_ultra_l1a.py +98 -305
  307. imap_processing/tests/ultra/unit/test_ultra_l1b.py +60 -14
  308. imap_processing/tests/ultra/unit/test_ultra_l1b_annotated.py +2 -2
  309. imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py +26 -27
  310. imap_processing/tests/ultra/unit/test_ultra_l1b_extended.py +239 -70
  311. imap_processing/tests/ultra/unit/test_ultra_l1c.py +5 -5
  312. imap_processing/tests/ultra/unit/test_ultra_l1c_pset_bins.py +114 -83
  313. imap_processing/tests/ultra/unit/test_ultra_l2.py +230 -0
  314. imap_processing/ultra/constants.py +1 -1
  315. imap_processing/ultra/l0/decom_tools.py +27 -39
  316. imap_processing/ultra/l0/decom_ultra.py +168 -204
  317. imap_processing/ultra/l0/ultra_utils.py +152 -136
  318. imap_processing/ultra/l1a/ultra_l1a.py +55 -271
  319. imap_processing/ultra/l1b/badtimes.py +1 -4
  320. imap_processing/ultra/l1b/cullingmask.py +2 -6
  321. imap_processing/ultra/l1b/de.py +116 -57
  322. imap_processing/ultra/l1b/extendedspin.py +20 -18
  323. imap_processing/ultra/l1b/lookup_utils.py +72 -9
  324. imap_processing/ultra/l1b/ultra_l1b.py +36 -16
  325. imap_processing/ultra/l1b/ultra_l1b_culling.py +66 -30
  326. imap_processing/ultra/l1b/ultra_l1b_extended.py +297 -94
  327. imap_processing/ultra/l1c/histogram.py +2 -6
  328. imap_processing/ultra/l1c/spacecraft_pset.py +84 -0
  329. imap_processing/ultra/l1c/ultra_l1c.py +8 -9
  330. imap_processing/ultra/l1c/ultra_l1c_pset_bins.py +206 -108
  331. imap_processing/ultra/l2/ultra_l2.py +299 -0
  332. imap_processing/ultra/lookup_tables/Angular_Profiles_FM45_LeftSlit.csv +526 -0
  333. imap_processing/ultra/lookup_tables/Angular_Profiles_FM45_RightSlit.csv +526 -0
  334. imap_processing/ultra/lookup_tables/Angular_Profiles_FM90_LeftSlit.csv +526 -0
  335. imap_processing/ultra/lookup_tables/Angular_Profiles_FM90_RightSlit.csv +526 -0
  336. imap_processing/ultra/lookup_tables/FM45_Startup1_ULTRA_IMGPARAMS_20240719.csv +2 -2
  337. imap_processing/ultra/lookup_tables/FM90_Startup1_ULTRA_IMGPARAMS_20240719.csv +2 -0
  338. imap_processing/ultra/packet_definitions/README.md +38 -0
  339. imap_processing/ultra/packet_definitions/ULTRA_SCI_COMBINED.xml +15302 -482
  340. imap_processing/ultra/utils/ultra_l1_utils.py +31 -12
  341. imap_processing/utils.py +69 -29
  342. {imap_processing-0.11.0.dist-info → imap_processing-0.13.0.dist-info}/METADATA +10 -6
  343. imap_processing-0.13.0.dist-info/RECORD +578 -0
  344. imap_processing/cdf/config/imap_mag_l1_variable_attrs.yaml +0 -237
  345. imap_processing/hi/l1a/housekeeping.py +0 -27
  346. imap_processing/hi/l1b/hi_eng_unit_convert_table.csv +0 -154
  347. imap_processing/swe/l1b/swe_esa_lookup_table.csv +0 -1441
  348. imap_processing/swe/l1b/swe_l1b_science.py +0 -652
  349. imap_processing/tests/codice/data/imap_codice_l1a_hi-counters-aggregated_20240429_v001.cdf +0 -0
  350. imap_processing/tests/codice/data/imap_codice_l1a_hi-counters-singles_20240429_v001.cdf +0 -0
  351. imap_processing/tests/codice/data/imap_codice_l1a_hi-omni_20240429_v001.cdf +0 -0
  352. imap_processing/tests/codice/data/imap_codice_l1a_hi-sectored_20240429_v001.cdf +0 -0
  353. imap_processing/tests/codice/data/imap_codice_l1a_hskp_20100101_v001.cdf +0 -0
  354. imap_processing/tests/codice/data/imap_codice_l1a_lo-counters-aggregated_20240429_v001.cdf +0 -0
  355. imap_processing/tests/codice/data/imap_codice_l1a_lo-counters-singles_20240429_v001.cdf +0 -0
  356. imap_processing/tests/codice/data/imap_codice_l1a_lo-nsw-angular_20240429_v001.cdf +0 -0
  357. imap_processing/tests/codice/data/imap_codice_l1a_lo-nsw-priority_20240429_v001.cdf +0 -0
  358. imap_processing/tests/codice/data/imap_codice_l1a_lo-nsw-species_20240429_v001.cdf +0 -0
  359. imap_processing/tests/codice/data/imap_codice_l1a_lo-sw-angular_20240429_v001.cdf +0 -0
  360. imap_processing/tests/codice/data/imap_codice_l1a_lo-sw-priority_20240429_v001.cdf +0 -0
  361. imap_processing/tests/codice/data/imap_codice_l1a_lo-sw-species_20240429_v001.cdf +0 -0
  362. imap_processing/tests/codice/data/imap_codice_l1b_hi-counters-aggregated_20240429_v001.cdf +0 -0
  363. imap_processing/tests/codice/data/imap_codice_l1b_hi-counters-singles_20240429_v001.cdf +0 -0
  364. imap_processing/tests/codice/data/imap_codice_l1b_hi-omni_20240429_v001.cdf +0 -0
  365. imap_processing/tests/codice/data/imap_codice_l1b_hi-sectored_20240429_v001.cdf +0 -0
  366. imap_processing/tests/codice/data/imap_codice_l1b_hskp_20100101_v001.cdf +0 -0
  367. imap_processing/tests/codice/data/imap_codice_l1b_lo-counters-aggregated_20240429_v001.cdf +0 -0
  368. imap_processing/tests/codice/data/imap_codice_l1b_lo-counters-singles_20240429_v001.cdf +0 -0
  369. imap_processing/tests/codice/data/imap_codice_l1b_lo-nsw-angular_20240429_v001.cdf +0 -0
  370. imap_processing/tests/codice/data/imap_codice_l1b_lo-nsw-priority_20240429_v001.cdf +0 -0
  371. imap_processing/tests/codice/data/imap_codice_l1b_lo-nsw-species_20240429_v001.cdf +0 -0
  372. imap_processing/tests/codice/data/imap_codice_l1b_lo-sw-angular_20240429_v001.cdf +0 -0
  373. imap_processing/tests/codice/data/imap_codice_l1b_lo-sw-priority_20240429_v001.cdf +0 -0
  374. imap_processing/tests/codice/data/imap_codice_l1b_lo-sw-species_20240429_v001.cdf +0 -0
  375. imap_processing/tests/hi/data/l1/imap_hi_l1b_45sensor-de_20250415_v999.cdf +0 -0
  376. imap_processing/tests/hit/PREFLIGHT_raw_record_2023_256_15_59_04_apid1251.pkts +0 -0
  377. imap_processing/tests/hit/PREFLIGHT_raw_record_2023_256_15_59_04_apid1252.pkts +0 -0
  378. imap_processing/tests/hit/validation_data/hskp_sample_eu.csv +0 -89
  379. imap_processing/tests/hit/validation_data/sci_sample_raw1.csv +0 -29
  380. imap_processing/tests/idex/test_data/imap_idex_l0_raw_20231214_v001.pkts +0 -0
  381. imap_processing/tests/lo/test_cdfs/imap_lo_l1a_de_20100101_v001.cdf +0 -0
  382. imap_processing/tests/lo/test_cdfs/imap_lo_l1a_spin_20100101_v001.cdf +0 -0
  383. imap_processing/tests/swe/test_swe_l1b_science.py +0 -84
  384. imap_processing/tests/ultra/test_data/mock_data.py +0 -161
  385. imap_processing/ultra/l1c/pset.py +0 -40
  386. imap_processing/ultra/lookup_tables/dps_sensitivity45.cdf +0 -0
  387. imap_processing-0.11.0.dist-info/RECORD +0 -488
  388. /imap_processing/idex/packet_definitions/{idex_packet_definition.xml → idex_science_packet_definition.xml} +0 -0
  389. /imap_processing/tests/ialirt/{test_data → data}/l0/20240827095047_SWE_IALIRT_packet.bin +0 -0
  390. /imap_processing/tests/ialirt/{test_data → data}/l0/BinLog CCSDS_FRAG_TLM_20240826_152323Z_IALIRT_data_for_SDC.bin +0 -0
  391. /imap_processing/tests/ialirt/{test_data → data}/l0/IALiRT Raw Packet Telemetry.txt +0 -0
  392. /imap_processing/tests/ialirt/{test_data → data}/l0/apid01152.tlm +0 -0
  393. /imap_processing/tests/ialirt/{test_data → data}/l0/eu_SWP_IAL_20240826_152033.csv +0 -0
  394. /imap_processing/tests/ialirt/{test_data → data}/l0/hi_fsw_view_1_ccsds.bin +0 -0
  395. /imap_processing/tests/ialirt/{test_data → data}/l0/hit_ialirt_sample.ccsds +0 -0
  396. /imap_processing/tests/ialirt/{test_data → data}/l0/hit_ialirt_sample.csv +0 -0
  397. /imap_processing/tests/ialirt/{test_data → data}/l0/idle_export_eu.SWE_IALIRT_20240827_093852.csv +0 -0
  398. /imap_processing/tests/ialirt/{test_data → data}/l0/imap_codice_l1a_hi-ialirt_20240523200000_v0.0.0.cdf +0 -0
  399. /imap_processing/tests/ialirt/{test_data → data}/l0/imap_codice_l1a_lo-ialirt_20241110193700_v0.0.0.cdf +0 -0
  400. /imap_processing/{mag/l1b → tests/spacecraft}/__init__.py +0 -0
  401. /imap_processing/{swe/l1b/engineering_unit_convert_table.csv → tests/swe/lut/imap_swe_eu-conversion_20240510_v000.csv} +0 -0
  402. /imap_processing/tests/ultra/{test_data → data}/l0/FM45_40P_Phi28p5_BeamCal_LinearScan_phi28.50_theta-0.00_20240207T102740.CCSDS +0 -0
  403. /imap_processing/tests/ultra/{test_data → data}/l0/FM45_7P_Phi0.0_BeamCal_LinearScan_phi0.04_theta-0.01_20230821T121304.CCSDS +0 -0
  404. /imap_processing/tests/ultra/{test_data → data}/l0/FM45_TV_Cycle6_Hot_Ops_Front212_20240124T063837.CCSDS +0 -0
  405. /imap_processing/tests/ultra/{test_data → data}/l0/Ultra45_EM_SwRI_Cal_Run7_ThetaScan_20220530T225054.CCSDS +0 -0
  406. /imap_processing/tests/ultra/{test_data → data}/l0/ultra45_raw_sc_auxdata_Ultra45_EM_SwRI_Cal_Run7_ThetaScan_20220530T225054.csv +0 -0
  407. /imap_processing/tests/ultra/{test_data → data}/l0/ultra45_raw_sc_enaphxtofhangimg_FM45_TV_Cycle6_Hot_Ops_Front212_20240124T063837.csv +0 -0
  408. /imap_processing/tests/ultra/{test_data → data}/l0/ultra45_raw_sc_ultraimgrates_Ultra45_EM_SwRI_Cal_Run7_ThetaScan_20220530T225054.csv +0 -0
  409. /imap_processing/tests/ultra/{test_data → data}/l0/ultra45_raw_sc_ultrarawimgevent_FM45_7P_Phi00_BeamCal_LinearScan_phi004_theta-001_20230821T121304.csv +0 -0
  410. /imap_processing/tests/ultra/{test_data → data}/l1/dps_exposure_helio_45_E1.cdf +0 -0
  411. /imap_processing/tests/ultra/{test_data → data}/l1/dps_exposure_helio_45_E12.cdf +0 -0
  412. /imap_processing/tests/ultra/{test_data → data}/l1/dps_exposure_helio_45_E24.cdf +0 -0
  413. {imap_processing-0.11.0.dist-info → imap_processing-0.13.0.dist-info}/LICENSE +0 -0
  414. {imap_processing-0.11.0.dist-info → imap_processing-0.13.0.dist-info}/WHEEL +0 -0
  415. {imap_processing-0.11.0.dist-info → imap_processing-0.13.0.dist-info}/entry_points.txt +0 -0
@@ -2,21 +2,32 @@
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
 
12
+ import astropy_healpix.healpy as hp
10
13
  import numpy as np
11
14
  import xarray as xr
12
15
  from numpy.typing import NDArray
13
16
 
14
17
  from imap_processing.cdf.utils import load_cdf
15
18
  from imap_processing.ena_maps.utils import map_utils, spatial_utils
19
+
20
+ # The coordinate names can vary between L1C and L2 data (e.g. azimuth vs longitude),
21
+ # so we define an enum to handle the coordinate names.
22
+ from imap_processing.ena_maps.utils.coordinates import CoordNames
16
23
  from imap_processing.spice import geometry
24
+ from imap_processing.spice.time import ttj2000ns_to_et
17
25
 
18
26
  logger = logging.getLogger(__name__)
19
27
 
28
+ # Set the maximum recursion depth for the conversion from Healpix to rectangular SkyMap.
29
+ MAX_SUBDIV_RECURSION_DEPTH = 8
30
+
20
31
 
21
32
  class SkyTilingType(Enum):
22
33
  """Enumeration of the types of tiling used in the ENA maps."""
@@ -60,7 +71,7 @@ class IndexMatchMethod(Enum):
60
71
  def match_coords_to_indices(
61
72
  input_object: PointingSet | AbstractSkyMap,
62
73
  output_object: PointingSet | AbstractSkyMap,
63
- event_time: float | None = None,
74
+ event_et: float | None = None,
64
75
  ) -> NDArray:
65
76
  """
66
77
  Find the output indices corresponding to each input coord between 2 spatial objects.
@@ -92,10 +103,11 @@ def match_coords_to_indices(
92
103
  The object containing a grid or tessellation of spatial pixels
93
104
  into which the input spatial pixel centers will 'land', and be matched to
94
105
  corresponding pixel 1D indices in the output frame.
95
- event_time : float, optional
106
+ event_et : float, optional
96
107
  Event time at which to transform the input spatial object to the output frame.
97
108
  This can be manually specified, e.g., for converting between Maps which do not
98
109
  contain an epoch value.
110
+ If specified, must be in SPICE compatible ET.
99
111
  The default value is None, in which case the event time of the PointingSet
100
112
  object is used.
101
113
 
@@ -121,12 +133,14 @@ def match_coords_to_indices(
121
133
  if isinstance(input_object, PointingSet) and isinstance(output_object, PointingSet):
122
134
  raise ValueError("Cannot match indices between two PointingSet objects.")
123
135
 
124
- # If event_time is not specified, use event_time of the PointingSet, if present.
125
- if event_time is None:
136
+ # If event_et is not specified, use epoch of the PointingSet, if present.
137
+ # The epoch will be in units of terrestrial time (TT) J2000 nanoseconds,
138
+ # which must be converted to ephemeris time (ET) for SPICE.
139
+ if event_et is None:
126
140
  if isinstance(input_object, PointingSet):
127
- event_time = input_object.data["epoch"].values
141
+ event_et = ttj2000ns_to_et(input_object.epoch)
128
142
  elif isinstance(output_object, PointingSet):
129
- event_time = output_object.data["epoch"].values
143
+ event_et = ttj2000ns_to_et(output_object.epoch)
130
144
  else:
131
145
  raise ValueError(
132
146
  "Event time must be specified if both objects are SkyMaps."
@@ -137,11 +151,11 @@ def match_coords_to_indices(
137
151
 
138
152
  # Transform the input pixel centers to the output frame
139
153
  input_obj_az_el_output_frame = geometry.frame_transform_az_el(
140
- et=event_time,
154
+ et=event_et,
141
155
  az_el=input_obj_az_el_input_frame,
142
156
  from_frame=input_object.spice_reference_frame,
143
157
  to_frame=output_object.spice_reference_frame,
144
- degrees=False,
158
+ degrees=True,
145
159
  )
146
160
 
147
161
  # The way indices are matched depends on the tiling type of the 2nd object
@@ -174,54 +188,142 @@ def match_coords_to_indices(
174
188
  elif output_object.tiling_type is SkyTilingType.HEALPIX:
175
189
  # To match to a Healpix tessellation, we need to use the healpy function ang2pix
176
190
  # which directly returns the index on the output frame's Healpix tessellation.
177
- """
178
- Leaving this as a placeholder for now, so we don't yet
179
- need to add a healpy dependency. It will look something like the
180
- following code, much simpler than the rectangular case:
181
-
182
- ```python
183
- import healpy as hp
184
191
  flat_indices_input_grid_output_frame = hp.ang2pix(
185
- nside=spatial_object_output_frame.nside,
186
- theta=np.rad2deg(obj1_az_el_points_frame2[:, 0]), # Lon
187
- phi=np.rad2deg(obj1_az_el_points_frame2[:, 1]), # Lat
188
- nest=False,
192
+ nside=output_object.nside,
193
+ theta=input_obj_az_el_output_frame[:, 0], # Lon in degrees
194
+ phi=input_obj_az_el_output_frame[:, 1], # Lat in degrees
195
+ nest=output_object.nested,
189
196
  lonlat=True,
190
197
  )
191
- ```
192
- """
193
- raise NotImplementedError(
194
- "Index matching for output tiling type Healpix is not yet implemented."
195
- )
196
-
197
198
  else:
198
199
  raise ValueError(
199
200
  "Tiling type of the output frame must be either RECTANGULAR or HEALPIX."
201
+ f"Received: {output_object.tiling_type}"
200
202
  )
201
203
 
202
204
  return flat_indices_input_grid_output_frame
203
205
 
204
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
+
205
212
  # Define the pointing set classes
206
213
  class PointingSet(ABC):
207
214
  """
208
215
  Abstract class to contain pointing set (PSET) data in the context of ENA sky maps.
209
216
 
217
+ Any spatial axes - (azimuth, elevation) for Rectangularly gridded tilings or
218
+ (pixel index) for Healpix - must be stored in the last axis/axes of each data array.
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()
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]
285
+
286
+ @property
287
+ def unwrapped_dims_dict(self) -> dict[str, tuple[str, ...]]:
288
+ """
289
+ Get dimensions of each variable in the pointing set, with only 1 spatial dim.
290
+
291
+ Returns
292
+ -------
293
+ unwrapped_dims_dict : dict[str, tuple[str, ...]]
294
+ Dictionary of variable names and their dimensions, with only 1 spatial dim.
295
+ The generic pixel dimension is always included.
296
+ E.g.: {"counts": ("epoch", "energy", "pixel")} .
297
+ """
298
+ variable_dims = {}
299
+ for var_name in self.data.data_vars:
300
+ pset_dims = self.data[var_name].dims
301
+ non_spatial_dims = tuple(
302
+ dim for dim in pset_dims if dim not in self.spatial_coords
303
+ )
304
+
305
+ variable_dims[var_name] = (
306
+ *non_spatial_dims,
307
+ CoordNames.GENERIC_PIXEL.value,
308
+ )
309
+ return variable_dims
310
+
311
+ @property
312
+ def non_spatial_coords(self) -> dict[str, xr.DataArray]:
313
+ """
314
+ Get the non-spatial coordinates of the pointing set.
315
+
316
+ Returns
317
+ -------
318
+ non_spatial_coords : dict[str, xr.DataArray]
319
+ Dictionary of coordinate names and their data arrays.
320
+ E.g.: {"epoch": [12345,], "energy": [100, 200, 300]} .
321
+ """
322
+ non_spatial_coords = {}
323
+ for coord_name in self.data.coords:
324
+ if coord_name not in self.spatial_coords:
325
+ non_spatial_coords[coord_name] = self.data[coord_name]
326
+ return non_spatial_coords
225
327
 
226
328
  def __repr__(self) -> str:
227
329
  """
@@ -233,66 +335,58 @@ class PointingSet(ABC):
233
335
  String representation of the pointing set.
234
336
  """
235
337
  return (
236
- f"{self.__class__} PointingSet"
338
+ f"{self.__class__.__name__} PointingSet"
237
339
  f"(spice_reference_frame={self.spice_reference_frame})"
238
340
  )
239
341
 
240
342
 
241
- class UltraPointingSet(PointingSet):
343
+ class RectangularPointingSet(PointingSet):
242
344
  """
243
- PSET object specifically for ULTRA data, nominally at Level 1C.
345
+ Pointing set object for rectangularly tiled data. Currently used in testing.
244
346
 
245
347
  Parameters
246
348
  ----------
247
- l1c_dataset : xr.Dataset | pathlib.Path | str
248
- L1c xarray dataset containing the pointing set data or the path to the dataset.
249
- Currently, the dataset is expected to be in a rectangular grid,
349
+ dataset : xr.Dataset | str | Path
350
+ Dataset or path to CDF file containing the pointing set data.
351
+ Currently, the dataset is expected to be tiled in a rectangular grid,
250
352
  with data_vars indexed along the coordinates:
251
353
  - 'epoch' : time value (1 value per PSET)
252
- - 'azimuth_bin_center' : azimuth bin center values
253
- - 'elevation_bin_center' : elevation bin center values
254
- Some data_vars may additionally be indexed by energy bin;
255
- however, only the spatial axes are used in this class.
354
+ - 'longitude' : (number of longitude/az bins in L1C)
355
+ - 'latitude' : (number of latitude/el bins in L1C)
256
356
  spice_reference_frame : geometry.SpiceFrame
257
357
  The reference Spice frame of the pointing set. Default is IMAP_DPS.
258
358
 
259
359
  Raises
260
360
  ------
261
361
  ValueError
262
- If the azimuth or elevation bin centers do not match the constructed grid.
263
- Or if the azimuth or elevation bin spacing is not uniform.
362
+ If the longitude/az or latitude/el bin centers don't match the constructed grid.
363
+ Or if the longitude or latitude bin spacing is not uniform.
264
364
  ValueError
265
365
  If multiple epochs are found in the dataset.
266
366
  """
267
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
+
268
374
  def __init__(
269
375
  self,
270
- l1c_dataset: xr.Dataset | pathlib.Path | str,
376
+ dataset: xr.Dataset | str | Path,
271
377
  spice_reference_frame: geometry.SpiceFrame = geometry.SpiceFrame.IMAP_DPS,
272
378
  ):
273
- # Store the reference frame of the pointing set
274
- self.spice_reference_frame = spice_reference_frame
275
-
276
- # Read in the data and store the xarray dataset as data attr
277
- if isinstance(l1c_dataset, (str, pathlib.Path)):
278
- self.data = load_cdf(pathlib.Path(l1c_dataset))
279
- elif isinstance(l1c_dataset, xr.Dataset):
280
- self.data = l1c_dataset
281
-
282
- # A PSET must have a single epoch
283
- self.epoch = self.data["epoch"].values
284
- if len(np.unique(self.epoch)) > 1:
285
- raise ValueError("Multiple epochs found in the dataset.")
379
+ super().__init__(dataset, spice_reference_frame)
286
380
 
287
- # The rest of the constructor handles the rectangular grid
288
- # aspects of the Ultra PSET.
289
- # NOTE: This may be changed to Healpix tessellation in the future
290
- self.tiling_type = SkyTilingType.RECTANGULAR
381
+ self.spatial_coords = (
382
+ CoordNames.AZIMUTH_L1C.value,
383
+ CoordNames.ELEVATION_L1C.value,
384
+ )
291
385
 
292
386
  # Ensure 1D axes grids are uniformly spaced,
293
387
  # then set spacing based on data's azimuth bin spacing.
294
- az_bin_delta = np.diff(self.data["azimuth_bin_center"])
295
- el_bin_delta = np.diff(self.data["elevation_bin_center"])
388
+ az_bin_delta = np.diff(self.data[CoordNames.AZIMUTH_L1C.value])
389
+ el_bin_delta = np.diff(self.data[CoordNames.ELEVATION_L1C.value])
296
390
  if not np.allclose(az_bin_delta, az_bin_delta[0], atol=1e-10, rtol=0):
297
391
  raise ValueError("Azimuth bin spacing is not uniform.")
298
392
  if not np.allclose(el_bin_delta, el_bin_delta[0], atol=1e-10, rtol=0):
@@ -302,28 +396,28 @@ class UltraPointingSet(PointingSet):
302
396
  "Azimuth and elevation bin spacing do not match: "
303
397
  f"az {az_bin_delta[0]} != el {el_bin_delta[0]}."
304
398
  )
305
- self.spacing_deg = az_bin_delta[0]
399
+ spacing_deg = az_bin_delta[0]
306
400
 
307
- # Build the azimuth and elevation grids with an AzElSkyGrid object
401
+ # Build the az/azimuth and el/elevation grids with an AzElSkyGrid object
308
402
  # and check that the 1D axes match the dataset's az and el.
309
403
  self.sky_grid = spatial_utils.AzElSkyGrid(
310
- spacing_deg=self.spacing_deg,
404
+ spacing_deg=spacing_deg,
311
405
  )
312
406
 
313
407
  for dim, constructed_bins in zip(
314
- ["azimuth", "elevation"],
408
+ [CoordNames.AZIMUTH_L1C.value, CoordNames.ELEVATION_L1C.value],
315
409
  [self.sky_grid.az_bin_midpoints, self.sky_grid.el_bin_midpoints],
316
410
  ):
317
411
  if not np.allclose(
318
- sorted(np.rad2deg(constructed_bins)),
319
- self.data[f"{dim}_bin_center"],
412
+ sorted(constructed_bins),
413
+ self.data[dim],
320
414
  atol=1e-10,
321
415
  rtol=0,
322
416
  ):
323
417
  raise ValueError(
324
418
  f"{dim} bin centers do not match."
325
- f"Constructed: {np.rad2deg(constructed_bins)}"
326
- f"Dataset: {self.data[f'{dim}_bin_center']}"
419
+ f"Constructed: {constructed_bins}"
420
+ f"Dataset: {self.data[dim]}"
327
421
  )
328
422
 
329
423
  # Unwrap the az, el grids to series of points tiling the sky and combine them
@@ -336,91 +430,263 @@ class UltraPointingSet(PointingSet):
336
430
  self.sky_grid.el_grid.ravel(),
337
431
  )
338
432
  )
339
- self.num_points = self.az_el_points.shape[0]
340
433
 
341
- # Also store the bin edges for the pointing set to allow for "pull" method
342
- # of index matching (not yet implemented).
343
- # These are 1D arrays of different lengths and cannot be stacked.
344
- self.az_bin_edges = self.sky_grid.az_bin_edges
345
- self.el_bin_edges = self.sky_grid.el_bin_edges
346
434
 
347
- def __repr__(self) -> str:
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:
348
448
  """
349
- Return a string representation of the UltraPointingSet.
449
+ Number of pixels on the side of one of the 12 top-level healpix tiles.
350
450
 
351
451
  Returns
352
452
  -------
353
- str
354
- String representation of the UltraPointingSet.
453
+ npix: int
454
+ The number of pixels on the side of one of the 12 ‘top-level’ healpix
455
+ tiles.
355
456
  """
356
- return (
357
- f"UltraPointingSet\n\t(spice_reference_frame="
358
- f"{self.spice_reference_frame}, epoch={self.epoch}, "
359
- f"num_points={self.num_points})"
457
+ return hp.npix_to_nside(self.num_points)
458
+
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):
467
+ """
468
+ Pointing set object specifically for Healpix-tiled ULTRA data, nominally at Level1C.
469
+
470
+ Parameters
471
+ ----------
472
+ dataset : xr.Dataset | str | Path
473
+ Dataset or path to CDF file containing the pointing set data.
474
+ Currently, the dataset is expected to be tiled in a HEALPix tessellation,
475
+ with data_vars indexed along the coordinates:
476
+ - 'epoch' : time value (1 value per PSET, from the mean of the PSET)
477
+ - 'energy' : (number of energy bins in L1C)
478
+ - 'healpix_index' : HEALPix pixel index
479
+ Only the 'healpix_index' coordinate is used in this class for projection.
480
+ spice_reference_frame : geometry.SpiceFrame
481
+ The reference Spice frame of the pointing set. Default is IMAP_DPS.
482
+
483
+ Raises
484
+ ------
485
+ ValueError
486
+ If the longitude/az or latitude/el bin centers don't match the constructed grid.
487
+ Or if the longitude or latitude bin spacing is not uniform.
488
+ ValueError
489
+ If multiple epochs are found in the dataset.
490
+ """
491
+
492
+ def __init__(
493
+ self,
494
+ dataset: xr.Dataset | str | Path,
495
+ spice_reference_frame: geometry.SpiceFrame = geometry.SpiceFrame.IMAP_DPS,
496
+ ):
497
+ super().__init__(dataset, spice_reference_frame)
498
+
499
+ # Set the spatial coordinates and number of points
500
+ self.spatial_coords = (CoordNames.HEALPIX_INDEX.value,)
501
+
502
+ # Tracks Per-Pixel Solid Angle in steradians.
503
+ self.solid_angle = hp.nside2pixarea(self.nside, degrees=False)
504
+
505
+ # Get the azimuth and elevation coordinates of the healpix pixel centers (deg)
506
+ azimuth_pixel_center, elevation_pixel_center = hp.pix2ang(
507
+ nside=self.nside,
508
+ ipix=np.arange(self.num_points),
509
+ nest=self.nested,
510
+ lonlat=True,
360
511
  )
361
512
 
513
+ # Verify that the azimuth and elevation of the healpix pixel centers
514
+ # match the data's azimuth and elevation bin centers.
515
+ # NOTE: They can have different names in the L1C dataset
516
+ # (e.g. "longitude"/"latitude" vs "azimuth"/"elevation").
517
+ for dim, constructed_bins in zip(
518
+ [CoordNames.AZIMUTH_L1C.value, CoordNames.ELEVATION_L1C.value],
519
+ [azimuth_pixel_center, elevation_pixel_center],
520
+ ):
521
+ if not np.allclose(
522
+ self.data[dim],
523
+ constructed_bins,
524
+ atol=1e-10,
525
+ rtol=0,
526
+ ):
527
+ raise ValueError(
528
+ f"{dim} pixel centers do not match the data's {dim} bin centers."
529
+ f"Constructed: {constructed_bins}"
530
+ f"Dataset: {self.data[dim]}"
531
+ )
362
532
 
363
- # Define the Map classes
364
- class AbstractSkyMap(ABC):
365
- """Abstract base class to contain map data in the context of ENA sky maps."""
533
+ # The coordinates of the healpix pixel centers are stored as a 2D array
534
+ # of shape (num_points, 2) where column 0 is the lon/az
535
+ # and column 1 is the lat/el.
536
+ self.az_el_points = np.column_stack(
537
+ (azimuth_pixel_center, elevation_pixel_center)
538
+ )
366
539
 
367
- @abstractmethod
368
- def __init__(self) -> None:
369
- pass
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)
564
+ )
370
565
 
371
566
  def __repr__(self) -> str:
372
567
  """
373
- Return a string representation of the map.
568
+ Return a string representation of the UltraPointingSet.
374
569
 
375
570
  Returns
376
571
  -------
377
572
  str
378
- String representation of the map.
573
+ String representation of the UltraPointingSet.
379
574
  """
380
- return f"{self.__class__} Map)"
575
+ return (
576
+ f"UltraPointingSet\n\t(spice_reference_frame="
577
+ f"{self.spice_reference_frame}, epoch={self.epoch}, "
578
+ f"num_points={self.num_points})"
579
+ )
381
580
 
382
581
 
383
- class RectangularSkyMap(AbstractSkyMap):
582
+ class HiPointingSet(PointingSet):
384
583
  """
385
- Map which tiles the sky with a 2D rectangular grid of azimuth/elevation pixels.
386
-
387
- NOTE: Internally, the map is stored as a 1D array of pixels.
584
+ PointingSet object specific to Hi L1C PSet data.
388
585
 
389
586
  Parameters
390
587
  ----------
391
- spacing_deg : float
392
- The spacing of the rectangular grid in degrees.
393
- spice_frame : geometry.SpiceFrame
394
- The reference Spice frame of the map.
588
+ dataset : xarray.Dataset
589
+ Hi L1C pointing set data loaded in an xarray.DataArray.
395
590
  """
396
591
 
397
- def __init__(
398
- self,
399
- spacing_deg: float,
400
- spice_frame: geometry.SpiceFrame,
401
- ):
402
- # Define the core properties of the map:
403
- self.tiling_type = SkyTilingType.RECTANGULAR # Type of tiling of the sky
404
- self.spacing_deg = spacing_deg
405
- self.spice_reference_frame = spice_frame
406
- self.sky_grid = spatial_utils.AzElSkyGrid(
407
- spacing_deg=self.spacing_deg,
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
+ )
408
599
  )
600
+ self.spatial_coords = ("spin_angle_bin",)
409
601
 
410
- # Solid angles of each pixel in the map grid in units of steradians
411
- self.solid_angle_grid = spatial_utils.build_solid_angle_map(
412
- spacing_deg=self.spacing_deg,
413
- )
414
602
 
415
- # Unwrap the az, el, solid angle grids to series of points tiling the sky
416
- az_points = self.sky_grid.az_grid.ravel()
417
- el_points = self.sky_grid.el_grid.ravel()
418
- self.az_el_points = np.column_stack((az_points, el_points))
419
- self.solid_angle_points = self.solid_angle_grid.ravel()
420
- self.num_points = self.az_el_points.shape[0]
603
+ # Define the Map classes
604
+ class AbstractSkyMap(ABC):
605
+ """
606
+ Abstract base class to contain map data in the context of ENA sky maps.
607
+
608
+ Data values are stored internally in an xarray Dataset, in the .data_1d attribute.
609
+ where the final (-1) axis is the only spatial dimension.
610
+ If the map is rectangular, this axis is the raveled 2D grid.
611
+ If the map is Healpix, this axis is the 1D array of Healpix pixel indices.
612
+
613
+ The data can be also accessed via the to_dataset method, which rewraps the data to
614
+ a 2D grid shape if the map is rectangular and formats the data as an xarray
615
+ Dataset with the correct dims and coords.
616
+ """
617
+
618
+ @abstractmethod
619
+ def __init__(self) -> None:
620
+ self.tiling_type: SkyTilingType
621
+ self.sky_grid: spatial_utils.AzElSkyGrid
622
+ self.num_points: int
623
+ self.non_spatial_coords: dict[str, xr.DataArray | NDArray]
624
+ self.spatial_coords: dict[str, xr.DataArray | NDArray]
625
+ self.binning_grid_shape: tuple[int, ...]
626
+ self.data_1d: xr.Dataset
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
+
632
+ def to_dataset(self) -> xr.Dataset:
633
+ """
634
+ Get the SkyMap data as a formatted xarray Dataset.
635
+
636
+ Returns
637
+ -------
638
+ xr.Dataset
639
+ The SkyMap data as a formatted xarray Dataset with dims and coords.
640
+ If the SkyMap is empty, an empty xarray Dataset is returned.
641
+ If the SkyMap is Rectangular, the data is rewrapped to a 2D grid of
642
+ lon/lat (AKA az/el) coordinates.
643
+ If the SkyMap is Healpix, the data is unchanged from the data_1d, but
644
+ the pixel coordinate is renamed to CoordNames.HEALPIX_INDEX.value.
645
+ """
646
+ if len(self.data_1d.data_vars) == 0:
647
+ # If the map is empty, return an empty xarray Dataset,
648
+ # with the unaltered spatial coords of the map
649
+ return xr.Dataset(
650
+ {},
651
+ coords={**self.spatial_coords},
652
+ )
421
653
 
422
- # Initialize empty data dictionary to store map data
423
- self.data_dict: dict[str, NDArray] = {}
654
+ if self.tiling_type is SkyTilingType.HEALPIX:
655
+ # return the data_1d as is, but with the pixel coordinate
656
+ # renamed to CoordNames.HEALPIX_INDEX.value
657
+ return self.data_1d.rename(
658
+ {CoordNames.GENERIC_PIXEL.value: CoordNames.HEALPIX_INDEX.value}
659
+ )
660
+ elif self.tiling_type is SkyTilingType.RECTANGULAR:
661
+ # Rewrap each data array in the data_1d to the original 2D grid shape
662
+ rewrapped_data = {}
663
+ for key in self.data_1d.data_vars:
664
+ # drop pixel dim from the end, and add the spatial coords as dims
665
+ rewrapped_dims = [
666
+ dim
667
+ for dim in self.data_1d[key].dims
668
+ if dim != CoordNames.GENERIC_PIXEL.value
669
+ ]
670
+ rewrapped_dims.extend(self.spatial_coords.keys())
671
+ rewrapped_data[key] = xr.DataArray(
672
+ spatial_utils.rewrap_even_spaced_az_el_grid(
673
+ self.data_1d[key].values,
674
+ self.binning_grid_shape,
675
+ ),
676
+ dims=rewrapped_dims,
677
+ )
678
+ # Add the output coordinates to the rewrapped data, excluding the pixel
679
+ self.non_spatial_coords.update(
680
+ {
681
+ key: self.data_1d[key].coords[key]
682
+ for key in self.data_1d[key].coords
683
+ if key != CoordNames.GENERIC_PIXEL.value
684
+ }
685
+ )
686
+ return xr.Dataset(
687
+ rewrapped_data,
688
+ coords={**self.non_spatial_coords, **self.spatial_coords},
689
+ )
424
690
 
425
691
  def project_pset_values_to_map(
426
692
  self,
@@ -440,7 +706,7 @@ class RectangularSkyMap(AbstractSkyMap):
440
706
  pointing_set : PointingSet
441
707
  The pointing set containing the values to project to the map.
442
708
  value_keys : list[tuple[str, IndexMatchMethod]] | None
443
- The keys of the values to project to the map.
709
+ The keys of the values in the PointingSet to project to the map.
444
710
  Ex.: ["counts", "flux"]
445
711
  data_vars named each key must be present, and of the same dimensionality in
446
712
  each pointing set which is to be projected to the map.
@@ -456,45 +722,345 @@ class RectangularSkyMap(AbstractSkyMap):
456
722
  """
457
723
  if value_keys is None:
458
724
  value_keys = list(pointing_set.data.data_vars.keys())
459
-
460
725
  for value_key in value_keys:
461
726
  if value_key not in pointing_set.data.data_vars:
462
727
  raise ValueError(f"Value key {value_key} not found in pointing set.")
463
728
 
464
- # Determine the indices of the sky map grid that correspond to
465
- # each pixel in the pointing set.
466
729
  if index_match_method is IndexMatchMethod.PUSH:
730
+ # Determine the indices of the sky map grid that correspond to
731
+ # each pixel in the pointing set.
467
732
  matched_indices_push = match_coords_to_indices(
468
733
  input_object=pointing_set,
469
734
  output_object=self,
470
735
  )
736
+ elif index_match_method is IndexMatchMethod.PULL:
737
+ # Determine the indices of the pointing set grid that correspond to
738
+ # each pixel in the sky map.
739
+ matched_indices_pull = match_coords_to_indices(
740
+ input_object=self,
741
+ output_object=pointing_set,
742
+ )
743
+ else:
744
+ raise NotImplementedError(
745
+ "Only PUSH and PULL index matching methods are supported."
746
+ )
471
747
 
472
748
  for value_key in value_keys:
749
+ pset_values = pointing_set.data[value_key]
750
+
473
751
  # If multiple spatial axes present
474
752
  # (i.e (az, el) for rectangular coordinate PSET),
475
753
  # flatten them in the values array to match the raveled indices
476
- raveled_pset_data = pointing_set.data[value_key].data.reshape(
477
- pointing_set.num_points, -1
754
+ non_spatial_axes_shape = tuple(
755
+ size
756
+ for key, size in pset_values.sizes.items()
757
+ if key not in pointing_set.spatial_coords
758
+ )
759
+ raveled_pset_data = pset_values.data.reshape(
760
+ *non_spatial_axes_shape,
761
+ pointing_set.num_points,
478
762
  )
479
- if value_key not in self.data_dict:
763
+
764
+ if value_key not in self.data_1d.data_vars:
480
765
  # Initialize the map data array if it doesn't exist (values start at 0)
481
- output_shape = (self.num_points, *raveled_pset_data.shape[1:])
482
- self.data_dict[value_key] = np.zeros(output_shape)
766
+ output_shape = (*raveled_pset_data.shape[:-1], self.num_points)
767
+ self.data_1d[value_key] = xr.DataArray(
768
+ np.zeros(output_shape),
769
+ dims=pointing_set.unwrapped_dims_dict[value_key],
770
+ )
771
+
772
+ # Make coordinates for the map data array if they don't exist
773
+ self.data_1d.coords.update(
774
+ {
775
+ dim: pointing_set.data[dim]
776
+ for dim in self.data_1d[value_key].dims
777
+ if dim not in self.data_1d.coords
778
+ }
779
+ )
483
780
 
484
781
  if index_match_method is IndexMatchMethod.PUSH:
782
+ # Bin the values at the matched indices. There may be multiple
783
+ # pointing set pixels that correspond to the same sky map pixel.
485
784
  pointing_projected_values = map_utils.bin_single_array_at_indices(
486
785
  value_array=raveled_pset_data,
487
- projection_grid_shape=(
488
- len(self.sky_grid.az_bin_midpoints),
489
- len(self.sky_grid.el_bin_midpoints),
490
- ),
786
+ projection_grid_shape=self.binning_grid_shape,
491
787
  projection_indices=matched_indices_push,
492
788
  )
789
+ elif index_match_method is IndexMatchMethod.PULL:
790
+ # We know that there will only be one value per sky map pixel,
791
+ # so we can use the matched indices directly
792
+ pointing_projected_values = raveled_pset_data[..., matched_indices_pull]
493
793
  else:
494
794
  raise NotImplementedError(
495
- "The 'pull' method of index matching is not yet implemented."
795
+ "Only PUSH and PULL index matching methods are supported."
496
796
  )
497
- self.data_dict[value_key] += pointing_projected_values
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.
802
+ self.data_1d[value_key] += pointing_projected_values
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
+
956
+
957
+ class RectangularSkyMap(AbstractSkyMap):
958
+ """
959
+ Map which tiles the sky with a 2D rectangular grid of azimuth/elevation pixels.
960
+
961
+ Parameters
962
+ ----------
963
+ spacing_deg : float
964
+ The spacing of the rectangular grid in degrees.
965
+ spice_frame : geometry.SpiceFrame
966
+ The reference Spice frame of the map.
967
+
968
+ Notes
969
+ -----
970
+ Internally, the map is stored as a 1D array of pixels, and all data arrays
971
+ are stored with the final (-1) axis as the only spatial axis, representing the
972
+ pixel index in the 1D array (See Figs 1-2, which demonstrate the 1D pixel index
973
+ corresponding to the 2D grid of coordinates).
974
+
975
+ ^ |15, 75|45, 75|75, 75|105, 75|...|255, 75|285, 75|315, 75|345, 75|
976
+ | |15, 45|45, 45|75, 45|105, 45|...|255, 45|285, 45|315, 45|345, 45|
977
+ | |15, 15|45, 15|75, 15|105, 15|...|255, 15|285, 15|315, 15|345, 15|
978
+ | |15, -15|45, -15|75, -15|105, -15|...|255, -15|285, -15|315, -15|345, -15|
979
+ | |15, -45|45, -45|75, -45|105, -45|...|255, -45|285, -45|315, -45|345, -45|
980
+ | |15, -75|45, -75|75, -75|105, -75|...|255, -75|285, -75|315, -75|345, -75|
981
+ |
982
+ ---------------------------------------------------------------> Azimuth (degrees)
983
+ Elevation (degrees)
984
+
985
+ Fig. 1: Example of a rectangular grid of pixels in azimuth and elevation coordinates
986
+ in degrees, with a spacing of 30 degrees. There will be 12 azimuth bins and 6
987
+ elevation bins in this example, resulting in 72 pixels in the map.
988
+
989
+ A multidimensional value (e.g. counts, with energy levels at each pixel)
990
+ will be stored as a 2D array with the first axis as the energy dimension and the
991
+ second axis as the pixel index.
992
+
993
+ ^ |5|11|17|23|29|35|41|47|53|59|65|71|
994
+ | |4|10|16|22|28|34|40|46|52|58|64|70|
995
+ | |3|9 |15|21|27|33|39|45|51|57|63|69|
996
+ | |2|8 |14|20|26|32|38|44|50|56|62|68|
997
+ | |1|7 |13|19|25|31|37|43|49|55|61|67|
998
+ | |0|6 |12|18|24|30|36|42|48|54|60|66|
999
+ ---------------------------------------> Azimuth
1000
+ Elevation
1001
+
1002
+ Fig. 2: The 1D indices of the pixels in Fig. 1.
1003
+ Note that the indices are raveled from the 2D grid of (az, el) such that as one
1004
+ increases in pixel index, elevation increments first, then azimuth.
1005
+ """
1006
+
1007
+ def __init__(
1008
+ self,
1009
+ spacing_deg: float,
1010
+ spice_frame: geometry.SpiceFrame,
1011
+ ):
1012
+ # Define the core properties of the map:
1013
+ self.tiling_type = SkyTilingType.RECTANGULAR # Type of tiling of the sky
1014
+
1015
+ # The reference Spice frame of the map, in which angles are defined
1016
+ self.spice_reference_frame = spice_frame
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
+
1022
+ # Angular spacing of the map grid (degrees) defines the number, size of pixels.
1023
+ self.spacing_deg = spacing_deg
1024
+ self.sky_grid = spatial_utils.AzElSkyGrid(
1025
+ spacing_deg=self.spacing_deg,
1026
+ )
1027
+ # The shape of the map (num_az_bins, num_el_bins) is used to bin the data
1028
+ self.binning_grid_shape = self.sky_grid.grid_shape
1029
+
1030
+ self.non_spatial_coords = {}
1031
+ self.spatial_coords = {
1032
+ CoordNames.AZIMUTH_L1C.value: xr.DataArray(
1033
+ self.sky_grid.az_bin_midpoints,
1034
+ dims=[CoordNames.AZIMUTH_L1C.value],
1035
+ attrs={"units": "degrees"},
1036
+ ),
1037
+ CoordNames.ELEVATION_L1C.value: xr.DataArray(
1038
+ self.sky_grid.el_bin_midpoints,
1039
+ dims=[CoordNames.ELEVATION_L1C.value],
1040
+ attrs={"units": "degrees"},
1041
+ ),
1042
+ }
1043
+
1044
+ # Unwrap the az, el grids to 1D array of points tiling the sky
1045
+ az_points = self.sky_grid.az_grid.ravel()
1046
+ el_points = self.sky_grid.el_grid.ravel()
1047
+
1048
+ # Stack so axis 0 is different pixels, and axis 1 is (az, el) of the pixel
1049
+ self.az_el_points = np.column_stack((az_points, el_points))
1050
+ self.num_points = self.az_el_points.shape[0]
1051
+
1052
+ # Calculate solid angles of each pixel in the map grid in units of steradians
1053
+ self.solid_angle_grid = spatial_utils.build_solid_angle_map(
1054
+ spacing_deg=self.spacing_deg,
1055
+ )
1056
+ self.solid_angle_points = self.solid_angle_grid.ravel()
1057
+
1058
+ # Initialize xarray Dataset to store map data projected from pointing sets
1059
+ self.data_1d: xr.Dataset = xr.Dataset(
1060
+ coords={
1061
+ CoordNames.GENERIC_PIXEL.value: np.arange(self.num_points),
1062
+ }
1063
+ )
498
1064
 
499
1065
  def __repr__(self) -> str:
500
1066
  """
@@ -506,14 +1072,405 @@ class RectangularSkyMap(AbstractSkyMap):
506
1072
  String representation of the RectangularSkyMap.
507
1073
  """
508
1074
  return (
509
- "RectangularSkyMap\n\t(reference_frame="
1075
+ f"{self.__class__.__name__}\n\t(reference_frame="
510
1076
  f"{self.spice_reference_frame.name} ({self.spice_reference_frame.value}), "
511
1077
  f"spacing_deg={self.spacing_deg}, num_points={self.num_points})"
512
1078
  )
513
1079
 
514
1080
 
515
- # TODO:
516
- # Add pulling index matching in match_pset_coords_to_indices
1081
+ class HealpixSkyMap(AbstractSkyMap):
1082
+ """
1083
+ Map which tiles the sky with a Healpix tessellation of equal-area pixels.
1084
+
1085
+ Parameters
1086
+ ----------
1087
+ nside : int
1088
+ The nside parameter of the Healpix tessellation.
1089
+ spice_frame : geometry.SpiceFrame
1090
+ The reference Spice frame of the map.
1091
+ nested : bool, optional
1092
+ Whether the Healpix tessellation is nested. Default is False.
1093
+ """
1094
+
1095
+ def __init__(
1096
+ self, nside: int, spice_frame: geometry.SpiceFrame, nested: bool = False
1097
+ ):
1098
+ # Define the core properties of the map:
1099
+ self.tiling_type = SkyTilingType.HEALPIX
1100
+ self.spice_reference_frame = spice_frame
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
+
1106
+ # Tile the sky with a Healpix tessellation. Defined by nside, nested parameters.
1107
+ self.nside = nside
1108
+ self.nested = nested
1109
+
1110
+ # Calculate how many pixels cover the sky and the approximate resolution (deg)
1111
+ self.num_points = hp.nside2npix(nside)
1112
+ self.approx_resolution = np.rad2deg(hp.nside2resol(nside, arcmin=False))
1113
+ # Define binning_grid_shape for consistency with RectangularSkyMap
1114
+ self.binning_grid_shape = (self.num_points,)
1115
+ self.spatial_coords = {
1116
+ CoordNames.HEALPIX_INDEX.value: xr.DataArray(
1117
+ np.arange(self.num_points),
1118
+ dims=[CoordNames.HEALPIX_INDEX.value],
1119
+ )
1120
+ }
1121
+
1122
+ # The centers of each pixel in the Healpix tessellation in azimuth (az) and
1123
+ # elevation (el) coordinates (degrees) within the map's Spice frame.
1124
+ pixel_az, pixel_el = hp.pix2ang(
1125
+ nside=nside, ipix=np.arange(self.num_points), nest=nested, lonlat=True
1126
+ )
1127
+ # Stack so axis 0 is different pixels, and axis 1 is (az, el) of the pixel
1128
+ self.az_el_points = np.column_stack((pixel_az, pixel_el))
1129
+
1130
+ # Tracks Per-Pixel Solid Angle in steradians.
1131
+ self.solid_angle = hp.nside2pixarea(nside, degrees=False)
1132
+
1133
+ # Solid angle is equal at all pixels, but define
1134
+ # solid_angle_points to be consistent with RectangularSkyMap
1135
+ self.solid_angle_points = np.full(self.num_points, self.solid_angle)
1136
+
1137
+ # Initialize xarray Dataset to store map data projected from pointing sets
1138
+ self.data_1d: xr.Dataset = xr.Dataset(
1139
+ coords={
1140
+ CoordNames.GENERIC_PIXEL.value: np.arange(self.num_points),
1141
+ }
1142
+ )
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.
517
1273
 
518
- # TODO:
519
- # Check units of time which will be read in. Do we need to add j2000ns_to_j2000s?
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
+
1463
+ def __repr__(self) -> str:
1464
+ """
1465
+ Return a string representation of the HealpixSkyMap.
1466
+
1467
+ Returns
1468
+ -------
1469
+ str
1470
+ String representation of the HealpixSkyMap.
1471
+ """
1472
+ return (
1473
+ f"{self.__class__.__name__}\n\t(reference_frame="
1474
+ f"{self.spice_reference_frame.name} ({self.spice_reference_frame.value}), "
1475
+ f"nside={self.nside}, num_points={self.num_points})"
1476
+ )