Passed
Push — main ( ed7d21...87238c )
by Douglas
01:43
created

pocketutils.tools.unit_tools   A

Complexity

Total Complexity 28

Size/Duplication

Total Lines 193
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 28
eloc 105
dl 0
loc 193
rs 10
c 0
b 0
f 0

7 Methods

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