Passed
Push — main ( c9ac86...a4501a )
by Douglas
02:00
created

UnitTools.approx_time_wrt()   C

Complexity

Conditions 9

Size

Total Lines 38
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 25
nop 6
dl 0
loc 38
rs 6.6666
c 0
b 0
f 0
1
import logging
0 ignored issues
show
introduced by
Missing module docstring
Loading history...
2
import math
3
from datetime import datetime, date, timedelta
4
from typing import Optional, SupportsFloat, Tuple, Union
5
6
import regex
0 ignored issues
show
introduced by
Unable to import 'regex'
Loading history...
7
from pint import Quantity, UnitRegistry
0 ignored issues
show
introduced by
Unable to import 'pint'
Loading history...
8
from pint.errors import PintTypeError
0 ignored issues
show
introduced by
Unable to import 'pint.errors'
Loading history...
9
10
from pocketutils.core._internal import nicesize
11
from pocketutils.core.exceptions import OutOfRangeError, StringPatternError
12
from pocketutils.tools.base_tools import BaseTools
13
from pocketutils.tools.string_tools import StringTools
14
15
logger = logging.getLogger("pocketutils")
16
_UNIT_REG = UnitRegistry()
17
18
19
class UnitTools(BaseTools):
0 ignored issues
show
introduced by
Missing class docstring
Loading history...
20
    @classmethod
21
    def approx_time_wrt(
0 ignored issues
show
best-practice introduced by
Too many return statements (7/6)
Loading history...
22
        cls,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
23
        now: Union[date, datetime],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
24
        then: Union[date, datetime],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
25
        *,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
26
        skip_today: bool = False,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
27
        sig: int = 3,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
28
    ) -> str:
29
        """
30
        Describes ``then`` with higher resolution for smaller differences to ``now``.
31
32
        Examples:
33
            - ``approx_time_wrt(date(2021, 1, 12), date(1996, 10, 1))  # "1996"``
34
            - ``approx_time_wrt(date(2021, 1, 12), date(2021, 10, 1))  # "2021-01-12"``
35
            - ``approx_time_wrt(date(2021, 10, 1), datetime(2021, 10, 1, 11, 55))  # "2021-01-12 11:55"``
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (105/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
36
            - ``approx_time_wrt(date(2021, 10, 1), datetime(2021, 10, 1, 11, 0, 0, 30, 222222))  # "2021-01-12 00:00:30"``
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (122/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
37
            - ``approx_time_wrt(date(2021, 10, 1), datetime(2021, 10, 1, 11, 0, 0, 2, 222222))  # "2021-01-12 00:00:02.222"``
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (125/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
38
            - ``approx_time_wrt(date(2021, 10, 1), datetime(2021, 10, 1, 11, 0, 0, 2, 22))  # "2021-01-12 00:00:02.000022"``
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (124/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
39
        """
40
        delta = now - then if now > then else then - now
41
        tot_days = (delta.days) + (delta.seconds / 86400) + (delta.microseconds / 86400 / 10 ** 6)
42
        tot_secs = tot_days * 86400
43
        _today = "" if skip_today and then.date() == now.date() else "%Y-%m-%d "
44
        if tot_days > sig * 365.24219:
0 ignored issues
show
unused-code introduced by
Unnecessary "elif" after "return"
Loading history...
45
            return str(then.year)
46
        elif tot_days > sig * 30.437:
47
            return then.strftime("%Y-%m")
48
        elif tot_days > sig:
49
            return then.strftime("%Y-%m-%d")
50
        elif tot_secs > sig * 60:
51
            return then.strftime(_today + "%H:%M")
52
        elif tot_secs > sig:
53
            return then.strftime(_today + "%H:%M:%S")
54
        elif tot_secs > sig / 1000:
55
            return then.strftime(_today + "%H:%M:%S") + "." + str(round(then.microsecond / 1000))
56
        else:
57
            return then.strftime(_today + "%H:%M:%S.%f")
58
59
    @classmethod
60
    def delta_time_to_str(cls, delta_sec: Union[float, timedelta], *, space: str = "") -> str:
61
        """
62
        Returns a pretty string from a difference in time in seconds.
63
        Rounds hours and minutes to 2 decimal places, and seconds to 1.
64
        Ex: delta_time_to_str(313) == 5.22min
65
            delta_sec: The time in seconds
66
            space: Space char between digits and units;
67
                   good choices are empty, ASCII space, Chars.narrownbsp, Chars.thinspace,
68
                   and Chars.nbsp.
69
70
        Returns:
71
            A string with units 'hr', 'min', or 's'
72
        """
73
        if isinstance(delta_sec, timedelta):
74
            delta_sec = delta_sec.total_seconds()
75
        if abs(delta_sec) > 60 * 60 * 3:
0 ignored issues
show
unused-code introduced by
Unnecessary "elif" after "return"
Loading history...
76
            return (
77
                StringTools.strip_empty_decimal(str(round(delta_sec / 60 / 60, 2))) + space + "hr"
78
            )
79
        elif abs(delta_sec) > 180:
80
            return StringTools.strip_empty_decimal(str(round(delta_sec / 60, 2))) + space + "min"
81
        else:
82
            return StringTools.strip_empty_decimal(str(round(delta_sec, 1))) + space + "s"
83
84
    @classmethod
85
    def ms_to_minsec(cls, ms: int, space: str = "") -> str:
0 ignored issues
show
Coding Style Naming introduced by
Argument name "ms" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
86
        """
87
        Converts a number of milliseconds to one of the following formats:
88
            - 10ms         if < 1 sec
89
            - 10:15        if < 1 hour
90
            - 10:15:33     if < 1 day
91
            - 5d:10:15:33  if > 1 day
92
        Prepends a minus sign (−) if negative.
93
94
        Args:
95
            ms: The milliseconds
96
            space: Space char between digits and 'ms' (if used);
97
                   good choices are empty, ASCII space, Chars.narrownbsp,
98
                   Chars.thinspace, and Chars.nbsp.
99
100
        Returns:
101
            A string of one of the formats above
102
        """
103
        ms = abs(int(ms))
104
        seconds = int((ms / 1000) % 60)
105
        minutes = int((ms / (1000 * 60)) % 60)
106
        hours = int((ms / (1000 * 60 * 60)) % 24)
107
        days = int(ms / (1000 * 60 * 60 * 24))
108
        z_hr = str(hours).zfill(2)
109
        z_min = str(minutes).zfill(2)
110
        z_sec = str(seconds).zfill(2)
111
        sgn = "−" if ms < 0 else ""
112
        if ms < 1000:
0 ignored issues
show
unused-code introduced by
Unnecessary "elif" after "return"
Loading history...
113
            return f"{sgn}{ms}{space}ms"
114
        elif days > 1:
115
            return f"{days}d:{z_hr}:{z_min}:{z_sec}"
116
        elif hours > 1:
117
            return f"{sgn}{z_hr}:{z_min}:{z_sec}"
118
        else:
119
            return f"{sgn}{z_min}:{z_sec}"
120
121
    @classmethod
122
    def friendly_size(cls, n_bytes: int, *, space: str = " ") -> str:
123
        """
124
        Returns a text representation of a number of bytes.
125
        Uses base 2 with IEC 1998, rounded to 0 decimal places, and without a space.
126
        """
127
        return nicesize(n_bytes, space=space)
128
129
    @classmethod
130
    def round_to_sigfigs(cls, num: SupportsFloat, sig_figs: int) -> float:
131
        """
132
        Round to specified number of sigfigs.
133
134
        Args:
135
            num: A Python or Numpy float or something that supports __float__
136
            sig_figs: The number of significant figures, non-negative
137
138
        Returns:
139
            A Python integer
140
        """
141
        if sig_figs < 0:
142
            raise OutOfRangeError(f"sig_figs {sig_figs} is negative", minimum=0)
143
        num = float(num)
144
        if num != 0:
0 ignored issues
show
unused-code introduced by
Unnecessary "else" after "return"
Loading history...
145
            digits = -int(math.floor(math.log10(abs(num))) - (sig_figs - 1))
146
            return round(num, digits)
147
        else:
148
            return 0  # can't take the log of 0
149
150
    @classmethod
151
    def format_micromolar(
152
        cls,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
153
        micromolar: float,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
154
        n_sigfigs: Optional[int] = 5,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
155
        *,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
156
        adjust_units: bool = True,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
157
        use_sigfigs: bool = True,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
158
        space: str = "",
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
159
    ) -> str:
160
        """
161
        Returns a concentration with units, with the units scaled as needed.
162
        Can handle millimolar, micromolar, nanomolar, and picomolar.
163
164
        Args:
165
            micromolar: Value
166
            n_sigfigs: For rounding; no rounding if None
167
            adjust_units: If False, will always use micromolar
168
            use_sigfigs: If True, rounds to a number of significant figures; otherwise round to decimal places
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (110/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
169
            space: Space char between digits and units;
170
                   good choices are empty, ASCII space, Chars.narrownbsp, Chars.thinspace, and Chars.nbsp.
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (106/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
171
172
        Returns:
173
            The concentration with a suffix of µM, mM, nM, or mM
174
        """
175
        d = micromolar
0 ignored issues
show
Coding Style Naming introduced by
Variable name "d" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
176
        m = abs(d)
0 ignored issues
show
Coding Style Naming introduced by
Variable name "m" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
177
        unit = "µM"
178
        if adjust_units:
179
            if m < 1e-6:
180
                d *= 1e9
0 ignored issues
show
Coding Style Naming introduced by
Variable name "d" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
181
                unit = "fM"
182
            elif m < 1e-3:
183
                d *= 1e6
0 ignored issues
show
Coding Style Naming introduced by
Variable name "d" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
184
                unit = "pM"
185
            elif m < 1:
186
                d *= 1e3
0 ignored issues
show
Coding Style Naming introduced by
Variable name "d" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
187
                unit = "nM"
188
            elif m >= 1e6:
189
                d /= 1e6
0 ignored issues
show
Coding Style Naming introduced by
Variable name "d" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
190
                unit = "M"
191
            elif m >= 1e3:
192
                d /= 1e3
0 ignored issues
show
Coding Style Naming introduced by
Variable name "d" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
193
                unit = "mM"
194
        if n_sigfigs is None:
195
            pass
196
        elif use_sigfigs:
197
            d = cls.round_to_sigfigs(d, n_sigfigs)
0 ignored issues
show
Coding Style Naming introduced by
Variable name "d" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
198
        else:
199
            d = round(d, n_sigfigs)
0 ignored issues
show
Coding Style Naming introduced by
Variable name "d" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
200
        if round(d) == d and str(d).endswith(".0"):
0 ignored issues
show
unused-code introduced by
Unnecessary "else" after "return"
Loading history...
201
            return str(d)[:-2] + space + unit
202
        else:
203
            return str(d) + space + unit
204
205
    @classmethod
206
    def split_species_micromolar(cls, text: str) -> Tuple[str, Optional[float]]:
207
        """
208
        Splits a name into a chemical/concentration pair, falling back with the full name.
209
        Ex: "abc 3.5uM" → (abc, 3.5)
210
        Ex: "abc 3.5 µM" → (abc, 3.5)
211
        Ex: "abc (3.5mM)" → (abc, 3500.0)
212
        Ex: "abc 3.5mM" → (abc, None)
213
        Ex: "3.5mM" → (3.5mM, None)  # an edge case: don't pass in only units
214
        Uses a moderately strict pattern for the drug and dose:
215
            - The dose must terminate the string, except for end parenthesis or whitespace.
216
            - The drug and dose must be separated by at least one non-alphanumeric, non-dot, non-hyphen character.
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (114/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
217
            - Units must follow the digits, separated by at most whitespace, and are case-sensitive.
218
        """
219
        # note the lazy ops in the first group and in the non-(alphanumeric/dot/dash) separator between the drug and dose
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (121/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
220
        pat = regex.compile(
221
            r"^\s*(.*?)(?:[^A-Za-z0-9.\-]+?[\s(\[{]*(\d+(?:.\d*)?)\s*([mµunpf]M)\s*[)\]}]*)?\s*$",
222
            flags=regex.V1,
223
        )
224
        match = pat.fullmatch(text)
225
        if match is None:
226
            raise StringPatternError(f"Text {text} couldn't be parsed", value=text, pattern=pat)
227
        if match.group(2) is None:
0 ignored issues
show
unused-code introduced by
Unnecessary "else" after "return"
Loading history...
228
            return text.strip(), None
229
        else:
230
            drug = match.group(1).strip("([{)]}")
231
            dose = UnitTools.concentration_to_micromolar(float(match.group(2)), match.group(3))
232
            return drug, dose
233
234
    @classmethod
235
    def extract_micromolar(cls, text: str) -> Optional[float]:
236
        """
237
        Returns what looks like a concentration with units. Accepts one of: mM, µM, uM, nM, pM.
238
        Searches pretty flexibly.
239
        If no matches are found, returns None.
240
        If multiple matches are found, warns and returns None.
241
        """
242
        # we need to make sure mM ex isn't part of a larger name
243
        pat1 = regex.compile(r"(\d+(?:.\d*)?)\s*([mµunpf]M)\s*[)\]}]*", flags=regex.V1)
244
245
        def find(pat):
246
            return {
247
                UnitTools.concentration_to_micromolar(float(match.group(1)), match.group(2))
248
                for match in pat.finditer(text)
249
                if match is not None
250
            }
251
252
        matches = find(pat1)
253
        if len(matches) == 1:
0 ignored issues
show
unused-code introduced by
Unnecessary "elif" after "return"
Loading history...
254
            return next(iter(matches))
255
        elif len(matches) > 1:
256
            logger.warning(f"Found {len(matches)} potential doses: {matches} . Returning None.")
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
257
        return None
258
259
    @classmethod
260
    def concentration_to_micromolar(cls, digits: Union[str, float], units: str) -> float:
261
        """
262
        Ex: concentration_to_micromolar(53, 'nM')  # returns 0.053
263
        """
264
        return float(digits) * {
265
            "M": 1e6,
266
            "mM": 1e3,
267
            "µM": 1,
268
            "uM": 1,
269
            "nM": 1e-3,
270
            "pM": 1e-6,
271
            "fM": 1e-9,
272
        }[units]
273
274
    @classmethod
275
    def canonicalize_quantity(cls, s: str, dimensionality: str) -> Quantity:
0 ignored issues
show
Coding Style Naming introduced by
Argument name "s" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
276
        """
277
        Returns a quantity in reduced units from a magnitude with units.
278
279
        Args:
280
            s: The string to parse; e.g. ``"1 m/s^2"``.
281
               Unit names and symbols permitted, and spaces may be omitted.
282
            dimensionality: The resulting Quantity is check against this;
283
                            e.g. ``"[length]/[meter]^2"``
284
285
        Returns:
286
            a pint ``Quantity``
287
288
        Raise:
289
            PintTypeError: If the dimensionality is inconsistent
290
        """
291
        q = _UNIT_REG.Quantity(s).to_reduced_units()
0 ignored issues
show
Coding Style Naming introduced by
Variable name "q" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
292
        if not q.is_compatible_with(dimensionality):
293
            raise PintTypeError(f"{s} not of dimensionality {dimensionality}")
294
        return q
295
296
297
__all__ = ["UnitTools"]
298