#####################################################################
# DOC #
#####################################################################
"""
@author: F. Ramognino <federico.ramognino@polimi.it>
Last update: 12/06/2023
"""
#####################################################################
# IMPORT #
#####################################################################
from __future__ import annotations
from typing import Iterable, Any, Self
import numpy as np
from pandas import DataFrame
import matplotlib.pyplot as plt
from abc import ABCMeta, abstractmethod
from libICEpost.src.base.Functions.typeChecking import checkType
from libICEpost.src.base.Utilities import Utilities
#############################################################################
# AUXILIARY FUNCTIONS #
#############################################################################
#############################################################################
[docs]
def tableIndex(table:BaseTabulation, index:int|Iterable[int]|slice) -> tuple[int]|Iterable[tuple[int,...]]:
"""
Compute the location of an index inside a table. Getting the index, returns a list of the indices of each input-variable.
Args:
table (BaseTabulation): The table to access.
index (int | Iterable[int] | slice): The index to access.
Returns:
tuple[int] | Iterable[tuple[int,...]]: The index/indices:
- If int is given, returns tuple[int].
- If slice or Iterable[int] is given, returns Iterable[tuple[int,...]].
Example:
>>> table.shape
(2, 3, 4)
>>> table._computeIndex(12)
(1, 0, 0)
>>> table._computeIndex([0, 1, 2])
[(0, 0, 0), (0, 0, 1), (0, 0, 2)]
>>> table._computeIndex(slice(0, 3))
[(0, 0, 0), (0, 0, 1), (0, 0, 2)]
"""
# If slice, convert to list of index
if isinstance(index, slice):
index = list(range(*index.indices(table.size)))
index = np.array(index, dtype=np.intp)
#Compute index
out = np.unravel_index(index, table.shape)
#Check if out is a tuple of array, if so reshape
if isinstance(out[0], np.ndarray):
out = [tuple(row) for row in np.transpose(out)]
return out
#############################################################################
# MAIN CLASSES #
#############################################################################
#Class used for storing and handling a generic tabulation:
[docs]
class BaseTabulation(Utilities, metaclass=ABCMeta):
"""
Class used for storing and handling a tabulation from a structured grid in an n-dimensional space of input-variables.
"""
_order:list[str]
"""The order in which the input variables are nested"""
#########################################################################
#Class methods:
[docs]
@classmethod
@abstractmethod
def from_pandas(cls, data:DataFrame, order:Iterable[str], *args, **kwargs) -> Self:
"""
Construct a tabulation from a pandas.DataFrame with n+x columns where n is len(order).
Args:
data (DataFrame): The data-frame to use.
order (Iterable[str]): The order in which the input variables are nested.
**kwargs: Additional arguments to pass to the constructor.
Returns:
Self: The tabulation.
"""
#Alias
fromPandas = from_pandas
#########################################################################
#Properties:
@property
def order(self) -> list[str]:
"""
The order in which variables are nested.
Returns:
list[str]
"""
return self._order[:]
@order.setter
@abstractmethod
def order(self, order:Iterable[str]):
self.checkArray(order, str, "order")
if not len(order) == len(self.order):
raise ValueError("Length of new order is inconsistent with number of variables in the table.")
if not sorted(self.order) == sorted(order):
raise ValueError("Variables for new ordering are inconsistent with variables in the table.")
self._order = order
####################################
@property
@abstractmethod
def ranges(self):
"""
Get a dict containing the data ranges in the tabulation (read-only).
"""
#######################################
@property
@abstractmethod
def ndim(self) -> int:
"""
Returns the number of dimentsions of the table.
"""
#######################################
@property
@abstractmethod
def shape(self) -> tuple[int]:
"""
The shape, i.e., how many sampling points are used for each input-variable.
"""
#######################################
@property
@abstractmethod
def size(self) -> int:
"""
Returns the number of data-points stored in the table.
"""
#########################################################################
#Private member functions:
_computeIndex = tableIndex
#########################################################################
#Public member functions:
getInput = getInput
[docs]
@abstractmethod
def insertDimension(self, variable:str, value:float, index:int=None, *, inplace:bool=False) -> BaseTabulation|None:
"""
Insert an axis to the dimension-set of the table with a single value.
This is useful to merge two tables with respect to an additional variable.
Args:
table (BaseTabulation): The table to modify.
variable (str): The name of the variable to insert.
value (float): The value for the range of the corresponding variable.
index (int, optional): The index where to insert the variable in nesting order. If None, append the variable at the end. Defaults to None.
inplace (bool, optional): If True, the operation is performed in-place. Defaults to False.
Returns:
BaseTabulation|None: The table with the inserted dimension if inplace is False, None otherwise.
Example:
Create a table with two variables:
```
>>> tab1 = Tabulation([1, 2, 3, 4], {"x":[0, 1], "y":[0, 1]}, ["x", "y"])
>>> tab1.insertDimension("z", 0.0, 1)
>>> tab1.ranges
{"x":[0, 1], "z":[0.0], "y":[0, 1]}
```
Create a second table with the same variables:
```
>>> tab2 = Tabulation([5, 6, 7, 8], {"x":[0, 1], "y":[0, 1]}, ["x", "y"])
>>> tab2.insertDimension("z", 1.0, 1)
>>> tab2.ranges
{"x":[0, 1], "z":[1.0], "y":[0, 1]}
```
Concatenate the two tables:
```
>>> tab1.concat(tab2, inplace=True)
>>> tab1.ranges
{"x":[0, 1], "z":[0.0, 1.0], "y":[0, 1]}
```
"""
[docs]
@abstractmethod
def slice(self, *, slices:Iterable[slice|Iterable[int]|int]=None, ranges:dict[str,float|Iterable[float]]=None, inplace:bool=False) -> BaseTabulation|None:
"""
Extract a table with sliced data. Can access in two ways:
1) by slicer
2) sub-set of interpolation points. Keyword arguments also accepred.
Args:
slices (Iterable[slice|Iterable[int]|int]): The slicers for each input-variable.
ranges (dict[str,float|Iterable[float]], optional): Ranges of sliced table. Defaults to None.
inplace (bool, optional): If True, the operation is performed in-place. Defaults to False.
Returns:
Self|None: The sliced table if inplace is False, None otherwise.
"""
[docs]
@abstractmethod
def clip(self, ranges:dict[str,tuple[float|None,float|None]]=None, *, inplace:bool=False, **kwargs) -> BaseTabulation|None:
"""
Clip the table to the given ranges. The ranges are given as a dictionary with the
variable names as keys and a tuple with the minimum and maximum values.
Args:
ranges (dict[str,tuple[float|None,float|None]], optional): The ranges to clip for each input-variable. If min or max is None, the range is unbounded.
inplace (bool, optional): If True, the operation is performed in-place. Defaults to False.
**kwargs: Can access also by keyword arguments.
Returns:
Self|None: The clipped table if inplace is False, None otherwise.
"""
[docs]
@abstractmethod
def concat(self, *tables:BaseTabulation, inplace:bool=False, fillValue:float=None, overwrite:bool=False) -> BaseTabulation|None:
"""
Extend the table with the data of other tables. The tables must have the same variables but
not necessarily in the same order. The data of the second table is appended to the data
of the first table, preserving the order of the variables.
If fillValue is not given, the ranges of the second table must be consistent with those
of the first table in the variables that are not concatenated. If fillValue is given, the
missing sampling points are filled with the given value.
Args:
*tables (BaseTabulation): The tables to append.
inplace (bool, optional): If True, the operation is performed in-place. Defaults to False.
fillValue (float, optional): The value to fill missing sampling points. Defaults to None.
overwrite (bool, optional): If True, overwrite the data of the first table with the data
of the second table in overlapping regions. Otherwise raise an error. Defaults to False.
Returns:
Self|None: The concatenated table if inplace is False, None otherwise.
"""
append = merge = concat
[docs]
@abstractmethod
def squeeze(self, inplace:bool=False) -> BaseTabulation|None:
"""
Remove dimensions with only 1 data-point.
Args:
inplace (bool, optional): If True, the operation is performed in-place. Defaults to False.
Returns:
Self|None: The squeezed tabulation if inplace is False, None otherwise.
"""
#Conversion
[docs]
@abstractmethod
def to_pandas(self) -> DataFrame:
"""
Convert the tabulation to a pandas.DataFrame.
Returns:
DataFrame: The data-frame.
"""
toPandas = to_pandas
#Plotting
[docs]
@abstractmethod
def plot(self, *args, **kwargs) -> plt.Axes:
"""
Plot the tabulation.
"""
[docs]
@abstractmethod
def plotHeatmap(self, *args, **kwargs) -> plt.Axes:
"""
Plot a heatmap of the tabulation.
"""
#########################################################################
#Dunder methods
#Interpolation
[docs]
@abstractmethod
def __call__(self, *args, **kwargs) -> float|np.ndarray[float]:
"""
Interpolate the table at a given point(s).
"""
pass
#######################################
[docs]
@abstractmethod
def __getitem__(self, index:int|Iterable[int]|slice) -> Any | Iterable[Any]:
"""
Get an element in the table.
Args:
index (int | Iterable[int] | slice | Iterable[slice]): Either:
- An index to access the table (flattened).
- A tuple of the x,y,z,... indices to access the table.
- A slice to access the table (flattened).
- A tuple of slices to access the table.
Returns:
Any | Iterable[Any]: The value(s) stored in the table.
"""
#######################################
[docs]
@abstractmethod
def __setitem__(self, index:int|Iterable[int]|slice|tuple[int|Iterable[int]|slice], value:float|np.ndarray[float]) -> None:
"""
Set the interpolation values at a slice of the table through np.ndarray.__setitem__ but:
- If int|Iterable[int]|slice is given, set the value at the index/indices in the flattened dataset.
- If tuple[int|Iterable[int]|slice] is given, set the value at the index/indices in the nested dataset.
"""
#######################################
[docs]
@abstractmethod
def __eq__(self, value) -> bool:
if not isinstance(value, self.__class__):
raise NotImplementedError(f"Cannot compare {self.__class__.__name__} with object of type '{value.__class__.__name__}'.")
#####################################
#Allow iteration
[docs]
def __iter__(self):
"""
Iterator
Returns:
Self
"""
for ii in range(self.size):
yield self[ii]
[docs]
def __len__(self) -> int:
"""
Returns the number of data-points stored in the table.
"""
return self.size
#######################################
[docs]
def __add__(self, table:BaseTabulation) -> BaseTabulation:
"""
Concatenate two tables. Alias for 'concat'.
"""
return self.concat(table, inplace=False, fillValue=None, overwrite=False)
[docs]
def __iadd__(self, table:BaseTabulation) -> BaseTabulation:
"""
Concatenate two tables in-place. Alias for 'concat'.
"""
self.concat(table, inplace=True, fillValue=None, overwrite=False)
return self
#######################################
[docs]
@abstractmethod
def __repr__(self) -> str:
return f"{self.__class__.__name__}(size={self.size}, order={self.order}, ranges={self.ranges}, shape={self.shape}"
[docs]
@abstractmethod
def __str__(self) -> str:
string = f"{self.__class__.__name__} with {self.size} data-points:\n"
string += f"Shape: {self.shape}\n"
string += f"Order: {self.order}\n"
string += f"Ranges:\n"
for o in self.order:
string += f"\t{o}: {self.ranges[o]}\n"
return string