pocketutils.tools.unit_tools   A
last analyzed

Complexity

Total Complexity 33

Size/Duplication

Total Lines 220
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 33
eloc 112
dl 0
loc 220
rs 9.76
c 0
b 0
f 0

11 Methods

Rating   Name   Duplication   Size   Complexity  
A UnitUtils.pint_quantity() 0 5 1
A UnitUtils.milliseconds_to_min_sec() 0 38 5
A UnitUtils.pretty_timedelta() 0 22 4
A UnitUtils.format_approx_big_number() 0 5 3
C UnitUtils.approx_time_wrt() 0 36 9
A LazyPint.__get() 0 6 2
A LazyPint.quantity() 0 4 1
A UnitUtils.round_to_sigfigs() 0 21 4
A UnitUtils.format_pint_quantity() 0 31 2
A LazyPint.unit_reg() 0 4 1
A LazyPint.__init__() 0 3 1
1
# SPDX-FileCopyrightText: Copyright 2020-2023, Contributors to pocketutils
2
# SPDX-PackageHomePage: https://github.com/dmyersturnbull/pocketutils
3
# SPDX-License-Identifier: Apache-2.0
4
"""
5
6
"""
7
8
import logging
9
import math
10
from dataclasses import dataclass
11
from datetime import date, datetime, timedelta
12
from typing import Self, SupportsFloat
13
14
from pocketutils.core.exceptions import ValueOutOfRangeError
15
16
__all__ = ["UnitUtils", "UnitTools"]
17
18
logger = logging.getLogger("pocketutils")
19
20
21
class LazyPint:
22
    def __init__(self):
23
        self.__unit_reg = None
24
        self.__Quantity = None
25
26
    @property
27
    def quantity(self):
28
        self.__get()
29
        return self.__Quantity
30
31
    @property
32
    def unit_reg(self):
33
        self.__get()
34
        return self.__unit_reg
35
36
    def __get(self) -> None:
37
        if self.__Quantity is None:
38
            from pint import UnitRegistry
39
40
            self.__unit_reg = UnitRegistry()
41
            self.__Quantity = self.__unit_reg.Quantity
42
            # Silence NEP 18 warning
43
            # with warnings.catch_warnings():
44
            #    warnings.simplefilter("ignore")
45
            #    Quantity([])
46
47
48
_LAZY_PINT = LazyPint()
49
50
51
@dataclass(slots=True, frozen=True)
52
class UnitUtils:
53
    def pint_quantity(self: Self, value: SupportsFloat, unit: str):
54
        """
55
        Returns a pint `Quantity`.
56
        """
57
        return _LAZY_PINT.quantity(value, unit)
58
59
    def format_approx_big_number(self: Self, n: int) -> str:
60
        for k, v in {1e15: "", 1e12: "T", 1e9: "B", 1e6: "M", 1e3: "k"}.items():
61
            if n >= k:
62
                return str(n // k) + v
63
        return str(n)
64
65
    def approx_time_wrt(
66
        self: Self,
67
        now: date | datetime,
68
        then: date | datetime,
69
        *,
70
        no_date_if_today: bool = False,
71
        higher_unit_factor: int = 2,
72
    ) -> str:
73
        """
74
        Describes `then` with higher resolution for smaller differences to `now`.
75
76
        Examples:
77
        - `approx_time_wrt(date(2021, 1, 12), date(1996, 10, 1))  # "1996"`
78
        - `approx_time_wrt(date(2021, 1, 12), date(2021, 10, 1))  # "2021-01-12"`
79
        - `approx_time_wrt(date(2021, 10, 1), datetime(2021, 10, 1, 11, 55))  # "2021-01-12 11:55"`
80
        - `approx_time_wrt(date(2021, 10, 1), datetime(2021, 10, 1, 11, 0, 0, 30, 222222))  # "2021-01-12 00:00:30"`
81
        - `approx_time_wrt(date(2021, 10, 1), datetime(2021, 10, 1, 11, 0, 0, 2, 222222))  # "2021-01-12 00:00:02.222"`
82
        - `approx_time_wrt(date(2021, 10, 1), datetime(2021, 10, 1, 11, 0, 0, 2, 22))  # "2021-01-12 00:00:02.000022"`
83
        """
84
        delta = now - then if now > then else then - now
85
        tot_days = delta.days + (delta.seconds / 86400) + (delta.microseconds / 86400 / 10**6)
86
        tot_secs = tot_days * 86400
87
        _today = "" if no_date_if_today and then.date() == now.date() else "%Y-%m-%d "
88
        if tot_days > higher_unit_factor * 365.24219:
89
            return str(then.year)
90
        elif tot_days > higher_unit_factor * 30.437:
91
            return then.strftime("%Y-%m")
92
        elif tot_days > higher_unit_factor:
93
            return then.strftime("%Y-%m-%d")
94
        elif tot_secs > higher_unit_factor * 60:
95
            return then.strftime(_today + "%H:%M")
96
        elif tot_secs > higher_unit_factor:
97
            return then.strftime(_today + "%H:%M:%S")
98
        elif tot_secs > higher_unit_factor / 1000:
99
            return then.strftime(_today + "%H:%M:%S") + "." + str(round(then.microsecond / 1000))
100
        return then.strftime(_today + "%H:%M:%S.%f")
101
102
    def pretty_timedelta(self: Self, delta: timedelta | float, *, space: str = "") -> str:
103
        """
104
        Returns a pretty string from a difference in time in seconds.
105
        Rounds hours and minutes to 2 decimal places, and seconds to 1.
106
        Ex: delta_time_to_str(313) == 5.22min
107
            delta_sec: The time in seconds
108
            space: Space char between digits and units;
109
                   good choices are empty, ASCII space, Chars.narrownbsp, Chars.thinspace,
110
                   and Chars.nbsp.
111
112
        Returns:
113
            A string with units 'hr', 'min', or 's'
114
        """
115
        if isinstance(delta, timedelta):
116
            delta_sec = delta.total_seconds()
117
        else:
118
            delta_sec = delta
119
        if abs(delta_sec) > 60 * 60 * 3:
120
            return str(round(delta_sec / 60 / 60, 2)).removesuffix(".0") + space + "hr"
121
        elif abs(delta_sec) > 180:
122
            return str(round(delta_sec / 60, 2)).removesuffix(".0") + space + "min"
123
        return str(round(delta_sec, 1)).removesuffix(".0") + space + "s"
124
125
    def milliseconds_to_min_sec(self: Self, ms: int, space: str = "", minus: str = "-") -> str:
126
        """
127
        Converts a number of milliseconds to one of the following formats.
128
        Will be one of these:
129
130
            - 10ms         if < 1 sec
131
            - 10:15        if < 1 hour
132
            - 10:15:33     if < 1 day
133
            - 5d:10:15:33  if > 1 day
134
135
        Prepends a minus sign (-) if negative.
136
137
        Args:
138
            ms: The milliseconds
139
            space: Space char between digits and 'ms' (if used);
140
                   good choices are empty, ASCII space, `Chars.narrownbsp`, `Chars.thinspace`, and `Chars.nbsp`.
141
            minus: Minus sign symbol
142
143
        Returns:
144
            A string of one of the formats above
145
        """
146
        ms = abs(int(ms))
147
        seconds = int((ms / 1000) % 60)
148
        minutes = int((ms / (1000 * 60)) % 60)
149
        hours = int((ms / (1000 * 60 * 60)) % 24)
150
        days = int(ms / (1000 * 60 * 60 * 24))
151
        z_hr = str(hours).zfill(2)
152
        z_min = str(minutes).zfill(2)
153
        z_sec = str(seconds).zfill(2)
154
        sgn = minus if ms < 0 else ""
155
        if ms < 1000:
156
            return f"{sgn}{ms}{space}ms"
157
        elif days > 1:
158
            return f"{days}d:{z_hr}:{z_min}:{z_sec}"
159
        elif hours > 1:
160
            return f"{sgn}{z_hr}:{z_min}:{z_sec}"
161
        else:
162
            return f"{sgn}{z_min}:{z_sec}"
163
164
    def round_to_sigfigs(self: Self, num: SupportsFloat, n_sigfigs: int | None) -> float:
165
        """
166
        Round to specified number of sigfigs.
167
168
        Args:
169
            num: Floating-point number to round
170
            n_sigfigs: The number of significant figures, non-negative
171
172
        Returns:
173
            A Python integer
174
        """
175
        if n_sigfigs is None:
176
            return float(num)
177
        if n_sigfigs < 0:
178
            msg = f"sig_figs {n_sigfigs} is negative"
179
            raise ValueOutOfRangeError(msg, value=num, minimum=0)
180
        num = float(num)
181
        if num != 0:
182
            digits = -int(math.floor(math.log10(abs(num))) - (n_sigfigs - 1))
183
            return round(num, digits)
184
        return 0  # can't take the log of 0
185
186
    def format_pint_quantity(
187
        self: Self,
188
        value: float,
189
        unit: str,
190
        n_digits: int | None = None,
191
        n_sigfigs: int | None = None,
192
        *,
193
        space: str = "",
194
    ) -> str:
195
        """
196
        Returns a value with units, with the units scaled as needed.
197
198
        Args:
199
            value: Value without a prefix
200
            unit: Unit
201
            n_digits: Number of digits after the decimal point
202
            n_sigfigs: Rounds to a number of significant (nonzero) figures
203
            space: Space char between digits and units;
204
                   good choices are empty, ASCII space,
205
                   :attr:`pocketutils.core.chars.Chars.narrownbsp`,
206
                   :attr:`pocketutils.core.chars.Chars.thinspace`,
207
                   and :attr:`pocketutils.core.chars.Chars.nbsp`.
208
209
        Returns:
210
            The value with a suffix like `"5.2 mg"`
211
        """
212
        if n_digits is not None:
213
            value = round(value, n_digits)
214
        value = self.round_to_sigfigs(value, n_sigfigs)
215
        dimmed = value * getattr(_LAZY_PINT.unit_reg, unit)
216
        return f"{dimmed:~}" + space + unit
217
218
219
UnitTools = UnitUtils()
220