Navigator / src / navigator / epoch / epoch.py
epoch.py
Raw
"""Epoch Class.

This module defines the Epoch class, representing an Epoch instance with observational and navigation data fragments. The class provides functionalities to handle and process data related to a single epoch, including observational and navigation data manipulation, trimming, purifying, saving, loading, and more.

Attributes:
    SINGLE (dict): Single Frequency Profile, specifying settings for single frequency mode.
    DUAL (dict): Dual Frequency Profile, specifying settings for dual frequency mode.
    INIT (dict): Initial Profile, specifying settings without applying any corrections.

Classes:
    Epoch: Represents an Epoch instance with observational and navigation data fragments.

Methods:
    - __init__: Initializes an Epoch instance with observational and navigation data fragments.
    - trim: Intersects satellite vehicles in observation and navigation data.
    - purify: Removes observations with missing data.
    - save: Saves the epoch to a file.
    - load: Loads an epoch from a file.
    - load_from_fragment: Loads an epoch from observational and navigation fragments.
    - load_from_fragment_path: Loads an epoch from paths to observational and navigation fragments.

Example:
    ```python
    from gnss_module import Epoch

    # Create an Epoch instance
    epoch = Epoch(obs_frag=obs_fragment, nav_frag=nav_fragment, trim=True, purify=True)

    # Save the Epoch
    epoch.save("path/to/save/epoch.pkl")

    # Load the Epoch
    loaded_epoch = Epoch.load("path/to/save/epoch.pkl")
    ```

Attributes:
    - timestamp: Get the timestamp of the epoch.
    - obs_data: Get or set the observational data of the epoch.
    - nav_data: Get or set the navigation data of the epoch.
    - station: Get the station name.
    - nav_meta: Get the navigation metadata.
    - obs_meta: Get the observation metadata.
    - profile: Get or set the profile of the epoch.
    - is_smoothed: Get or set the smoothed attribute.

Methods:
    - __getitem__: Retrieve observables for a specific satellite vehicle (SV) by index.
    - __len__: Return the number of satellite vehicles (SVs) in the epoch.
    - __repr__: Return a string representation of the Epoch.

Author:
    Nischal Bhattarai

"""

import pickle
from pathlib import Path  # type: ignore

import pandas as pd  # type: ignore

from ..utils.igs_network import IGSNetwork

__all__ = ["Epoch"]


class Epoch:
    """Represents an Epoch instance with observational and navigation data fragments.

    This class provides functionalities to handle and process data related to a single epoch,
    including observational and navigation data, trimming, purifying, saving, loading, and more.


    """

    # RINEX Observables
    L1_CODE_ON = "C1C"
    L2_CODE_ON = "C2W"
    L1_PHASE_ON = "L1C"
    L2_PHASE_ON = "L2W"

    OBSERVABLES = [L1_CODE_ON, L2_CODE_ON, L1_PHASE_ON, L2_PHASE_ON]

    # Relvant columns
    MINUMUM_REQUIRED_COLUMNS_MAP = {
        "single": [L1_CODE_ON, L1_PHASE_ON],
        "dual": [L1_CODE_ON, L2_CODE_ON, L1_PHASE_ON, L2_PHASE_ON],
        "phase": [L1_CODE_ON, L2_CODE_ON, L1_PHASE_ON, L2_PHASE_ON],
    }

    # Single Frequency Profile
    SINGLE = {
        "apply_tropo": True,
        "apply_iono": True,
        "mode": "single",
        "smoothed": False,
        "navigation_format": "ephemeris",
    }
    # Dual Frequency Profile
    DUAL = {
        "apply_tropo": True,
        "apply_iono": True,
        "mode": "dual",
        "smoothed": False,
        "navigation_format": "ephemeris",
    }
    # Initial Profile [Doesn't apply any corrections]
    INITIAL = {
        "apply_tropo": False,
        "apply_iono": False,
        "mode": "single",
        "smoothed": False,
        "navigation_format": "ephemeris",
    }
    # SP3 Profile
    SP3 = {
        "apply_tropo": False,
        "apply_iono": False,
        "mode": "single",
        "smoothed": True,
        "navigation_format": "sp3",
    }
    # Phase Profile
    PHASE = {
        "apply_tropo": False,
        "apply_iono": False,
        "mode": "phase",
        "smoothed": False,
        "navigation_format": "ephemeris",
    }

    # DUMMY
    DUMMY = {
        "apply_tropo": False,
        "apply_iono": False,
        "mode": "dummy",
        "smoothed": False,
        "navigation_format": "ephemeris",
    }

    ALLOWED_PROFILE_KEYS = [
        "apply_tropo",
        "apply_iono",
        "mode",
        "smoothed",
        "navigation_format",
    ]
    MANDATORY_PROFILE_KEYS = [
        "apply_tropo",
        "apply_iono",
        "mode",
        "navigation_format",
    ]

    # IGS network
    IGS_NETWORK = IGSNetwork()

    # Mandatory keys for real coordinates
    MANDATORY_COORDS_KEYS = ["x", "y", "z"]

    # Ionospheric Correction Keys
    IONOSPHERIC_KEY = "IONOSPHERIC CORR"

    def __init__(
        self,
        timestamp: pd.Timestamp,
        obs_data: pd.DataFrame,
        nav_data: pd.DataFrame,
        obs_meta: pd.Series,
        nav_meta: pd.Series,
        trim: bool = False,
        purify: bool = False,
        real_coord: pd.Series | dict | None = None,
        approximate_coords: pd.Series | dict | None = None,
        station: str | None = None,
        columns_mapping: dict | None = None,
        profile: dict[str, bool | str] = INITIAL,
    ) -> None:
        """Initialize an Epoch instance with a timestamp and observational data and navigation data.

        Args:
            timestamp (pd.Timestamp): The timestamp of the epoch i.e reciever timestamp.
            obs_data (pd.DataFrame): The observational data of the epoch.
            obs_meta (pd.Series): The observation metadata.
            nav_data (pd.DataFrame): The navigation data of the epoch.
            nav_meta (pd.Series): The navigation metadata.
            trim (bool, optional): Intersect satellite vehicles in observation and navigation data. Defaults to False.
            purify (bool, optional): Remove observations with missing data. Defaults to False.
            real_coord (pd.Series | dict | None, optional): The real coordinates of the station. Defaults to None.
            approximate_coords (pd.Series | dict | None, optional): The approximate coordinates of the station. Defaults to None.
            station (str | None, optional): The station name. Defaults to None.
            columns_mapping (dict | None, optional): The columns mapping to map names of columns to appropriate definitions. Defaults to None.
            profile (dict[str, bool | str], optional): The profile of the epoch. Defaults to INITIAL.

        Returns:
            None

        Note:
            - The code and phase observables are expected to be in the following format:
                - L1 Code: 'C1C'
                - L2 Code: 'C2W'
                - L1 Phase: 'L1C'
                - L2 Phase: 'L2W'
            - If the name of the columns are different, a columns_mapping dictionary can be provided to map the names to the appropriate definitions.
                for example:
                columns_mapping = {
                    'C1C': 'C1C',
                    'C2W': 'C2X',
                    'L1C': 'L1C',
                    'L2W': 'L2X'
                }
        """
        # Define a profile for the epoch can be [dual , single]
        self.profile = profile

        # Rename the colums to default names
        if columns_mapping is not None:
            # Reverse the dictionary
            columns_mapping = {v: k for k, v in columns_mapping.items()}
            obs_data = obs_data.rename(columns=columns_mapping)

        # Purify the data if required
        obs_data = (
            self.purify(
                obs_data,
                relevant_columns=Epoch.MINUMUM_REQUIRED_COLUMNS_MAP.get(
                    self.profile["mode"]
                ),
            )
            if purify
            else obs_data
        )

        # Store the timestamp
        self.timestamp = timestamp

        # Store FragObs and FragNav
        self.obs_data = obs_data
        self.nav_data = nav_data

        # Store the metadata
        self.obs_meta = obs_meta
        self.nav_meta = nav_meta

        # Trim the data if required
        if trim:
            self.trim()

        # Set the is_smoothed attribute
        self.is_smoothed = False

        # Set the real coordinates of the station
        self.real_coord = real_coord

        # Set the approximate coordinates of the station
        self.approximate_coords = approximate_coords

        # Set the station name
        self.station = station

        # Populate the IGS network if the station is part of the IGS network
        if self.station in self.IGS_NETWORK:
            self.real_coord = pd.Series(
                self.IGS_NETWORK.get_xyz(self._station), index=["x", "y", "z"]
            )

    @property
    def real_coord(self) -> pd.Series:
        """Get the real coordinates of the station.

        Returns:
            pd.Series: The real coordinates of the station.

        """
        return self._real_coord

    @real_coord.setter
    def real_coord(self, value: pd.Series | dict | None) -> None:
        """Set the real coordinates of the station.

        Args:
            value (pd.Series): The value to set.

        """
        if value is not None and not all(
            keys in value for keys in Epoch.MANDATORY_COORDS_KEYS
        ):
            raise ValueError(
                f"Real coordinates must contain the following keys: ['x', 'y', 'z']. Got {value.keys()} instead."
            )
        self._real_coord = pd.Series(value) if value is not None else pd.Series()

    @property
    def approximate_coords(self) -> pd.Series:
        """Get the approximate coordinates of the station.

        Returns:
            pd.Series: The approximate coordinates of the station.

        """
        return self._approximate_coords

    @approximate_coords.setter
    def approximate_coords(self, value: pd.Series | dict | None) -> None:
        """Set the approximate coordinates of the station.

        Args:
            value (pd.Series): The value to set.

        """
        if value is not None and not all(
            keys in value for keys in Epoch.MANDATORY_COORDS_KEYS
        ):
            raise ValueError(
                f"Approximate coordinates must contain the following keys: ['x', 'y', 'z']. Got {value.keys()} instead."
            )
        self._approximate_coords = (
            pd.Series(value) if value is not None else pd.Series()
        )

    @property
    def timestamp(self) -> pd.Timestamp:
        """Get the timestamp of the epoch.

        Returns:
            pd.Timestamp: The timestamp associated with the epoch.

        """
        return self._timestamp

    @timestamp.setter
    def timestamp(self, value: pd.Timestamp) -> None:
        """Set the timestamp of the epoch.

        Args:
            value (pd.Timestamp): The value to set.

        """
        if not isinstance(value, pd.Timestamp):
            raise ValueError(
                f"Timestamp must be a pd.Timestamp. Got {type(value)} instead."
            )

        self._timestamp = value

    @property
    def obs_data(self) -> pd.DataFrame:
        """Get the observational data of the epoch.

        Returns:
            pd.DataFrame: A DataFrame containing observational data.

        """
        return self._obs_data

    @obs_data.setter
    def obs_data(self, data: pd.DataFrame) -> None:  # noqa: ARG002
        """Set the observational data of the epoch.

        Args:
            data (pd.DataFrame): The data to set.

        """
        if not isinstance(data, pd.DataFrame):
            raise ValueError(
                f"Observational data must be a pd.DataFrame. Got {type(data)} instead."
            )
        self._obs_data = data

    @property
    def nav_data(self) -> pd.DataFrame:
        """Get the navigation data of the epoch.

        Returns:
            pd.DataFrame: A DataFrame containing navigation data.

        """
        return self._nav_data

    @nav_data.setter
    def nav_data(self, nav_data: pd.DataFrame) -> None:  # noqa: ARG002
        """Set the navigation data of the epoch.

        Args:
            nav_data (pd.DataFrame): The nav_data to set.

        """
        if not isinstance(nav_data, pd.DataFrame):
            raise ValueError(
                f"Navigation data must be a pd.DataFrame. Got {type(nav_data)} instead."
            )
        self._nav_data = nav_data

    @property
    def station(self) -> str:
        """Get the station name.

        Returns:
            str: The station name.

        """
        return self._station

    @station.setter
    def station(self, value: str) -> None:
        """Set the station name.

        Args:
            value (str): The value to set.

        """
        self._station = value

    @property
    def nav_meta(self) -> pd.Series:
        """Get the navigation metadata.

        Returns:
            pd.Series: The navigation metadata.

        """
        return self._nav_meta

    @nav_meta.setter
    def nav_meta(self, value: pd.Series) -> None:
        """Set the navigation metadata.

        Args:
            value (pd.Series): The value to set.

        """
        if not isinstance(value, pd.Series):
            raise ValueError(
                f"Navigation metadata must be a pd.Series. Got {type(value)} instead."
            )
        self._nav_meta = value

    @property
    def obs_meta(self) -> pd.Series:
        """Get the observation metadata.

        Returns:
            pd.Series: The observation metadata.

        """
        return self._obs_meta

    @obs_meta.setter
    def obs_meta(self, value: pd.Series) -> None:
        """Set the observation metadata.

        Args:
            value (pd.Series): The value to set.

        """
        if not isinstance(value, pd.Series):
            raise ValueError(
                f"Observation metadata must be a pd.Series. Got {type(value)} instead."
            )
        self._obs_meta = value

    @property
    def profile(self) -> dict:
        """Get the profile of the epoch.

        Returns:
            dict: The profile of the epoch.

        """
        return self._profile

    @profile.setter
    def profile(self, value: dict) -> None:
        """Set the profile of the epoch.

        Args:
            value (dict): The value to set.

        """
        # Check if the value contains the necessary keys
        if not all(key in value for key in Epoch.MANDATORY_PROFILE_KEYS):
            raise ValueError(
                f"Profile must contain the following keys: {Epoch.MANDATORY_PROFILE_KEYS}. Got {value.keys()} instead."
            )
        self._profile = value

    @property
    def is_smoothed(self) -> bool:
        """Get the smoothed attribute.

        Returns:
            bool: True if the epoch has been smoothed, False otherwise.

        """
        return self.profile.get("smoothed", False)

    @is_smoothed.setter
    def is_smoothed(self, value: bool) -> None:
        """Set the smoothed attribute.

        Args:
            value (bool): The value to set.

        """
        self._profile["smoothed"] = value

    def trim(self) -> None:
        """Intersect the satellite vehicles in the observation data and navigation data.

        Trims the data to contain only satellite vehicles present in both observations and navigation.
        """
        # Drop the satellite vehicles not present in both observation and navigation data
        common_sv = self.common_sv.copy()
        self.obs_data = self.obs_data.loc[common_sv]

        if self.profile["navigation_format"] == "ephemeris":
            # Ephemeris profile is multi-indexed
            self.nav_data = self.nav_data.loc[(slice(None), common_sv), :]
        elif self.profile["navigation_format"] == "sp3":
            # Do nothing
            pass
        else:
            raise ValueError(
                f"Navigation format must be either 'ephemeris' or 'sp3'. Got {self.profile['navigation_format']} instead."
            )

        return

    def purify(self, data: pd.DataFrame, relevant_columns: list[str]) -> pd.DataFrame:
        """Remove observations with missing data.

        Args:
            data (pd.DataFrame): DataFrame containing observational or navigation data.
            relevant_columns (list[str]): List of relevant columns to consider.

        Returns:
            pd.DataFrame: DataFrame with missing data observations removed.
        """
        # Drop duplicates columns
        data = data[~data.index.duplicated(keep="first")]
        relevant_columns = data.columns.intersection(relevant_columns)
        # Drop any rows with NA values on to_drop_na columns
        data.dropna(subset=relevant_columns, how="any", axis=0, inplace=True)
        return data

    @property
    def common_sv(self) -> pd.Index:
        """Get the common satellite vehicles between the observation data and navigation data.

        Returns:
            pd.Index: Common satellite vehicles present in both observational and navigation data.
        """
        return self.obs_data.index.get_level_values("sv").intersection(
            self.nav_data.index.get_level_values("sv")
        )

    def __repr__(self) -> str:
        """Return a string representation of the Epoch.

        Returns:
            str: A string representation of the Epoch.

        """
        return f"Epoch(timestamp={self.timestamp}, sv={self.obs_data.shape[0]})"

    def __getitem__(self, sv: int) -> pd.Series:
        """Retrieve observables for a specific satellite vehicle (SV) by index.

        Args:
            sv (int): The index of the satellite vehicle (SV).

        Returns:
            pd.Series: A pandas Series containing observables for the specified SV.

        """
        return self.obs_data.loc[sv]

    def __len__(self) -> int:
        """Return the number of satellite vehicles (SVs) in the epoch.

        Returns:
            int: The number of satellite vehicles (SVs) in the epoch.

        """
        return len(self.obs_data)

    def save(self, path: str | Path) -> None:
        """Save the epoch to a file.

        Args:
            path (str): The path to save the epoch to.

        Returns:
            None

        """
        # Pickle the epoch object
        with open(path, "wb") as file:
            pickle.dump(self, file)

        return

    @staticmethod
    def load(path: str | Path) -> "Epoch":
        """Load an epoch from a file.

        Args:
            path (str): The path to load the epoch from.

        Returns:
            Epoch: The epoch loaded from the file.

        """
        # Unpickle the epoch object
        with open(path, "rb") as file:
            epoch = pickle.load(file)

        # Check if the loaded object is an Epoch
        if not isinstance(epoch, Epoch):
            raise TypeError(
                f"Loaded object is not an Epoch. Got {type(epoch)} instead."
            )

        return epoch

    @property
    def has_ionospheric_correction(self) -> bool:
        """Check if the epoch has ionospheric correction applied.

        Returns:
            bool: True if ionospheric correction is applied, False otherwise.
        """
        return self.IONOSPHERIC_KEY in self.nav_meta

    def __gt__(self, other: "Epoch") -> bool:
        """Check if the epoch is greater than another epoch.

        Args:
            other (Epoch): The other epoch to compare to.

        Returns:
            bool: True if the epoch is greater than the other epoch, False otherwise.
        """
        # If not same station, raise error
        if self.station != other.station:
            raise ValueError(
                f"Cannot compare epochs with different stations. Got {self.station} and {other.station}."
            )
        return self.timestamp > other.timestamp

    def __lt__(self, other: "Epoch") -> bool:
        """Check if the epoch is less than another epoch.

        Args:
            other (Epoch): The other epoch to compare to.

        Returns:
            bool: True if the epoch is less than the other epoch, False otherwise.
        """
        # If not same station, raise error
        if self.station != other.station:
            raise ValueError(
                f"Cannot compare epochs with different stations. Got {self.station} and {other.station}."
            )
        return self.timestamp < other.timestamp

    def __eq__(self, other: "Epoch") -> bool:
        """Check if the epoch is equal to another epoch (same timestamp and station).

        Args:
            other (Epoch): The other epoch to compare to.

        Returns:
            bool: True if the epoch is equal to the other epoch, False otherwise.
        """
        # If not same station, raise error
        if self.station != other.station:
            raise ValueError(
                f"Cannot compare epochs with different stations. Got {self.station} and {other.station}."
            )
        return self.timestamp == other.timestamp and self.station == other.station