readabs.recalibrate

Recalibrate a Series or DataFrame so the data is in the range -1000 to 1000.

  1"""Recalibrate a Series or DataFrame so the data is in the range -1000 to 1000."""
  2
  3import sys
  4from collections.abc import Callable
  5from operator import mul, truediv
  6from typing import Any
  7
  8import numpy as np
  9from pandas import DataFrame, Series
 10
 11from readabs.datatype import Datatype as DataT
 12
 13# Constants
 14NDIM_SERIES = 1
 15NDIM_DATAFRAME = 2
 16MAX_VALUE_THRESHOLD = 1000
 17MIN_VALUE_THRESHOLD = 1
 18STEP_SIZE = 3
 19DIVISOR = 1000
 20
 21
 22# --- public
 23def recalibrate(
 24    data: DataT,
 25    units: str,
 26) -> tuple[DataT, str]:
 27    """Recalibrate a Series or DataFrame so the data is in the range -1000 to 1000.
 28
 29    Change the name of the units to reflect the recalibration.
 30
 31    Note, DataT = TypeVar("DataT", Series, DataFrame). DataT is a constrained typevar.
 32    If you provide a Series, you will get a Series back. If you provide a DataFrame,
 33    you will get a DataFrame back.
 34
 35    Parameters
 36    ----------
 37    data : Series or DataFrame
 38        The data to recalibrate.
 39    units : str
 40        The units of the data. This string should be in the form of
 41        "Number", "Thousands", "Millions", "Billions", etc. The units
 42        should be in title case.
 43
 44    Returns
 45    -------
 46    Series or DataFrame
 47        The recalibrated data will be a Series if a Series was provided,
 48        or a DataFrame if a DataFrame was provided.
 49
 50    Examples
 51    --------
 52    ```python
 53    from pandas import Series
 54    from readabs import recalibrate
 55    s = Series([1_000, 10_000, 100_000, 1_000_000])
 56    recalibrated, units = recalibrate(s, "$")
 57    print(f"{recalibrated=}, {units=}")
 58    ```
 59
 60    """
 61    if not isinstance(data, (Series, DataFrame)):
 62        raise TypeError("data must be a Series or DataFrame")
 63    units, restore_name = _prepare_units(units)
 64    flat_data = data.to_numpy().flatten()
 65    flat_data, units = _recalibrate(flat_data, units)
 66
 67    if restore_name:
 68        units = f"{restore_name} {units}"
 69        for n in "numbers", "number":
 70            if n in units:
 71                units = units.replace(n, "").strip()
 72                break
 73    units = units.title()
 74
 75    result = data.__class__(flat_data.reshape(data.shape))
 76    result.index = data.index
 77    # restore the column labels (DataFrame) or series name (Series); the
 78    # isinstance checks narrow the constrained TypeVar so the attributes type-check
 79    if isinstance(data, DataFrame) and isinstance(result, DataFrame):
 80        result.columns = data.columns
 81    elif isinstance(data, Series) and isinstance(result, Series):
 82        result.name = data.name
 83    return result, units
 84
 85
 86def recalibrate_value(value: float, units: str) -> tuple[float, str]:
 87    """Recalibrate a floating point value.
 88
 89    The value will be recalibrated so it is in the range -1000 to 1000.
 90    The units will be changed to reflect the recalibration.
 91
 92    Parameters
 93    ----------
 94    value : float
 95        The value to recalibrate.
 96    units : str
 97        The units of the value. This string should be in the form of
 98        "Number", "Thousands", "Millions", "Billions", etc. The units
 99        should be in title case.
100
101    Returns
102    -------
103    tuple[float, str]
104        A tuple containing the recalibrated value and the recalibrated units.
105
106    Examples
107    --------
108    ```python
109    from readabs import recalibrate_value
110    recalibrated, units = recalibrate_value(10_000_000, "Thousand")
111    print(recalibrated, units)
112    ```
113
114    """
115    series = Series([value])
116    output, units = recalibrate(series, units)
117    return output.to_numpy()[0], units
118
119
120# --- private
121_MIN_RECALIBRATE = "number"  # all lower case
122_MAX_RECALIBRATE = "decillion"  # all lower case
123_keywords = {
124    _MIN_RECALIBRATE.title(): 0,
125    "Thousand": 3,
126    "Million": 6,
127    "Billion": 9,
128    "Trillion": 12,
129    "Quadrillion": 15,
130    "Quintillion": 18,
131    "Sextillion": 21,
132    "Septillion": 24,
133    "Octillion": 27,
134    "Nonillion": 30,
135    _MAX_RECALIBRATE.title(): 33,
136}
137_r_keywords = {v: k for k, v in _keywords.items()}
138
139
140def _prepare_units(units: str) -> tuple[str, str]:
141    """Prepare the units for recalibration."""
142    substitutions = [
143        ("000 Hours", "Thousand Hours"),
144        ("$'000,000", "$ Million"),
145        ("$'000", " $ Thousand"),
146        ("'000,000", "Millions"),
147        ("'000", "Thousands"),
148        ("000,000", "Millions"),
149        ("000", "Thousands"),
150    ]
151    units = units.strip()
152    for pattern, replacement in substitutions:
153        units = units.replace(pattern, replacement)
154
155    # manage the names for some gnarly units
156    possible_units = ("$", "Tonnes")  # there may be more possible units
157    found_unit = ""
158    for pu in possible_units:
159        if pu.lower() in units.lower():
160            units = units.lower().replace(pu.lower(), "").strip()
161            if units == "":
162                units = "number"
163            found_unit = pu
164            break
165
166    return units, found_unit
167
168
169def _find_calibration(units: str) -> str | None:
170    found = None
171    for keyword in _keywords:
172        if keyword in units or keyword.lower() in units:
173            found = keyword
174            break
175    return found
176
177
178# private
179def _perfect_already(data: np.ndarray) -> bool:
180    """No need to recalibrate if the data is already perfect."""
181    check_max = np.nanmax(np.abs(data))
182    return bool(MIN_VALUE_THRESHOLD <= check_max < MAX_VALUE_THRESHOLD)
183
184
185def _all_zero(data: np.ndarray) -> bool:
186    """Cannot recalibrate if all the data is zero."""
187    if np.nanmax(np.abs(data)) == 0:
188        print("recalibrate(): All zero data")
189        return True
190    return False
191
192
193def _not_numbers(data: np.ndarray) -> bool:
194    """Cannot recalibrate if the data is not numeric."""
195    if (not np.issubdtype(data.dtype, np.number)) or np.isinf(data).any() or np.isnan(data).all():
196        print("recalibrate(): Data is partly or completely non-numeric.")
197        return True
198    return False
199
200
201def _can_recalibrate(flat_data: np.ndarray, units: str) -> bool:
202    """Check if the data can be recalibrated."""
203    if _find_calibration(units) is None:
204        print(f"recalibrate(): Units not appropriately calibrated: {units}")
205        return False
206
207    return all(not f(flat_data) for f in (_not_numbers, _all_zero, _perfect_already))
208
209
210def _recalibrate(flat_data: np.ndarray, units: str) -> tuple[np.ndarray, str]:
211    """Recalibrate the data.
212
213    Loop over the data until its maximum value is between -1000 and 1000.
214    """
215    if _can_recalibrate(flat_data, units):
216        while True:
217            maximum = np.nanmax(np.abs(flat_data))
218            if maximum >= MAX_VALUE_THRESHOLD:
219                if _MAX_RECALIBRATE in units.lower():
220                    print("recalibrate() is not designed for very big units")
221                    break
222                flat_data, units = _do_recal(flat_data, units, STEP_SIZE, truediv)
223                continue
224            if maximum < 1:
225                if _MIN_RECALIBRATE in units.lower():
226                    print("recalibrate() is not designed for very small units")
227                    break
228                flat_data, units = _do_recal(flat_data, units, -STEP_SIZE, mul)
229                continue
230            break
231    return flat_data, units
232
233
234def _do_recal(
235    flat_data: np.ndarray, units: str, step: int, operator: Callable[[np.ndarray, int], np.ndarray]
236) -> tuple[np.ndarray, str]:
237    calibration = _find_calibration(units)
238    if calibration is None:
239        raise ValueError(f"No calibration found for units: {units}")
240    factor = _keywords[calibration]
241    if factor + step not in _r_keywords:
242        print(f"Unexpected factor: {factor + step}")
243        sys.exit(-1)
244    replacement = _r_keywords[factor + step]
245    units = units.replace(calibration, replacement)
246    units = units.replace(calibration.lower(), replacement)
247    flat_data = operator(flat_data, DIVISOR)
248    return flat_data, units
249
250
251# --- test
252if __name__ == "__main__":
253
254    def test_example() -> None:
255        """Test the example in the docstring."""
256        s = Series([1_000, 10_000, 100_000, 1_000_000])
257        recalibrated, units = recalibrate(s, "$")
258        print(f"{recalibrated=}, {units=}")
259
260        recalibrated_val, units_val = recalibrate_value(10_000_000, "Thousand")
261        print(f"{recalibrated_val=}, {units_val=}")
262        print("=" * 40)
263
264    test_example()
265
266    def test_recalibrate() -> None:
267        """Test the recalibrate() function."""
268
269        def run_test(dataset: tuple[tuple[list[Any], str], ...]) -> None:
270            for d, u in dataset:
271                data: Series[Any] = Series(d)
272                recalibrated, units = recalibrate(data, u)
273                print(f"{data.to_numpy()}, {u} ==> {recalibrated.to_numpy()}, {units}")
274                print("=" * 40)
275
276        # good examples
277        good = (
278            ([1, 2, 3, 4, 5], "Number"),  # no change
279            ([1_000, 10_000, 100_000, 1_000_000], "$"),
280            ([1_000, 10_000, 100_000, 1_000_000], "Number Spiders"),
281            ([1_000, 10_000, 100_000, 1_000_000], "Thousand"),
282            ([0.2, 0.3], "Thousands"),
283            ([0.000_000_2, 0.000_000_3], "Trillion"),
284        )
285        run_test(good)
286
287        # bad sets of data - should produce error messages and do nothing
288        bad = (
289            ([1, 2, 3, 4, 5], "Hundreds"),
290            ([0, 0, 0], "Thousands"),
291            ([np.nan, 0, 0], "Thousands"),
292            ([np.inf, 1, 2], "Thousands"),
293            ([0, 0, "a"], "Thousands"),
294        )
295        run_test(bad)
296
297    test_recalibrate()
298
299    def test_recalibrate_value() -> None:
300        """Test the recalibrate_value() function."""
301        # good example
302        recalibrated, units = recalibrate_value(10_000_000, "Thousand")
303        print(recalibrated, units)
304        print("=" * 40)
305
306        # bad example
307        recalibrated, units = recalibrate_value(3_900, "Spiders")
308        print(recalibrated, units)
309        print("=" * 40)
310
311    test_recalibrate_value()
NDIM_SERIES = 1
NDIM_DATAFRAME = 2
MAX_VALUE_THRESHOLD = 1000
MIN_VALUE_THRESHOLD = 1
STEP_SIZE = 3
DIVISOR = 1000
def recalibrate(data: ~Datatype, units: str) -> tuple[~Datatype, str]:
24def recalibrate(
25    data: DataT,
26    units: str,
27) -> tuple[DataT, str]:
28    """Recalibrate a Series or DataFrame so the data is in the range -1000 to 1000.
29
30    Change the name of the units to reflect the recalibration.
31
32    Note, DataT = TypeVar("DataT", Series, DataFrame). DataT is a constrained typevar.
33    If you provide a Series, you will get a Series back. If you provide a DataFrame,
34    you will get a DataFrame back.
35
36    Parameters
37    ----------
38    data : Series or DataFrame
39        The data to recalibrate.
40    units : str
41        The units of the data. This string should be in the form of
42        "Number", "Thousands", "Millions", "Billions", etc. The units
43        should be in title case.
44
45    Returns
46    -------
47    Series or DataFrame
48        The recalibrated data will be a Series if a Series was provided,
49        or a DataFrame if a DataFrame was provided.
50
51    Examples
52    --------
53    ```python
54    from pandas import Series
55    from readabs import recalibrate
56    s = Series([1_000, 10_000, 100_000, 1_000_000])
57    recalibrated, units = recalibrate(s, "$")
58    print(f"{recalibrated=}, {units=}")
59    ```
60
61    """
62    if not isinstance(data, (Series, DataFrame)):
63        raise TypeError("data must be a Series or DataFrame")
64    units, restore_name = _prepare_units(units)
65    flat_data = data.to_numpy().flatten()
66    flat_data, units = _recalibrate(flat_data, units)
67
68    if restore_name:
69        units = f"{restore_name} {units}"
70        for n in "numbers", "number":
71            if n in units:
72                units = units.replace(n, "").strip()
73                break
74    units = units.title()
75
76    result = data.__class__(flat_data.reshape(data.shape))
77    result.index = data.index
78    # restore the column labels (DataFrame) or series name (Series); the
79    # isinstance checks narrow the constrained TypeVar so the attributes type-check
80    if isinstance(data, DataFrame) and isinstance(result, DataFrame):
81        result.columns = data.columns
82    elif isinstance(data, Series) and isinstance(result, Series):
83        result.name = data.name
84    return result, units

Recalibrate a Series or DataFrame so the data is in the range -1000 to 1000.

Change the name of the units to reflect the recalibration.

Note, DataT = TypeVar("DataT", Series, DataFrame). DataT is a constrained typevar. If you provide a Series, you will get a Series back. If you provide a DataFrame, you will get a DataFrame back.

Parameters

data : Series or DataFrame The data to recalibrate. units : str The units of the data. This string should be in the form of "Number", "Thousands", "Millions", "Billions", etc. The units should be in title case.

Returns

Series or DataFrame The recalibrated data will be a Series if a Series was provided, or a DataFrame if a DataFrame was provided.

Examples

from pandas import Series
from readabs import recalibrate
s = Series([1_000, 10_000, 100_000, 1_000_000])
recalibrated, units = recalibrate(s, "$")
print(f"{recalibrated=}, {units=}")
def recalibrate_value(value: float, units: str) -> tuple[float, str]:
 87def recalibrate_value(value: float, units: str) -> tuple[float, str]:
 88    """Recalibrate a floating point value.
 89
 90    The value will be recalibrated so it is in the range -1000 to 1000.
 91    The units will be changed to reflect the recalibration.
 92
 93    Parameters
 94    ----------
 95    value : float
 96        The value to recalibrate.
 97    units : str
 98        The units of the value. This string should be in the form of
 99        "Number", "Thousands", "Millions", "Billions", etc. The units
100        should be in title case.
101
102    Returns
103    -------
104    tuple[float, str]
105        A tuple containing the recalibrated value and the recalibrated units.
106
107    Examples
108    --------
109    ```python
110    from readabs import recalibrate_value
111    recalibrated, units = recalibrate_value(10_000_000, "Thousand")
112    print(recalibrated, units)
113    ```
114
115    """
116    series = Series([value])
117    output, units = recalibrate(series, units)
118    return output.to_numpy()[0], units

Recalibrate a floating point value.

The value will be recalibrated so it is in the range -1000 to 1000. The units will be changed to reflect the recalibration.

Parameters

value : float The value to recalibrate. units : str The units of the value. This string should be in the form of "Number", "Thousands", "Millions", "Billions", etc. The units should be in title case.

Returns

tuple[float, str] A tuple containing the recalibrated value and the recalibrated units.

Examples

from readabs import recalibrate_value
recalibrated, units = recalibrate_value(10_000_000, "Thousand")
print(recalibrated, units)