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.
- imap_processing/_version.py +2 -2
- imap_processing/cdf/config/imap_codice_global_cdf_attrs.yaml +13 -1
- imap_processing/cdf/config/imap_codice_l1a_variable_attrs.yaml +97 -254
- imap_processing/cdf/config/imap_codice_l2-hi-omni_variable_attrs.yaml +635 -0
- imap_processing/cdf/config/imap_codice_l2-hi-sectored_variable_attrs.yaml +422 -0
- imap_processing/cdf/config/imap_enamaps_l2-common_variable_attrs.yaml +29 -22
- imap_processing/cdf/config/imap_enamaps_l2-healpix_variable_attrs.yaml +2 -0
- imap_processing/cdf/config/imap_enamaps_l2-rectangular_variable_attrs.yaml +12 -2
- imap_processing/cdf/config/imap_swapi_variable_attrs.yaml +2 -13
- imap_processing/cdf/utils.py +2 -2
- imap_processing/cli.py +10 -27
- imap_processing/codice/codice_l1a_lo_angular.py +362 -0
- imap_processing/codice/codice_l1a_lo_species.py +282 -0
- imap_processing/codice/codice_l1b.py +62 -97
- imap_processing/codice/codice_l2.py +801 -174
- imap_processing/codice/codice_new_l1a.py +64 -0
- imap_processing/codice/constants.py +96 -0
- imap_processing/codice/utils.py +270 -0
- imap_processing/ena_maps/ena_maps.py +157 -95
- imap_processing/ena_maps/utils/coordinates.py +5 -0
- imap_processing/ena_maps/utils/corrections.py +450 -0
- imap_processing/ena_maps/utils/map_utils.py +143 -42
- imap_processing/ena_maps/utils/naming.py +3 -1
- imap_processing/hi/hi_l1c.py +34 -12
- imap_processing/hi/hi_l2.py +82 -44
- imap_processing/ialirt/constants.py +7 -1
- imap_processing/ialirt/generate_coverage.py +3 -1
- imap_processing/ialirt/l0/parse_mag.py +1 -0
- imap_processing/ialirt/l0/process_codice.py +66 -0
- imap_processing/ialirt/l0/process_hit.py +1 -0
- imap_processing/ialirt/l0/process_swapi.py +1 -0
- imap_processing/ialirt/l0/process_swe.py +2 -0
- imap_processing/ialirt/process_ephemeris.py +6 -2
- imap_processing/ialirt/utils/create_xarray.py +4 -2
- imap_processing/idex/idex_l2a.py +2 -2
- imap_processing/idex/idex_l2b.py +1 -1
- imap_processing/lo/l1c/lo_l1c.py +62 -4
- imap_processing/lo/l2/lo_l2.py +85 -15
- imap_processing/mag/l1a/mag_l1a.py +2 -2
- imap_processing/mag/l1a/mag_l1a_data.py +71 -13
- imap_processing/mag/l1c/interpolation_methods.py +34 -13
- imap_processing/mag/l1c/mag_l1c.py +117 -67
- imap_processing/mag/l1d/mag_l1d_data.py +3 -1
- imap_processing/quality_flags.py +1 -0
- imap_processing/spice/geometry.py +11 -9
- imap_processing/spice/pointing_frame.py +77 -50
- imap_processing/swapi/constants.py +4 -0
- imap_processing/swapi/l1/swapi_l1.py +59 -24
- imap_processing/swapi/l2/swapi_l2.py +17 -3
- imap_processing/swe/utils/swe_constants.py +7 -7
- imap_processing/ultra/l1a/ultra_l1a.py +121 -72
- imap_processing/ultra/l1b/de.py +57 -1
- imap_processing/ultra/l1b/extendedspin.py +1 -1
- imap_processing/ultra/l1b/ultra_l1b_annotated.py +0 -1
- imap_processing/ultra/l1b/ultra_l1b_culling.py +2 -2
- imap_processing/ultra/l1b/ultra_l1b_extended.py +25 -12
- imap_processing/ultra/l1c/helio_pset.py +29 -6
- imap_processing/ultra/l1c/l1c_lookup_utils.py +4 -2
- imap_processing/ultra/l1c/spacecraft_pset.py +10 -6
- imap_processing/ultra/l1c/ultra_l1c.py +6 -6
- imap_processing/ultra/l1c/ultra_l1c_pset_bins.py +82 -20
- imap_processing/ultra/l2/ultra_l2.py +2 -2
- imap_processing-1.0.2.dist-info/METADATA +121 -0
- {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/RECORD +67 -61
- imap_processing-1.0.0.dist-info/METADATA +0 -120
- {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/LICENSE +0 -0
- {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/WHEEL +0 -0
- {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
|
|
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.
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
"
|
|
158
|
+
"input_indices must be a 1D array. "
|
|
69
159
|
"If using a rectangular grid, the indices must be unwrapped."
|
|
70
160
|
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|