|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import json |
5 | 6 | import logging |
6 | 7 | import pathlib |
7 | 8 | from abc import ABC, abstractmethod |
8 | 9 | from enum import Enum |
| 10 | +from pathlib import Path |
9 | 11 |
|
10 | 12 | import astropy_healpix.healpy as hp |
11 | 13 | import numpy as np |
@@ -234,7 +236,7 @@ def unwrapped_dims_dict(self) -> dict[str, tuple[str, ...]]: |
234 | 236 | unwrapped_dims_dict : dict[str, tuple[str, ...]] |
235 | 237 | Dictionary of variable names and their dimensions, with only 1 spatial dim. |
236 | 238 | The generic pixel dimension is always included. |
237 | | - E.g.: {"counts": ("epoch", "energy_bin_center", "pixel")} . |
| 239 | + E.g.: {"counts": ("epoch", "energy", "pixel")} . |
238 | 240 | """ |
239 | 241 | variable_dims = {} |
240 | 242 | for var_name in self.data.data_vars: |
@@ -438,6 +440,9 @@ def __init__( |
438 | 440 | self.num_points = self.data[CoordNames.HEALPIX_INDEX.value].size |
439 | 441 | self.nside = hp.npix_to_nside(self.num_points) |
440 | 442 |
|
| 443 | + # Tracks Per-Pixel Solid Angle in steradians. |
| 444 | + self.solid_angle = hp.nside2pixarea(self.nside, degrees=False) |
| 445 | + |
441 | 446 | # Determine if the HEALPix tessellation is nested, default is False |
442 | 447 | self.nested = bool( |
443 | 448 | self.data[CoordNames.HEALPIX_INDEX.value].attrs.get("nested", False) |
@@ -478,6 +483,48 @@ def __init__( |
478 | 483 | (self.azimuth_pixel_center, self.elevation_pixel_center) |
479 | 484 | ) |
480 | 485 |
|
| 486 | + @classmethod |
| 487 | + def from_path_or_dataset( |
| 488 | + cls, |
| 489 | + input_data: xr.Dataset | str | pathlib.Path, |
| 490 | + ) -> UltraPointingSet: |
| 491 | + """ |
| 492 | + Read a path or Dataset into an UltraPointingSet. |
| 493 | +
|
| 494 | + Parameters |
| 495 | + ---------- |
| 496 | + input_data : xr.Dataset | str | pathlib.Path |
| 497 | + Path to the CDF file or xarray Dataset containing the L1C dataset. |
| 498 | + If a dataset is provided, it will be copied to avoid modifying the original. |
| 499 | +
|
| 500 | + Returns |
| 501 | + ------- |
| 502 | + UltraPointingSet |
| 503 | + An UltraPointingSet object containing the L1C dataset. |
| 504 | +
|
| 505 | + Raises |
| 506 | + ------ |
| 507 | + ValueError |
| 508 | + If input_data is neither an xarray Dataset nor a path to a CDF file. |
| 509 | + """ |
| 510 | + # Allow for passing in EITHER xarray Datasets (preferable for testing) |
| 511 | + if isinstance(input_data, xr.Dataset): |
| 512 | + # Copy to avoid modifying the original dataset in place |
| 513 | + input_data = input_data.copy(deep=True) |
| 514 | + ultra_pointing_set = UltraPointingSet(l1c_dataset=input_data) |
| 515 | + # OR paths to CDF files (preferable for projecting many PointingSets) |
| 516 | + elif isinstance(input_data, str | pathlib.Path): |
| 517 | + if isinstance(input_data, str): |
| 518 | + input_data = pathlib.Path(input_data) |
| 519 | + ultra_pointing_set = UltraPointingSet(l1c_dataset=load_cdf(input_data)) |
| 520 | + else: |
| 521 | + raise ValueError( |
| 522 | + f"Input data must be either an xarray Dataset or a path to a CDF file " |
| 523 | + "containing the L1C dataset.\n" |
| 524 | + f"Found {type(input_data)} instead." |
| 525 | + ) |
| 526 | + return ultra_pointing_set |
| 527 | + |
481 | 528 | def __repr__(self) -> str: |
482 | 529 | """ |
483 | 530 | Return a string representation of the UltraPointingSet. |
@@ -519,6 +566,10 @@ def __init__(self) -> None: |
519 | 566 | self.binning_grid_shape: tuple[int, ...] |
520 | 567 | self.data_1d: xr.Dataset |
521 | 568 |
|
| 569 | + # Initialize values to be used by the instrument code to push/pull |
| 570 | + self.values_to_push_project: list[str] = [] |
| 571 | + self.values_to_pull_project: list[str] = [] |
| 572 | + |
522 | 573 | def to_dataset(self) -> xr.Dataset: |
523 | 574 | """ |
524 | 575 | Get the SkyMap data as a formatted xarray Dataset. |
@@ -685,8 +736,164 @@ def project_pset_values_to_map( |
685 | 736 | "Only PUSH and PULL index matching methods are supported." |
686 | 737 | ) |
687 | 738 |
|
| 739 | + # TODO: we may need to allow for unweighted/weighted means here by |
| 740 | + # dividing pointing_projected_values by some binned weights. |
| 741 | + # For unweighted means, we could use the number of pointing set pixels |
| 742 | + # that correspond to each map pixel as the weights. |
688 | 743 | self.data_1d[value_key] += pointing_projected_values |
689 | 744 |
|
| 745 | + @classmethod |
| 746 | + def from_json(cls, json_path: str | Path) -> RectangularSkyMap | HealpixSkyMap: |
| 747 | + """ |
| 748 | + Create a SkyMap object from a JSON configuration file. |
| 749 | +
|
| 750 | + Parameters |
| 751 | + ---------- |
| 752 | + json_path : str | Path |
| 753 | + Path to the JSON configuration file. |
| 754 | +
|
| 755 | + Returns |
| 756 | + ------- |
| 757 | + RectangularSkyMap | HealpixSkyMap |
| 758 | + An instance of a SkyMap object with the specified properties. |
| 759 | + """ |
| 760 | + with open(json_path) as f: |
| 761 | + properties = json.load(f) |
| 762 | + return cls.from_dict(properties) |
| 763 | + |
| 764 | + @classmethod |
| 765 | + def from_dict(cls, properties: dict) -> RectangularSkyMap | HealpixSkyMap: |
| 766 | + """ |
| 767 | + Create a SkyMap object from a dictionary of properties. |
| 768 | +
|
| 769 | + Parameters |
| 770 | + ---------- |
| 771 | + properties : dict |
| 772 | + Dictionary containing the map properties. The required keys are: |
| 773 | + - "spice_reference_frame" : str |
| 774 | + The reference Spice frame of the map as a string. The available |
| 775 | + options are defined in the spice geometry module: |
| 776 | + `imap_processing.geometry.spice.SpiceFrame`. Example: "ECLIPJ2000". |
| 777 | + - "sky_tiling_type" : str |
| 778 | + The type of sky tiling, either "HEALPIX" or "RECTANGULAR". |
| 779 | + - if "HEALPIX": |
| 780 | + - "nside" : int |
| 781 | + The nside parameter for the Healpix tessellation. |
| 782 | + - "nested" : bool |
| 783 | + Whether the Healpix tessellation is nested or not. |
| 784 | + - if "RECTANGULAR": |
| 785 | + - "spacing_deg" : float |
| 786 | + The spacing of the rectangular grid in degrees. |
| 787 | + - "values_to_push_project" : list[str], optional |
| 788 | + The names of the variables to project to the map with the PUSH method. |
| 789 | + NOTE: The projection is done by the instrument code, so this value can |
| 790 | + only be used to inform that code. No values are projected automatically. |
| 791 | + - "values_to_pull_project" : list[str], optional |
| 792 | + The names of the variables to project to the map with the PULL method. |
| 793 | + See the above note for more details. |
| 794 | +
|
| 795 | + See example dictionary in notes section. |
| 796 | +
|
| 797 | + Returns |
| 798 | + ------- |
| 799 | + RectangularSkyMap | HealpixSkyMap |
| 800 | + An instance of a SkyMap object with the specified properties. |
| 801 | +
|
| 802 | + Raises |
| 803 | + ------ |
| 804 | + ValueError |
| 805 | + If the sky tiling type is not recognized. |
| 806 | +
|
| 807 | + Notes |
| 808 | + ----- |
| 809 | + Example dictionary: |
| 810 | +
|
| 811 | + ```python |
| 812 | + properties = { |
| 813 | + "spice_reference_frame": "ECLIPJ2000", |
| 814 | + "sky_tiling_type": "HEALPIX", |
| 815 | + "nside": 32, |
| 816 | + "nested": False, |
| 817 | + "values_to_push_project": ['counts', 'flux'], |
| 818 | + "values_to_pull_project": [] |
| 819 | + } |
| 820 | + ``` |
| 821 | + """ |
| 822 | + sky_tiling_type = SkyTilingType[properties["sky_tiling_type"].upper()] |
| 823 | + spice_reference_frame = geometry.SpiceFrame[properties["spice_reference_frame"]] |
| 824 | + |
| 825 | + skymap: RectangularSkyMap | HealpixSkyMap # Mypy gets confused by if/elif types |
| 826 | + if sky_tiling_type is SkyTilingType.HEALPIX: |
| 827 | + skymap = HealpixSkyMap( |
| 828 | + nside=properties["nside"], |
| 829 | + nested=properties["nested"], |
| 830 | + spice_frame=spice_reference_frame, |
| 831 | + ) |
| 832 | + elif sky_tiling_type is SkyTilingType.RECTANGULAR: |
| 833 | + skymap = RectangularSkyMap( |
| 834 | + spacing_deg=properties["spacing_deg"], |
| 835 | + spice_frame=spice_reference_frame, |
| 836 | + ) |
| 837 | + else: |
| 838 | + raise ValueError( |
| 839 | + f"Unknown sky tiling type: {sky_tiling_type}. " |
| 840 | + f"Must be one of: {SkyTilingType.__members__.keys()}" |
| 841 | + ) |
| 842 | + |
| 843 | + # Store requested variables to push/pull, which will be done by the instrument |
| 844 | + # code which creates and uses the SkyMap object. |
| 845 | + skymap.values_to_push_project = properties.get("values_to_push_project", []) |
| 846 | + skymap.values_to_pull_project = properties.get("values_to_pull_project", []) |
| 847 | + return skymap |
| 848 | + |
| 849 | + def to_dict(self) -> dict: |
| 850 | + """ |
| 851 | + Convert the SkyMap object to a dictionary of properties. |
| 852 | +
|
| 853 | + Returns |
| 854 | + ------- |
| 855 | + dict |
| 856 | + Dictionary containing the map properties. |
| 857 | + """ |
| 858 | + if isinstance(self, HealpixSkyMap): |
| 859 | + map_properties_dict = { |
| 860 | + "sky_tiling_type": "HEALPIX", |
| 861 | + "spice_reference_frame": self.spice_reference_frame.name, |
| 862 | + "nside": self.nside, |
| 863 | + "nested": self.nested, |
| 864 | + } |
| 865 | + elif isinstance(self, RectangularSkyMap): |
| 866 | + map_properties_dict = { |
| 867 | + "sky_tiling_type": "RECTANGULAR", |
| 868 | + "spice_reference_frame": self.spice_reference_frame.name, |
| 869 | + "spacing_deg": self.spacing_deg, |
| 870 | + } |
| 871 | + else: |
| 872 | + raise ValueError( |
| 873 | + f"Unknown SkyMap type: {self.__class__.__name__}. " |
| 874 | + f"Must be one of: {AbstractSkyMap.__subclasses__()}" |
| 875 | + ) |
| 876 | + |
| 877 | + map_properties_dict["values_to_push_project"] = ( |
| 878 | + self.values_to_push_project if self.values_to_push_project else [] |
| 879 | + ) |
| 880 | + map_properties_dict["values_to_pull_project"] = ( |
| 881 | + self.values_to_pull_project if self.values_to_pull_project else [] |
| 882 | + ) |
| 883 | + return map_properties_dict |
| 884 | + |
| 885 | + def to_json(self, json_path: str | Path) -> None: |
| 886 | + """ |
| 887 | + Save the SkyMap object to a JSON configuration file. |
| 888 | +
|
| 889 | + Parameters |
| 890 | + ---------- |
| 891 | + json_path : str | Path |
| 892 | + Path to the JSON file where the properties will be saved. |
| 893 | + """ |
| 894 | + with open(json_path, "w") as f: |
| 895 | + json.dump(self.to_dict(), f, indent=4) |
| 896 | + |
690 | 897 |
|
691 | 898 | class RectangularSkyMap(AbstractSkyMap): |
692 | 899 | """ |
@@ -749,6 +956,10 @@ def __init__( |
749 | 956 | # The reference Spice frame of the map, in which angles are defined |
750 | 957 | self.spice_reference_frame = spice_frame |
751 | 958 |
|
| 959 | + # Initialize values to be used by the instrument code to push/pull |
| 960 | + self.values_to_push_project: list[str] = [] |
| 961 | + self.values_to_pull_project: list[str] = [] |
| 962 | + |
752 | 963 | # Angular spacing of the map grid (degrees) defines the number, size of pixels. |
753 | 964 | self.spacing_deg = spacing_deg |
754 | 965 | self.sky_grid = spatial_utils.AzElSkyGrid( |
@@ -829,6 +1040,10 @@ def __init__( |
829 | 1040 | self.tiling_type = SkyTilingType.HEALPIX |
830 | 1041 | self.spice_reference_frame = spice_frame |
831 | 1042 |
|
| 1043 | + # Initialize values to be used by the instrument code to push/pull |
| 1044 | + self.values_to_push_project: list[str] = [] |
| 1045 | + self.values_to_pull_project: list[str] = [] |
| 1046 | + |
832 | 1047 | # Tile the sky with a Healpix tessellation. Defined by nside, nested parameters. |
833 | 1048 | self.nside = nside |
834 | 1049 | self.nested = nested |
|
0 commit comments