#!/usr/bin/env python3
#
# temperature.py
"""
Temperature-specific functionality.
"""
#
# Copyright (c) 2020 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# Based on cyberpandas
# https://github.com/ContinuumIO/cyberpandas
# Copyright (c) 2018, Anaconda, Inc.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# stdlib
import abc
import operator
import re
from typing import Any, Callable, List, MutableSequence, Optional, Sequence, Type, TypeVar, Union
# 3rd party
import numpy
import pandas # type: ignore[import-untyped]
from domdf_python_tools import doctools
from pandas.api.extensions import ExtensionDtype # type: ignore[import-untyped]
from pandas.core.dtypes.inference import is_list_like # type: ignore[import-untyped]
# this package
from si_unit_pandas.base import BaseArray, UserFloat
__all__ = [
"Celsius",
"CelsiusType",
"Fahrenheit",
"TemperatureArray",
"TemperatureBase",
"is_temperature_type",
"to_temperature",
]
_to_temp_types = Union[float, str, Sequence[Union[float, str, "Celsius"]]]
# -----------------------------------------------------------------------------
# Extension Type
# -----------------------------------------------------------------------------
[docs]class TemperatureBase(metaclass=abc.ABCMeta):
"""
Metaclass providing a common base class for Temperatures.
"""
[docs]@doctools.append_docstring_from(float)
class Celsius(UserFloat): # noqa: PRM002
"""
:class:`float` subclass representing a temperature in Celsius.
"""
def __init__(self, value: Any):
if isinstance(value, str):
value = re.split("[ ℃°C]", value)[0]
super().__init__(value)
[docs] def __str__(self) -> str:
"""
Return the temperature as a string.
"""
return f"{float(self)}\u205F\u2103"
[docs] def __repr__(self) -> str:
"""
Return a string representation of the temperature.
"""
return str(self)
[docs]@doctools.append_docstring_from(float)
class Fahrenheit(UserFloat):
"""
:class:`float` subclass representing a temperature in Fahrenheit.
"""
[docs] def __str__(self) -> str:
"""
Return the temperature as a string.
"""
return f"{float(self)}\u205F\u2109"
[docs] def __repr__(self) -> str:
"""
Return a string representation of the temperature.
"""
return str(self)
TemperatureBase.register(Celsius)
TemperatureBase.register(Fahrenheit)
[docs]@pandas.api.extensions.register_extension_dtype
class CelsiusType(ExtensionDtype):
"""
Numpy dtype representing a temperature in degrees Celsius.
"""
name: str = "celsius"
type: Type = TemperatureBase # noqa: A003 # pylint: disable=redefined-builtin
kind: str = 'O'
_record_type: Type = float
[docs] @classmethod
def construct_from_string(cls, string: str) -> "CelsiusType":
"""
Construct a :class:`~.CelsiusType` from a string.
:param string:
"""
if string == cls.name:
return cls()
else:
raise TypeError(f"Cannot construct a '{cls.__name__}' from '{string}'")
[docs] @classmethod
def construct_array_type(cls) -> Type["TemperatureArray"]: # noqa: D102
return TemperatureArray
@property
def _is_numeric(self) -> bool:
"""
Whether columns with this dtype should be considered numeric.
By default ExtensionDtypes are assumed to be non-numeric.
They'll be excluded from operations that exclude non-numeric
columns, like (groupby) reductions, plotting, etc.
"""
return True
@property
def _is_boolean(self) -> bool:
"""
Whether this dtype should be considered boolean.
By default, ExtensionDtypes are assumed to be non-numeric.
Setting this to True will affect the behavior of several places,
e.g.
* is_bool
* boolean indexing
"""
return True
# -----------------------------------------------------------------------------
# Extension Container
# -----------------------------------------------------------------------------
_A = TypeVar("_A")
[docs]class TemperatureArray(BaseArray): # noqa: PRM002
"""
Holder for Temperatures.
TemperatureArray is a container for Temperatures. It satisfies pandas'
extension array interface, and so can be stored inside
:class:`pandas.Series` and :class:`pandas.DataFrame`.
"""
__array_priority__: int = 1000
_dtype = CelsiusType()
_itemsize: int = 16
can_hold_na: bool = True
def __init__(
self,
data: Union["TemperatureArray", numpy.ndarray, Sequence[Union[str, float, Celsius]], float],
dtype: Optional[Type] = None,
copy: bool = False,
) -> None:
# The dtype is always CelsiusType
data = _to_temperature_array(data) # TODO: avoid potential copy
if copy:
data = data.copy()
self.data = data
[docs] def __getitem__(self, item: Union[int, slice, numpy.ndarray]) -> Any:
"""
Select a subset of self.
:param item:
* int: The position in 'self' to get.
* slice: A slice object, where 'start', 'stop', and 'step' are integers or None.
* ndarray: A 1-d boolean NumPy ndarray the same length as 'self'
:rtype: scalar or ExtensionArray
.. note::
For scalar ``item``, return a scalar value suitable for the array's
type. This should be an instance of ``self.dtype.type``.
For slice ``key``, return an instance of ``ExtensionArray``, even
if the slice is length 0 or 1.
For a boolean mask, return an instance of ``ExtensionArray``, filtered
to the values where ``item`` is True.
"""
result = operator.getitem(self.data, item) # type: ignore[misc]
if result.ndim == 0:
return Celsius(result.item())
else:
return type(self)(result)
def _format_values(self) -> List[Celsius]:
formatted = []
# TODO: perf
for i in range(len(self)):
formatted.append(Celsius(self.data[i]))
return formatted
@property
def _parser(self) -> Callable[[_to_temp_types], "TemperatureArray"]:
return to_temperature
[docs] def append(self, value: _to_temp_types) -> None:
"""
Append a value to this TemperatureArray.
:param value:
"""
super().append(value)
[docs] def astype(self, dtype: Type, copy: bool = True): # noqa: MAN002 # TODO
"""
Returns the array with its values as the given dtype.
:param dtype:
:param copy: If :py:obj:`True`, returns a copy of the array.
"""
if isinstance(dtype, CelsiusType):
if copy:
self = self.copy()
return self
return super().astype(dtype)
[docs] def isin(self, other: _to_temp_types) -> numpy.ndarray:
"""
Check whether elements of `self` are in `other`.
Comparison is done elementwise.
:param other:
:return: A 1-D boolean ndarray with the same length as self.
"""
if isinstance(other, (str, float)) or not isinstance(other, (self.__class__, Sequence)):
other = [other]
temperatures = []
if not isinstance(other, self.__class__):
for net in other:
net = Celsius(net)
temperatures.append(net)
else:
temperatures = other
temperatures = self.__class__(temperatures)
mask = numpy.zeros(len(self), dtype="bool")
for network in temperatures:
mask |= self == network
mask |= self == temperatures
return mask
[docs]def is_temperature_type(obj: Any) -> bool:
"""
Returns whether ``obj`` is a temperature type.
:param obj:
"""
t = getattr(obj, "dtype", obj)
try:
return isinstance(t, CelsiusType) or issubclass(t, CelsiusType)
except Exception:
return False
[docs]def to_temperature(values: _to_temp_types) -> TemperatureArray:
"""
Convert values to a :class:`~.TemperatureArray`.
:param values:
"""
if is_list_like(values):
return TemperatureArray(_to_temperature_array(values))
else:
return TemperatureArray(_to_temperature_array([values])) # type: ignore[list-item]
def _to_temperature_array(
values: Union[TemperatureArray, numpy.ndarray, Sequence[Union[str, float, Celsius]], float],
) -> numpy.ndarray: # : Union[TemperatureArray, np.ndarray]
"""
Convert the values to a temperature array.
:param values:
"""
if isinstance(values, TemperatureArray):
return values.data
if isinstance(values, numpy.ndarray) and values.ndim == 1 and numpy.issubdtype(values.dtype, numpy.integer):
values = values.astype(float)
values = numpy.asarray(values, dtype=CelsiusType._record_type)
elif not (isinstance(values, numpy.ndarray) and values.dtype == CelsiusType._record_type):
values = _to_int_pairs(values) # type: ignore[arg-type]
return numpy.atleast_1d(numpy.asarray(values, dtype=CelsiusType._record_type))
def _to_int_pairs(values: _to_temp_types) -> Union[List[float], float]:
if isinstance(values, (str, int, float, Celsius)):
if isinstance(values, Fahrenheit):
values = (values - 32) * (5 / 9)
return float(values)
elif isinstance(values, numpy.ndarray) and values.dtype != object:
if values.ndim != 2:
raise ValueError("'values' should be a 2-D when passing a NumPy array.")
return values
else:
new_values: MutableSequence[Union[float, Celsius]] = []
for v in values:
if isinstance(v, Fahrenheit):
new_values.append((v - 32) * (5 / 9))
else:
new_values.append(float(v))
return [float(v) for v in new_values]