imap-processing 1.0.0__py3-none-any.whl → 1.0.2__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.
Files changed (68) hide show
  1. imap_processing/_version.py +2 -2
  2. imap_processing/cdf/config/imap_codice_global_cdf_attrs.yaml +13 -1
  3. imap_processing/cdf/config/imap_codice_l1a_variable_attrs.yaml +97 -254
  4. imap_processing/cdf/config/imap_codice_l2-hi-omni_variable_attrs.yaml +635 -0
  5. imap_processing/cdf/config/imap_codice_l2-hi-sectored_variable_attrs.yaml +422 -0
  6. imap_processing/cdf/config/imap_enamaps_l2-common_variable_attrs.yaml +29 -22
  7. imap_processing/cdf/config/imap_enamaps_l2-healpix_variable_attrs.yaml +2 -0
  8. imap_processing/cdf/config/imap_enamaps_l2-rectangular_variable_attrs.yaml +12 -2
  9. imap_processing/cdf/config/imap_swapi_variable_attrs.yaml +2 -13
  10. imap_processing/cdf/utils.py +2 -2
  11. imap_processing/cli.py +10 -27
  12. imap_processing/codice/codice_l1a_lo_angular.py +362 -0
  13. imap_processing/codice/codice_l1a_lo_species.py +282 -0
  14. imap_processing/codice/codice_l1b.py +62 -97
  15. imap_processing/codice/codice_l2.py +801 -174
  16. imap_processing/codice/codice_new_l1a.py +64 -0
  17. imap_processing/codice/constants.py +96 -0
  18. imap_processing/codice/utils.py +270 -0
  19. imap_processing/ena_maps/ena_maps.py +157 -95
  20. imap_processing/ena_maps/utils/coordinates.py +5 -0
  21. imap_processing/ena_maps/utils/corrections.py +450 -0
  22. imap_processing/ena_maps/utils/map_utils.py +143 -42
  23. imap_processing/ena_maps/utils/naming.py +3 -1
  24. imap_processing/hi/hi_l1c.py +34 -12
  25. imap_processing/hi/hi_l2.py +82 -44
  26. imap_processing/ialirt/constants.py +7 -1
  27. imap_processing/ialirt/generate_coverage.py +3 -1
  28. imap_processing/ialirt/l0/parse_mag.py +1 -0
  29. imap_processing/ialirt/l0/process_codice.py +66 -0
  30. imap_processing/ialirt/l0/process_hit.py +1 -0
  31. imap_processing/ialirt/l0/process_swapi.py +1 -0
  32. imap_processing/ialirt/l0/process_swe.py +2 -0
  33. imap_processing/ialirt/process_ephemeris.py +6 -2
  34. imap_processing/ialirt/utils/create_xarray.py +4 -2
  35. imap_processing/idex/idex_l2a.py +2 -2
  36. imap_processing/idex/idex_l2b.py +1 -1
  37. imap_processing/lo/l1c/lo_l1c.py +62 -4
  38. imap_processing/lo/l2/lo_l2.py +85 -15
  39. imap_processing/mag/l1a/mag_l1a.py +2 -2
  40. imap_processing/mag/l1a/mag_l1a_data.py +71 -13
  41. imap_processing/mag/l1c/interpolation_methods.py +34 -13
  42. imap_processing/mag/l1c/mag_l1c.py +117 -67
  43. imap_processing/mag/l1d/mag_l1d_data.py +3 -1
  44. imap_processing/quality_flags.py +1 -0
  45. imap_processing/spice/geometry.py +11 -9
  46. imap_processing/spice/pointing_frame.py +77 -50
  47. imap_processing/swapi/constants.py +4 -0
  48. imap_processing/swapi/l1/swapi_l1.py +59 -24
  49. imap_processing/swapi/l2/swapi_l2.py +17 -3
  50. imap_processing/swe/utils/swe_constants.py +7 -7
  51. imap_processing/ultra/l1a/ultra_l1a.py +121 -72
  52. imap_processing/ultra/l1b/de.py +57 -1
  53. imap_processing/ultra/l1b/extendedspin.py +1 -1
  54. imap_processing/ultra/l1b/ultra_l1b_annotated.py +0 -1
  55. imap_processing/ultra/l1b/ultra_l1b_culling.py +2 -2
  56. imap_processing/ultra/l1b/ultra_l1b_extended.py +25 -12
  57. imap_processing/ultra/l1c/helio_pset.py +29 -6
  58. imap_processing/ultra/l1c/l1c_lookup_utils.py +4 -2
  59. imap_processing/ultra/l1c/spacecraft_pset.py +10 -6
  60. imap_processing/ultra/l1c/ultra_l1c.py +6 -6
  61. imap_processing/ultra/l1c/ultra_l1c_pset_bins.py +82 -20
  62. imap_processing/ultra/l2/ultra_l2.py +2 -2
  63. imap_processing-1.0.2.dist-info/METADATA +121 -0
  64. {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/RECORD +67 -61
  65. imap_processing-1.0.0.dist-info/METADATA +0 -120
  66. {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/LICENSE +0 -0
  67. {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/WHEEL +0 -0
  68. {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/entry_points.txt +0 -0
@@ -1,10 +1,40 @@
1
1
  """L2 corrections common to multiple IMAP ENA instruments."""
2
2
 
3
+ import logging
3
4
  from pathlib import Path
5
+ from typing import TypeVar
4
6
 
5
7
  import numpy as np
6
8
  import pandas as pd
9
+ import xarray as xr
7
10
  from numpy.polynomial import Polynomial
11
+ from scipy.constants import electron_volt, erg, proton_mass
12
+
13
+ from imap_processing.ena_maps.ena_maps import (
14
+ LoHiBasePointingSet,
15
+ )
16
+ from imap_processing.ena_maps.utils.coordinates import CoordNames
17
+ from imap_processing.spice import geometry
18
+ from imap_processing.spice.time import ttj2000ns_to_et
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Tell ruff to ignore ambiguous Greek letters in formulas in this file
23
+ # ruff: noqa: RUF003
24
+
25
+ # Create a TypeVar to represent the specific class being passed in
26
+ # Bound to LoHiBasePointingSet, meaning it must be LoHiBasePointingSet
27
+ # or a subclass of it
28
+ LoHiBasePsetSubclass = TypeVar("LoHiBasePsetSubclass", bound=LoHiBasePointingSet)
29
+
30
+ # Physical constants for Compton-Getting correction
31
+ # Units: electron_volt = [J / eV]
32
+ # erg = [J / erg]
33
+ # To get [erg / eV], => electron_volt [J / eV] / erg [J / erg] = erg_per_ev [erg / eV]
34
+ ERG_PER_EV = electron_volt / erg # erg per eV - unit conversion factor
35
+ # Units: proton_mass = [kg]
36
+ # Here, we convert proton_mass to grams
37
+ PROTON_MASS_GRAMS = proton_mass * 1e3 # proton mass in grams
8
38
 
9
39
 
10
40
  class PowerLawFluxCorrector:
@@ -289,3 +319,423 @@ class PowerLawFluxCorrector:
289
319
  )
290
320
 
291
321
  return corrected_flux, corrected_flux_stat_unc
322
+
323
+
324
+ def _add_spacecraft_velocity_to_pset(
325
+ pset: LoHiBasePsetSubclass,
326
+ ) -> LoHiBasePsetSubclass:
327
+ """
328
+ Calculate and add spacecraft velocity data to pointing set.
329
+
330
+ Parameters
331
+ ----------
332
+ pset : LoHiBasePointingSet
333
+ Pointing set object to be updated.
334
+
335
+ Returns
336
+ -------
337
+ pset : LoHiBasePointingSet
338
+ Pointing set object with spacecraft velocity data added.
339
+
340
+ Notes
341
+ -----
342
+ Adds the following DataArrays to pset.data:
343
+ - "sc_velocity": Spacecraft velocity vector (km/s) with dims ["x_y_z"]
344
+ - "sc_direction_vector": Spacecraft velocity unit vector with dims ["x_y_z"]
345
+ """
346
+ # Compute ephemeris time (J2000 seconds) of PSET midpoint time
347
+ # TODO: Use the Pointing midpoint time. Epoch should be start time
348
+ # but use it until we can make Lo and Hi PSETs have a consistent
349
+ # variable to hold the midpoint time.
350
+ et = ttj2000ns_to_et(pset.data["epoch"].values[0])
351
+ # Get spacecraft state in HAE frame
352
+ sc_state = geometry.imap_state(et, ref_frame=geometry.SpiceFrame.IMAP_HAE)
353
+ sc_velocity_vector = sc_state[3:6]
354
+
355
+ # Store spacecraft velocity as DataArray
356
+ pset.data["sc_velocity"] = xr.DataArray(
357
+ sc_velocity_vector, dims=[CoordNames.CARTESIAN_VECTOR.value]
358
+ )
359
+
360
+ # Calculate spacecraft speed and direction
361
+ sc_velocity_km_per_sec = np.linalg.norm(
362
+ pset.data["sc_velocity"], axis=-1, keepdims=True
363
+ )
364
+ pset.data["sc_direction_vector"] = pset.data["sc_velocity"] / sc_velocity_km_per_sec
365
+
366
+ return pset
367
+
368
+
369
+ def _add_cartesian_look_direction(pset: LoHiBasePsetSubclass) -> LoHiBasePsetSubclass:
370
+ """
371
+ Calculate and add look direction vectors to pointing set.
372
+
373
+ Parameters
374
+ ----------
375
+ pset : LoHiBasePointingSet
376
+ Pointing set object to be updated.
377
+
378
+ Returns
379
+ -------
380
+ pset : LoHiBasePointingSet
381
+ Pointing set object with look direction vectors added.
382
+
383
+ Notes
384
+ -----
385
+ Adds the following DataArray to pset.data:
386
+ - "look_direction": Cartesian unit vectors with dims [...spatial_dims, "x_y_z"]
387
+ """
388
+ longitudes = pset.data["hae_longitude"]
389
+ latitudes = pset.data["hae_latitude"]
390
+
391
+ # Stack spherical coordinates (r=1 for unit vectors, azimuth, elevation)
392
+ spherical_coords = np.stack(
393
+ [
394
+ np.ones_like(longitudes), # r = 1 for unit vectors
395
+ longitudes, # azimuth = longitude
396
+ latitudes, # elevation = latitude
397
+ ],
398
+ axis=-1,
399
+ )
400
+
401
+ # Convert to Cartesian coordinates and store as DataArray
402
+ pset.data["look_direction"] = xr.DataArray(
403
+ geometry.spherical_to_cartesian(spherical_coords),
404
+ dims=[*longitudes.dims, CoordNames.CARTESIAN_VECTOR.value],
405
+ )
406
+
407
+ return pset
408
+
409
+
410
+ def _calculate_compton_getting_transform(
411
+ pset: LoHiBasePsetSubclass,
412
+ energy_hf: xr.DataArray,
413
+ ) -> LoHiBasePsetSubclass:
414
+ """
415
+ Apply Compton-Getting transformation to compute ENA source directions.
416
+
417
+ This implements the Compton-Getting velocity transformation to correct
418
+ for the motion of the spacecraft through the heliosphere. The transformation
419
+ accounts for the Doppler shift of ENA energies and the aberration of
420
+ arrival directions.
421
+
422
+ All calculations are performed using xarray DataArrays to preserve
423
+ dimension information throughout the computation.
424
+
425
+ Parameters
426
+ ----------
427
+ pset : LoHiBasePointingSet
428
+ Pointing set object with sc_velocity, sc_direction_vector, and
429
+ look_direction already added.
430
+ energy_hf : xr.DataArray
431
+ ENA energies in the heliosphere frame in eV.
432
+
433
+ Returns
434
+ -------
435
+ pset : LoHiBasePointingSet
436
+ Pointing set object with Compton-Getting related variables added and
437
+ updated az_el_points.
438
+
439
+ Notes
440
+ -----
441
+ The algorithm is based on the "Appendix A. The IMAP-Lo Mapping Algorithms"
442
+ document.
443
+ Adds the following DataArrays to pset.data:
444
+ - "energy_sc": ENA energies in spacecraft frame (eV)
445
+ - "energy_hf": ENA energies in the heliosphere frame (eV)
446
+ - "ram_mask": Mask indicating whether ENA source direction is from the ram
447
+ direction.
448
+ Updates the following DataArrays in pset.data:
449
+ - "hae_longitude": ENA source longitudes in heliosphere frame (degrees)
450
+ - "hae_latitude": ENA source latitudes in heliosphere frame (degrees)
451
+ """
452
+ # Store heliosphere frame energies
453
+ pset.data["energy_hf"] = energy_hf
454
+
455
+ # Calculate spacecraft speed
456
+ sc_velocity_km_per_sec = np.linalg.norm(
457
+ pset.data["sc_velocity"], axis=-1, keepdims=True
458
+ )
459
+
460
+ # Calculate dot product between look directions and spacecraft direction vector
461
+ # Use Einstein summation for efficient vectorized dot product
462
+ dot_product = xr.DataArray(
463
+ np.einsum(
464
+ "...i,...i->...",
465
+ pset.data["look_direction"],
466
+ pset.data["sc_direction_vector"],
467
+ ),
468
+ dims=pset.data["look_direction"].dims[:-1],
469
+ )
470
+
471
+ # Calculate the kinetic energy of a hydrogen ENA traveling at spacecraft velocity
472
+ # E_u = (1/2) * m * U_sc^2 (convert km/s to cm/s with 1.0e5 factor)
473
+ energy_u = (
474
+ 0.5 * PROTON_MASS_GRAMS * (sc_velocity_km_per_sec * 1e5) ** 2 / ERG_PER_EV
475
+ )
476
+
477
+ # Note: Tim thinks that this approach seems backwards. Here, we are assuming
478
+ # that ENAs are observed in the heliosphere frame at the ESA energy levels.
479
+ # We then calculate the velocity that said ENAs would have in the spacecraft
480
+ # frame as well as the CG corrected energy level in the spacecraft frame.
481
+ # We then use this velocity to calculate and the velocity of the spacecraft
482
+ # to do the vector math which determines the ENA source direction in the
483
+ # heliosphere frame.
484
+ # The ENAs are in fact observed in the spacecraft frame at a known energy
485
+ # level in the spacecraft frame. Why don't we use that energy level to
486
+ # calculate the source direction in the spacecraft frame and then do the
487
+ # vector math to find the source direction in the heliosphere frame? We
488
+ # would also need to calculate the CG corrected ENA energy in the heliosphere
489
+ # frame and keep track of that when binning.
490
+
491
+ # Calculate y values for each energy level (Equation 61)
492
+ # y_k = sqrt(E^h_k / E^u)
493
+ y = np.sqrt(pset.data["energy_hf"] / energy_u)
494
+
495
+ # Velocity magnitude factor calculation (Equation 62)
496
+ # x_k = (êₛ · û_sc) + sqrt(y² + (êₛ · û_sc)² - 1)
497
+ x = dot_product + np.sqrt(y**2 + dot_product**2 - 1)
498
+ # Get the dimensions in the right order so that spatial is last
499
+ x = x.transpose(dot_product.dims[0], y.dims[0], dot_product.dims[1])
500
+
501
+ # Calculate ENA speed in the spacecraft frame
502
+ # |v⃗_sc| = x_k * U_sc
503
+ velocity_sc = x * sc_velocity_km_per_sec
504
+
505
+ # Calculate the kinetic energy in the spacecraft frame
506
+ # E_sc = (1/2) * M_p * v_sc² (convert km/s to cm/s with 1.0e5 factor)
507
+ pset.data["energy_sc"] = (
508
+ 0.5 * PROTON_MASS_GRAMS * (velocity_sc * 1e5) ** 2 / ERG_PER_EV
509
+ )
510
+
511
+ # Calculate the velocity vector in the spacecraft frame
512
+ # v⃗_sc = |v_sc| * êₛ (velocity direction follows look direction)
513
+ velocity_vector_sc = velocity_sc * pset.data["look_direction"]
514
+
515
+ # Calculate the ENA velocity vector in the heliosphere frame
516
+ # v⃗_helio = v⃗_sc - U⃗_sc (simple velocity addition)
517
+ velocity_vector_helio = velocity_vector_sc - pset.data["sc_velocity"]
518
+
519
+ # Convert to spherical coordinates to get ENA source directions
520
+ ena_source_direction_helio = geometry.cartesian_to_spherical(
521
+ velocity_vector_helio.data
522
+ )
523
+
524
+ # Update the PSET hae_longitude and hae_latitude variables with the new
525
+ # energy-dependent values.
526
+ pset.data["hae_longitude"] = (
527
+ pset.data["energy_sc"].dims,
528
+ ena_source_direction_helio[..., 1],
529
+ )
530
+ pset.data["hae_latitude"] = (
531
+ pset.data["energy_sc"].dims,
532
+ ena_source_direction_helio[..., 2],
533
+ )
534
+
535
+ # For ram/anti-ram filtering we can use the sign of the scalar projection
536
+ # of the ENA source direction onto the spacecraft velocity vector.
537
+ # ram_mask = (v⃗_helio · û_sc) >= 0
538
+ ram_mask = (
539
+ np.einsum(
540
+ "...i,...i->...", velocity_vector_helio, pset.data["sc_direction_vector"]
541
+ )
542
+ >= 0
543
+ )
544
+ pset.data["ram_mask"] = xr.DataArray(
545
+ ram_mask,
546
+ dims=velocity_vector_helio.dims[:-1],
547
+ )
548
+
549
+ return pset
550
+
551
+
552
+ def apply_compton_getting_correction(
553
+ pset: LoHiBasePsetSubclass,
554
+ energy_hf: xr.DataArray,
555
+ ) -> LoHiBasePsetSubclass:
556
+ """
557
+ Apply Compton-Getting correction to a pointing set and update coordinates.
558
+
559
+ This function performs the Compton-Getting velocity transformation to correct
560
+ ENA observations for the motion of the spacecraft through the heliosphere.
561
+ The corrected coordinates represent the true source directions of the ENAs
562
+ in the heliosphere frame.
563
+
564
+ The pointing set is modified in-place: new variables are added to the dataset
565
+ for the corrected coordinates and energies, and the az_el_points attribute
566
+ is updated to use the corrected coordinates for binning.
567
+
568
+ All calculations are performed using xarray DataArrays to preserve dimension
569
+ information throughout the computation.
570
+
571
+ Parameters
572
+ ----------
573
+ pset : LoHiBasePointingSet
574
+ Pointing set object containing HAE longitude/latitude coordinates.
575
+ energy_hf : xr.DataArray
576
+ ENA energies in the heliosphere frame in eV. Must be 1D with an
577
+ energy dimension.
578
+
579
+ Returns
580
+ -------
581
+ pset : LoHiBasePointingSet
582
+ Updated pointing set object with Compton-Getting related variables added.
583
+
584
+ Notes
585
+ -----
586
+ This function adds the following variables to the pointing set dataset:
587
+ - "sc_velocity": Spacecraft velocity vector (km/s)
588
+ - "sc_direction_vector": Spacecraft velocity unit vector
589
+ - "look_direction": Cartesian unit vectors of observation directions
590
+ - "energy_hf": ENA energies in heliosphere frame (eV)
591
+ - "energy_sc": ENA energies in spacecraft frame (eV)
592
+ This function modifies the following variables in the pointing set dataset:
593
+ - "hae_longitude": ENA source longitudes in heliosphere frame (degrees)
594
+ - "hae_latitude": ENA source latitudes in heliosphere frame (degrees)
595
+
596
+ The az_el_points attribute is updated to use the corrected coordinates,
597
+ which will be used for subsequent binning operations.
598
+ """
599
+ # Step 1: Add spacecraft velocity and direction to pset
600
+ pset = _add_spacecraft_velocity_to_pset(pset)
601
+
602
+ # Step 2: Calculate and add look direction vectors to pset
603
+ pset = _add_cartesian_look_direction(pset)
604
+
605
+ # Step 3: Apply Compton-Getting transformation
606
+ pset = _calculate_compton_getting_transform(pset, energy_hf)
607
+
608
+ # Step 4: Update az_el_points to use the corrected coordinates
609
+ pset.update_az_el_points()
610
+
611
+ return pset
612
+
613
+
614
+ def interpolate_map_flux_to_helio_frame(
615
+ map_ds: xr.Dataset,
616
+ esa_energies_ev: xr.DataArray,
617
+ helio_energies_ev: xr.DataArray,
618
+ ) -> xr.Dataset:
619
+ """
620
+ Interpolate flux from spacecraft frame to heliocentric frame energies.
621
+
622
+ This implements the Compton-Getting interpolation step that transforms
623
+ flux measurements from the spacecraft frame to the heliocentric frame.
624
+ The algorithm follows these steps:
625
+ 1. For each spatial pixel and energy step, get the spacecraft energy
626
+ 2. Find bounding ESA energy channels for interpolation
627
+ 3. Perform power-law interpolation between bounding channels to spacecraft energy
628
+ 4. Apply energy scaling transformation to heliocentric frame
629
+
630
+ Parameters
631
+ ----------
632
+ map_ds : xarray.Dataset
633
+ Map dataset with `energy_sc` data variable containing the spacecraft
634
+ frame energies for each spatial pixel and ESA energy step.
635
+ esa_energies_ev : xarray.DataArray
636
+ The ESA nominal central energies (in eV).
637
+ helio_energies_ev : xarray.DataArray
638
+ The heliocentric frame energies to interpolate to (in eV).
639
+ In practice, these are the same as esa_energies_ev.
640
+
641
+ Returns
642
+ -------
643
+ map_ds : xarray.Dataset
644
+ Updated map dataset with interpolated heliocentric frame fluxes.
645
+ """
646
+ logger.info("Performing Compton-Getting interpolation to heliocentric frame")
647
+
648
+ # Work with xarray DataArrays to handle arbitrary spatial dimensions
649
+ energy_sc = map_ds["energy_sc"]
650
+ intensity = map_ds["ena_intensity"]
651
+ stat_unc = map_ds["ena_intensity_stat_uncert"]
652
+ sys_err = map_ds["ena_intensity_sys_err"]
653
+
654
+ # Step 1: Find bounding ESA energy indices for each position
655
+ # Use np.searchsorted on flattened array, then reshape back
656
+ esa_energy_vals = esa_energies_ev.values
657
+ energy_sc_flat = energy_sc.values.ravel()
658
+
659
+ # Find right bound index for each element (vectorized)
660
+ right_idx_flat = np.searchsorted(esa_energy_vals, energy_sc_flat, side="right")
661
+ right_idx_flat = np.clip(right_idx_flat, 1, len(esa_energy_vals) - 1)
662
+ left_idx_flat = right_idx_flat - 1
663
+
664
+ # Reshape indices back to match energy_sc shape
665
+ right_idx = right_idx_flat.reshape(energy_sc.shape)
666
+ left_idx = left_idx_flat.reshape(energy_sc.shape)
667
+
668
+ # Create DataArrays for indices with same dims as energy_sc
669
+ # Note: we need to avoid coordinate name conflicts when using isel()
670
+ # The energy dimension should be present in dims but not as a coordinate
671
+ # since we're using these as indices into the energy dimension
672
+ # Create coordinates dict without the energy coordinate
673
+ coords_without_energy = {k: v for k, v in energy_sc.coords.items() if k != "energy"}
674
+
675
+ right_idx_da = xr.DataArray(
676
+ right_idx, dims=energy_sc.dims, coords=coords_without_energy
677
+ )
678
+ left_idx_da = xr.DataArray(
679
+ left_idx, dims=energy_sc.dims, coords=coords_without_energy
680
+ )
681
+
682
+ # Step 2: Extract flux values at bounding energy channels
683
+ # Use xarray's advanced indexing to get fluxes at left and right indices
684
+ flux_left = intensity.isel({"energy": left_idx_da})
685
+ flux_right = intensity.isel({"energy": right_idx_da})
686
+ stat_unc_left = stat_unc.isel({"energy": left_idx_da})
687
+ stat_unc_right = stat_unc.isel({"energy": right_idx_da})
688
+ sys_err_left = sys_err.isel({"energy": left_idx_da})
689
+
690
+ # Get energy values at boundaries - select from esa_energies_ev using indices
691
+ energy_left = esa_energies_ev.isel({"energy": left_idx_da})
692
+ energy_right = esa_energies_ev.isel({"energy": right_idx_da})
693
+
694
+ # Step 3: Perform power-law interpolation to spacecraft energy
695
+ # slope = log(f_right/f_left) / log(e_right/e_left)
696
+ # flux_sc = f_left * (energy_sc / e_left)^slope
697
+ with np.errstate(divide="ignore", invalid="ignore"):
698
+ # Calculate slope for power-law interpolation
699
+ slope = np.log(flux_right / flux_left) / np.log(energy_right / energy_left)
700
+
701
+ # Interpolate flux using power-law
702
+ flux_sc = flux_left * ((energy_sc / energy_left) ** slope)
703
+
704
+ # Interpolation factor for uncertainty propagation (Equations 75 & 76)
705
+ unc_factor = np.log(energy_sc / energy_left) / np.log(
706
+ energy_right / energy_left
707
+ )
708
+
709
+ # Statistical uncertainty propagation (Equation 75):
710
+ # δJ = J * sqrt((δJ_left/J_left)^2 * (1 + unc_factor^2) + (δJ_right/J_right)^2)
711
+ stat_unc_sc = flux_sc * np.sqrt(
712
+ (stat_unc_left / flux_left) ** 2 * (1.0 + unc_factor**2)
713
+ + (stat_unc_right / flux_right) ** 2
714
+ )
715
+
716
+ # Systematic uncertainty propagation (Equation 76):
717
+ # σJ^g = σJ^src_kref * (⟨E^s_kref⟩ / E^ESA_kref)^γ_kref * (E^h / ⟨E^s_kref⟩)
718
+ # Systematic error scales proportionally with flux during power-law
719
+ # interpolation
720
+ sys_err_sc = sys_err_left * ((energy_sc / energy_left) ** slope)
721
+
722
+ # Step 4: Energy scaling transformation (Liouville theorem)
723
+ # flux_helio = flux_sc * (helio_energy / energy_sc)
724
+ # Use xarray broadcasting - helio_energies_ev will broadcast along esa_energy_step
725
+ with np.errstate(divide="ignore", invalid="ignore"):
726
+ energy_ratio = helio_energies_ev / energy_sc
727
+ flux_helio = flux_sc * energy_ratio
728
+ stat_unc_helio = stat_unc_sc * energy_ratio
729
+ sys_err_helio = sys_err_sc * energy_ratio
730
+
731
+ # Set any location where the value is not finite to NaN (converts +/-inf to NaN)
732
+ flux_helio = flux_helio.where(np.isfinite(flux_helio), np.nan)
733
+ stat_unc_helio = stat_unc_helio.where(np.isfinite(stat_unc_helio), np.nan)
734
+ sys_err_helio = sys_err_helio.where(np.isfinite(sys_err_helio), np.nan)
735
+
736
+ # Update the dataset with interpolated values
737
+ map_ds["ena_intensity"] = flux_helio
738
+ map_ds["ena_intensity_stat_uncert"] = stat_unc_helio
739
+ map_ds["ena_intensity_sys_err"] = sys_err_helio
740
+
741
+ return map_ds
@@ -10,6 +10,89 @@ from numpy.typing import NDArray
10
10
  logger = logging.getLogger(__name__)
11
11
 
12
12
 
13
+ def vectorized_bincount(
14
+ indices: NDArray, weights: NDArray | None = None, minlength: int = 0
15
+ ) -> NDArray:
16
+ """
17
+ Vectorized version of np.bincount for multi-dimensional arrays.
18
+
19
+ This function applies np.bincount across multi-dimensional input arrays by
20
+ adding offsets to the indices and flattening, then reshaping the result.
21
+ This approach allows broadcasting between indices and weights.
22
+
23
+ Parameters
24
+ ----------
25
+ indices : NDArray
26
+ Array of non-negative integers to be binned. Can be multi-dimensional.
27
+ If multi-dimensional, bincount is applied independently along each
28
+ leading dimension.
29
+ weights : NDArray, optional
30
+ Array of weights that is broadcastable with indices. If provided, each
31
+ weight is accumulated into its corresponding bin. If None (default),
32
+ each index contributes a count of 1.
33
+ minlength : int, optional
34
+ Minimum number of bins in the output array. Applied to each independent
35
+ bincount operation. Default is 0.
36
+
37
+ Returns
38
+ -------
39
+ NDArray
40
+ Array of binned values with the same leading dimensions as the input
41
+ arrays, and a final dimension of size minlength (or the maximum index + 1,
42
+ whichever is larger).
43
+
44
+ See Also
45
+ --------
46
+ numpy.bincount : The underlying function being vectorized.
47
+
48
+ Examples
49
+ --------
50
+ >>> indices = np.array([[0, 1, 1], [2, 2, 3]])
51
+ >>> vectorized_bincount(indices, minlength=4)
52
+ array([[1., 2., 0., 0.],
53
+ [0., 0., 2., 1.]])
54
+ """
55
+ # Handle 1D case directly
56
+ if indices.ndim == 1 and (weights is None or weights.ndim == 1):
57
+ return np.bincount(indices, weights=weights, minlength=minlength)
58
+
59
+ # For multi-dimensional arrays, broadcast indices and weights
60
+ if weights is not None:
61
+ indices_bc, weights_bc = np.broadcast_arrays(indices, weights)
62
+ weights_flat = weights_bc.ravel()
63
+ else:
64
+ indices_bc = indices
65
+ weights_flat = None
66
+
67
+ # Get the shape for reshaping output
68
+ non_spatial_shape = indices_bc.shape[:-1]
69
+ n_binsets = np.prod(non_spatial_shape)
70
+
71
+ # Determine actual minlength if not specified
72
+ if minlength == 0:
73
+ minlength = int(np.max(indices_bc)) + 1
74
+
75
+ # We want to flatten the multi-dimensional bincount problem into a 1D problem.
76
+ # This can be done by offsetting the indices for each element of each additional
77
+ # dimension by an integer multiple of the number of bins. Doing so gives
78
+ # each element in the additional dimensions its own set of 1D bins: index 0
79
+ # uses bins [0, minlength), index 1 uses bins [minlength, 2*minlength), etc.
80
+ offsets = np.arange(n_binsets).reshape(*non_spatial_shape, 1) * minlength
81
+ indices_flat = (indices_bc + offsets).ravel()
82
+
83
+ # Single bincount call with flattened data
84
+ binned_flat = np.bincount(
85
+ indices_flat, weights=weights_flat, minlength=n_binsets * minlength
86
+ )
87
+
88
+ # Reshape to separate each sample's bins
89
+ binned_values = binned_flat.reshape(n_binsets, -1)[:, :minlength].reshape(
90
+ *non_spatial_shape, minlength
91
+ )
92
+
93
+ return binned_values
94
+
95
+
13
96
  def bin_single_array_at_indices(
14
97
  value_array: NDArray,
15
98
  projection_grid_shape: tuple[int, ...],
@@ -25,7 +108,7 @@ def bin_single_array_at_indices(
25
108
  Parameters
26
109
  ----------
27
110
  value_array : NDArray
28
- Array of values to bin. The final axis be the one and only spatial axis.
111
+ Array of values to bin. The final axis is the one and only spatial axis.
29
112
  If other axes are present, they will be binned independently
30
113
  along the spatial axis.
31
114
  projection_grid_shape : tuple[int, ...]
@@ -34,71 +117,89 @@ def bin_single_array_at_indices(
34
117
  or just (number of bins,) if the grid is 1D.
35
118
  projection_indices : NDArray
36
119
  Ordered indices for projection grid, corresponding to indices in input grid.
37
- 1 dimensional. May be non-unique, depending on the projection method.
120
+ Can be 1-dimensional or multi-dimensional. If multi-dimensional, must be
121
+ broadcastable with value_array. May contain non-unique indices, depending
122
+ on the projection method.
38
123
  input_indices : NDArray
39
124
  Ordered indices for input grid, corresponding to indices in projection grid.
40
125
  1 dimensional. May be non-unique, depending on the projection method.
41
- If None (default), an arange of the same length as the
42
- final axis of value_array is used.
126
+ If None (default), an numpy.arange of the same length as the final axis of
127
+ value_array is used.
43
128
  input_valid_mask : NDArray, optional
44
129
  Boolean mask array for valid values in input grid.
45
130
  If None, all pixels are considered valid. Default is None.
131
+ Must be broadcastable with value_array and projection_indices.
46
132
 
47
133
  Returns
48
134
  -------
49
135
  NDArray
50
- Binned values on the projection grid.
136
+ Binned values on the projection grid. The output shape depends on the
137
+ input shapes after broadcasting:
138
+ - If value_array is 1D: returns 1D array of shape (num_projection_indices,)
139
+ - If value_array is multi-dimensional: returns array with shape
140
+ (*value_array.shape[:-1], num_projection_indices), where the leading
141
+ dimensions match value_array's non-spatial dimensions and the final
142
+ dimension contains the binned values for each projection grid position.
143
+ - If projection_indices is multi-dimensional and broadcasts with value_array,
144
+ the output shape will be (broadcasted_shape[:-1], num_projection_indices).
51
145
 
52
146
  Raises
53
147
  ------
54
148
  ValueError
55
- If the input and projection indices are not 1D arrays
56
- with the same number of elements.
57
- NotImplementedError
58
- If the input value_array has dimensionality less than 1.
149
+ If input_indices is not a 1D array, or if the arrays cannot be
150
+ broadcast together.
59
151
  """
152
+ # Set and check input_indices
60
153
  if input_indices is None:
61
154
  input_indices = np.arange(value_array.shape[-1])
62
- if input_valid_mask is None:
63
- input_valid_mask = np.ones(value_array.shape[-1], dtype=bool)
64
-
65
- # Both sets of indices must be 1D with the same number of elements
66
- if input_indices.ndim != 1 or projection_indices.ndim != 1:
155
+ # input_indices must be 1D
156
+ if input_indices.ndim != 1:
67
157
  raise ValueError(
68
- "Indices must be 1D arrays. "
158
+ "input_indices must be a 1D array. "
69
159
  "If using a rectangular grid, the indices must be unwrapped."
70
160
  )
71
- if input_indices.size != projection_indices.size:
72
- raise ValueError(
73
- "The number of input and projection indices must be the same. \n"
74
- f"Received {input_indices.size} input indices and {projection_indices.size}"
75
- " projection indices."
161
+
162
+ # Verify projection_indices is broadcastable with value_array
163
+ try:
164
+ broadcasted_shape = np.broadcast_shapes(
165
+ projection_indices.shape, value_array.shape
76
166
  )
167
+ except ValueError as e:
168
+ raise ValueError(
169
+ f"projection_indices shape {projection_indices.shape} must be "
170
+ f"broadcastable with value_array shape {value_array.shape}"
171
+ ) from e
77
172
 
78
- input_valid_mask = np.asarray(input_valid_mask, dtype=bool)
79
- mask_idx = input_valid_mask[input_indices]
173
+ # Set and check input_valid_mask
174
+ if input_valid_mask is None:
175
+ input_valid_mask = np.ones(value_array.shape[-1], dtype=bool)
176
+ else:
177
+ input_valid_mask = np.asarray(input_valid_mask, dtype=bool)
178
+ # Verify input_valid_mask is broadcastable with value_array
179
+ try:
180
+ np.broadcast_shapes(input_valid_mask.shape, value_array.shape)
181
+ except ValueError as e:
182
+ raise ValueError(
183
+ f"input_valid_mask shape {input_valid_mask.shape} must be "
184
+ f"broadcastable with value_array shape {value_array.shape}"
185
+ ) from e
80
186
 
81
- num_projection_indices = np.prod(projection_grid_shape)
187
+ # Broadcast input_valid_mask to match value_array shape if needed
188
+ input_valid_mask_bc = np.broadcast_to(input_valid_mask, broadcasted_shape)
189
+
190
+ # Select values at input_indices positions along the spatial axis
191
+ values = value_array[..., input_indices]
192
+
193
+ # Apply mask: set invalid values to 0
194
+ values_masked = np.where(input_valid_mask_bc, values, 0)
195
+
196
+ num_projection_indices = int(np.prod(projection_grid_shape))
197
+
198
+ # Use vectorized_bincount to handle arbitrary dimensions
199
+ binned_values = vectorized_bincount(
200
+ projection_indices, weights=values_masked, minlength=num_projection_indices
201
+ )
82
202
 
83
- # Only valid values are summed into bins.
84
- if value_array.ndim == 1:
85
- values = value_array[input_indices]
86
- binned_values = np.bincount(
87
- projection_indices[mask_idx],
88
- weights=values[mask_idx],
89
- minlength=num_projection_indices,
90
- )
91
- elif value_array.ndim >= 2:
92
- # Apply bincount to each row independently
93
- binned_values = np.apply_along_axis(
94
- lambda x: np.bincount(
95
- projection_indices[mask_idx],
96
- weights=x[..., input_indices][mask_idx],
97
- minlength=num_projection_indices,
98
- ),
99
- axis=-1,
100
- arr=value_array,
101
- )
102
203
  return binned_values
103
204
 
104
205