densitty 0.8.2__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
densitty/util.py CHANGED
@@ -1,26 +1,28 @@
1
1
  """Utility functions."""
2
2
 
3
- from bisect import bisect_left
4
- from collections import namedtuple
5
- from decimal import Decimal
3
+ from __future__ import annotations # for pre-Python 3.12 compatibility
4
+
6
5
  import math
7
- from typing import Any, Protocol, Sequence, SupportsFloat
6
+ import typing
8
7
 
8
+ from bisect import bisect_left
9
+ from decimal import Decimal, BasicContext
10
+ from fractions import Fraction
11
+ from typing import Any, Callable, NamedTuple, Sequence
9
12
 
10
- class FloatLike[T](SupportsFloat, Protocol):
11
- """A Protocol that supports the arithmetic ops we require, and can convert to float"""
12
13
 
13
- def __lt__(self, __other: T) -> bool: ...
14
- def __add__(self, __other: Any) -> T: ...
15
- def __sub__(self, __other: Any) -> T: ...
16
- def __mul__(self, __other: Any) -> T: ...
17
- def __truediv__(self, __other: Any) -> T: ...
18
- def __abs__(self) -> T: ...
14
+ # FloatLike and Vec are defined in the stubs file util.pyi for type checking
15
+ # At runtime, define as Any so older Python versions don't choke:
16
+ if not typing.TYPE_CHECKING:
17
+ FloatLike = Any
18
+ Vec = Any
19
19
 
20
20
 
21
- ValueRange = namedtuple("ValueRange", ["min", "max"])
21
+ class ValueRange(NamedTuple):
22
+ """Encapsulates a range from min..max"""
22
23
 
23
- type Vec = Sequence[FloatLike]
24
+ min: Decimal
25
+ max: Decimal
24
26
 
25
27
 
26
28
  def clamp(x, min_x, max_x):
@@ -76,44 +78,73 @@ def nearest(stepwise: Sequence, x: float):
76
78
  return stepwise[clamped_idx]
77
79
 
78
80
 
79
- def decimal_value_range(v: ValueRange | Sequence):
80
- """Produce a ValueRange containing Decimal values"""
81
- return ValueRange(Decimal(v[0]), Decimal(v[1]))
81
+ def make_decimal(x: FloatLike) -> Decimal:
82
+ """Turn a float into a decimal with reasonable precision,
83
+ avoiding things like 1.0000000000000002220446049250313080847263336181640625"""
84
+ if isinstance(x, Decimal):
85
+ return x
86
+ return BasicContext.create_decimal_from_float(float(x))
87
+
88
+
89
+ def make_value_range(v: ValueRange | Sequence[FloatLike]) -> ValueRange:
90
+ """Produce a ValueRange from from something that may be a sequence of FloatLikes"""
91
+ return ValueRange(make_decimal(v[0]), make_decimal(v[1]))
92
+
93
+
94
+ def partial_first(f: Callable[[FloatLike, FloatLike], FloatLike]) -> Callable:
95
+ """Equivalent to functools.partial, but works with Python 3.10"""
96
+
97
+ def out(x: FloatLike):
98
+ return f(x, 0)
99
+
100
+ return out
101
+
102
+
103
+ def partial_second(f: Callable[[FloatLike, FloatLike], FloatLike]) -> Callable:
104
+ """Equivalent to functools.partial, but works with Python 3.10"""
105
+
106
+ def out(x: FloatLike):
107
+ return f(0, x)
108
+
109
+ return out
82
110
 
83
111
 
84
112
  def sfrexp10(value):
85
113
  """Returns sign, base-10 fraction (mantissa), and exponent.
86
- i.e. (s, f, e) such that value = s * f * 10 ** e with 0 <= f < 1.0
114
+ i.e. (s, f, e) such that value = s * f * 10 ** e.
115
+ if f == 0 => value == 0, else 0.1 < f <= 1.0
87
116
  """
88
117
  if value == 0:
89
- return 1, 0, -100
118
+ return 1, Fraction(0), -100
90
119
 
91
- sign = -1 if value < 0 else 1
92
-
93
- v = Decimal(abs(value))
94
- exponent = v.adjusted() + 1
95
- frac = v.scaleb(-exponent) # scale frac's exponent to be 0
120
+ if value < 0:
121
+ sign = -1
122
+ value = -value
123
+ else:
124
+ sign = 1
96
125
 
97
- return sign, frac, exponent
126
+ exp = math.ceil(math.log10(float(value)))
127
+ frac = (Fraction(value) / Fraction(10) ** exp).limit_denominator()
128
+ return sign, frac, exp
98
129
 
99
130
 
100
131
  round_fractions = (
101
- Decimal(1) / Decimal(10),
102
- Decimal(1) / Decimal(8),
103
- Decimal(1) / Decimal(6),
104
- Decimal(1) / Decimal(5),
105
- Decimal(1) / Decimal(4),
106
- Decimal(1) / Decimal(3),
107
- Decimal(2) / Decimal(5),
108
- Decimal(1) / Decimal(2),
109
- Decimal(2) / Decimal(3),
110
- Decimal(4) / Decimal(5),
111
- Decimal(1),
132
+ Fraction(1, 10),
133
+ Fraction(1, 8),
134
+ Fraction(1, 6),
135
+ Fraction(1, 5),
136
+ Fraction(1, 4),
137
+ Fraction(1, 3),
138
+ Fraction(2, 5),
139
+ Fraction(1, 2),
140
+ Fraction(2, 3),
141
+ Fraction(4, 5),
142
+ Fraction(1, 1),
112
143
  )
113
144
 
114
145
 
115
146
  def round_up_ish(value, round_fracs=round_fractions):
116
- """'Round' the value up to the next highest value in 'round_vals' times a multiple of 10
147
+ """'Round' the value up to the next highest value in 'round_fracs' times a multiple of 10
117
148
 
118
149
  Parameters
119
150
  ----------
@@ -121,114 +152,85 @@ def round_up_ish(value, round_fracs=round_fractions):
121
152
  round_vals: the allowable values (mantissa in base 10)
122
153
  return: the closest round_vals[i] * 10**N equal to or larger than 'value'
123
154
  """
124
- sign, frac, exp = sfrexp10(value)
155
+ sign, frac_float, exp = sfrexp10(value)
125
156
 
126
157
  # if we're passed in a float that can't be represented in binary (say 0.1 or 0.2), it will be
127
- # rounded up to the next representable float. Subtract the smallest possible value (ulp) to
128
- # so that when we round up, it can match an exact Decimal("0.1") or such:
129
- frac -= Decimal(math.ulp(frac))
158
+ # rounded up to the next representable float. Adjust to closest sensible fraction:
159
+ frac = Fraction(frac_float).limit_denominator()
130
160
 
131
161
  idx = bisect_left(round_fracs, frac) # find index that this would be inserted before (>= frac)
132
162
  round_frac = round_fracs[idx]
133
163
 
134
- return sign * round_frac.scaleb(exp)
164
+ return sign * round_frac * 10**exp
135
165
 
136
166
 
137
167
  def roundness(value):
138
168
  """Metric for how 'round' a value is. 10 is rounder than 1, is rounder than 1.1."""
139
169
 
140
- # if value is a sequence, combine the roundness of all elements, prioritizing in order:
141
170
  if isinstance(value, Sequence):
142
- out, weight = 0, 1
143
- for v in value:
144
- out += roundness(v) * weight
145
- weight *= 0.99
146
- return out
147
-
171
+ # return the average roundness of all elements, with a bonus for the size of the range
172
+ num = len(value)
173
+
174
+ if num > 1:
175
+ roundnesses = (roundness(v) for v in value)
176
+ mean = sum(roundnesses) / num
177
+ # give a bonus to sets that cover a longer interval
178
+ log_value_range = math.log(value[-1] - value[0])
179
+ else:
180
+ mean = roundness(value[0])
181
+ log_value_range = -1000 # as if we're covering a range of 10^-1000
182
+
183
+ # we want a small bonus to "roundness" if the range is bigger.
184
+ # Keep it small enough that 1..2 won't get expanded to 0.5..2, say
185
+ # log_value_range increases by ~0.3 for every doubling of range
186
+ # which is ~ the penalty for using 1/2 vs 1
187
+ return mean + log_value_range
188
+
189
+ # Just a single value, not a sequence:
148
190
  if value == 0:
149
191
  # 0 is the roundest value
150
192
  return 1000 # equivalent to roundness of 1e1000
151
- _, frac, exp = sfrexp10(value)
152
193
 
153
- round_frac = round(frac, 5) # round to specific # of digits so we can interpret as fraction
194
+ if value < 0:
195
+ value = -value
196
+
197
+ exp = math.ceil(math.log10(float(value)))
198
+ frac = (Fraction(value) / Fraction(10) ** exp).limit_denominator()
199
+ # so frac is 1 for a multiple of 10, and <1 for non-multiples of 10
200
+
201
+ # penalties based on the denominator of 'frac' when expressed as a ratio:
154
202
  penalties = {
155
- 1.00000: 0.0, # no penalty for multiples of 10
156
- 0.50000: 0.5, # penalty for multiple of 5 vs multiple of 10
157
- 0.25000: 0.6, # penalty for multiple of 4 vs multiple of 10
158
- 0.75000: 0.6, #
159
- Decimal("0.33333"): 0.7, # penalty for multiple of 3 vs multiple of 10
160
- Decimal("0.66667"): 0.7, #
161
- 0.12500: 0.8, # penalty for multiple of 8 vs multiple of 10
162
- 0.37500: 0.8,
163
- 0.62500: 0.8,
164
- 0.87500: 0.8,
203
+ 1: 0, # value is power of 10 (1eX)
204
+ 2: 0.3, # value is 5.0eX
205
+ 5: 0.5, # value is 2/4/6/8.0 eX
206
+ 10: 1, # x.1, x.3, x.7, x.9 eX
207
+ 4: 1, # x.25, x.75 eX
208
+ 3: 1.2, # x.333, x.666 TODO: figure out how these are being printed and limit digits
209
+ 20: 1.2, # x.05, x.15, x.35...
210
+ 25: 1.8, # x.04, x.08, ...
211
+ 50: 1.8, # x.02, x.06, ...
212
+ 100: 2, # x.01, ...
165
213
  }
166
214
 
167
- if round_frac in penalties:
168
- return exp - penalties[round_frac]
215
+ if frac.denominator in penalties:
216
+ return exp - penalties[frac.denominator]
169
217
 
170
- # Ouch: our fractional part is just not nice, so maximally un-round:
171
- return -1000 # equivalent to roundness of 1e-1000
218
+ # In case we have ticks like 1.001, 1.002, 1.003, try to account for number of digits required:
219
+ max_digits = 10
220
+ for digits in range(2, max_digits):
221
+ if 10**digits % frac.denominator == 0:
222
+ return exp - digits
172
223
 
224
+ return exp - max_digits
173
225
 
174
- def most_round(values):
175
- """Pick the most round of the input values. Ties go to the earliest."""
176
- best_r = -1e100
177
- best_v = 0
178
- for v in values:
179
- r = roundness(v)
180
- if r > best_r:
181
- best_r, best_v = r, v
182
- return best_v
183
226
 
227
+ def roundness_ordered(values):
228
+ """Returns values in order of decreasing roundness"""
229
+ d = {roundness(v): v for v in values}
230
+ for r in reversed(sorted(d)):
231
+ yield d[r]
184
232
 
185
- def pick_step_size(value_range, num_steps_hint, min_steps_per_label=1) -> tuple[Decimal, Decimal]:
186
- """Try to pick a step size that gives nice round values for step positions.
187
- For coming up with nice tick positions for an axis, and with nice bin sizes for binning.
188
- For an axis, it is also useful to produce an interval between labeled ticks.
189
233
 
190
- Parameters
191
- ----------
192
- value_range: bounds of interval
193
- num_steps_hint: approximate number of steps desired for the interval
194
- min_steps_per_label: for use with axis/label generation, as labels take more space than ticks
195
- return: step size, interval between labeled steps/ticks
196
- """
197
- num_steps_hint = max(1, num_steps_hint)
198
- # if steps are 0,1,2,3,4,5,6... or 0,2,4,6,8,10,... steps_per_label of 5 is sensible,
199
- # if steps are 0,5,10,15,20,... steps_per_label of 4 is sensible
200
- nominal_step = (value_range.max - value_range.min) / num_steps_hint
201
-
202
- # Figure out the order-of-magnitude (power of 10), aka "decade" of the steps:
203
- log_nominal = math.log10(nominal_step)
204
- log_decade = math.floor(log_nominal) # i.e. # of digits
205
- decade = Decimal(10) ** log_decade
206
-
207
- # Now figure out where in that decade we are, so we can pick the closest 1/2/5 value
208
- log_frac = log_nominal - log_decade # remainder after decade taken out
209
- frac = 10**log_frac # i.e. fraction through the decade (shift decimal point to front)
210
-
211
- # common-case: label every or every-other, or every 5th, or every 10th
212
- if min_steps_per_label <= 2:
213
- steps_per_label = min_steps_per_label
214
- elif min_steps_per_label <= 5:
215
- steps_per_label = 5
216
- else:
217
- steps_per_label = max(min_steps_per_label, 10)
218
-
219
- if frac < 1.1:
220
- step = decade
221
- elif frac < 2.2:
222
- step = 2 * decade
223
- # Steps of .2, don't label every other one
224
- if steps_per_label == 2:
225
- steps_per_label = 5
226
- elif frac < 5.5:
227
- step = 5 * decade
228
- # ticks every .5, don't label every 5th
229
- if steps_per_label == 5:
230
- steps_per_label = max(round(min_steps_per_label / 2) * 2, 6)
231
- else:
232
- step = 10 * decade
233
-
234
- return step, step * steps_per_label
234
+ def most_round(values):
235
+ """Pick the most round of the input values."""
236
+ return next(roundness_ordered(values))
densitty/util.pyi ADDED
@@ -0,0 +1,37 @@
1
+ from decimal import Decimal
2
+ from typing import Any, Callable, NamedTuple, Protocol, Sequence, SupportsFloat
3
+
4
+ class FloatLike[T](SupportsFloat, Protocol):
5
+ def __lt__(self, __other: Any) -> bool: ...
6
+ def __le__(self, __other: Any) -> bool: ...
7
+ def __add__(self, __other: Any) -> T: ...
8
+ def __sub__(self, __other: Any) -> T: ...
9
+ def __radd__(self, __other: Any) -> T: ...
10
+ def __rsub__(self, __other: Any) -> T: ...
11
+ def __mul__(self, __other: Any) -> T: ...
12
+ def __rmul__(self, __other: Any) -> T: ...
13
+ def __truediv__(self, __other: Any) -> T: ...
14
+ def __abs__(self) -> T: ...
15
+
16
+ class ValueRange(NamedTuple):
17
+ min: Decimal
18
+ max: Decimal
19
+
20
+ type Vec = Sequence[FloatLike]
21
+
22
+ def clamp(x, min_x, max_x): ...
23
+ def clamp_rgb(rgb): ...
24
+ def interp(piecewise: Sequence[Vec], x: float) -> Vec: ...
25
+ def nearest(stepwise: Sequence, x: float): ...
26
+ def make_decimal(x: FloatLike) -> Decimal: ...
27
+ def make_value_range(v: ValueRange | Sequence[FloatLike]) -> ValueRange: ...
28
+ def partial_first(f: Callable[[FloatLike, FloatLike], FloatLike]) -> Callable: ...
29
+ def partial_second(f: Callable[[FloatLike, FloatLike], FloatLike]) -> Callable: ...
30
+ def sfrexp10(value): ...
31
+
32
+ round_fractions: tuple[Decimal, ...]
33
+
34
+ def round_up_ish(value, round_fracs=...): ...
35
+ def roundness(value): ...
36
+ def roundness_ordered(values): ...
37
+ def most_round(values): ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: densitty
3
- Version: 0.8.2
3
+ Version: 0.9.0
4
4
  Summary: densitty - create textual 2-D density plots, heatmaps, and 2-D histograms in Python
5
5
  Author: Bill Tompkins
6
6
  License-Expression: MIT
@@ -11,11 +11,14 @@ Classifier: Environment :: Console
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
14
17
  Classifier: Programming Language :: Python :: 3.13
15
18
  Classifier: Programming Language :: Python :: 3.14
16
19
  Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
17
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
- Requires-Python: >=3.12
21
+ Requires-Python: >=3.10
19
22
  Description-Content-Type: text/markdown
20
23
  License-File: LICENSE
21
24
  Dynamic: license-file
@@ -33,4 +36,4 @@ Generate 2-D histograms (density plots, heat maps, eye diagrams) similar to [mat
33
36
 
34
37
  ## [Color, Size, and Glyph Support](https://billtompkins.github.io/densitty/docs/terminal_support.html)
35
38
 
36
- ## API (TODO)
39
+ ## [API](https://billtompkins.github.io/densitty/docs/api.html)
@@ -0,0 +1,17 @@
1
+ densitty/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ densitty/ansi.py,sha256=YHagUsCwbyNlOkV2pXJuxolWwjvs1E4hxMnJaohqTfE,3661
3
+ densitty/ascii_art.py,sha256=-MUppQkEeCAqSxdLCfuDRD1NKtzSrJAt2WdJEL56_fI,589
4
+ densitty/axis.py,sha256=gI0xNKkYfINAEcYtqCu4K0EsWKPyBjc24CpK_1UvcLA,15637
5
+ densitty/binning.py,sha256=KfQ0_Z_FdZoNYY4RYNKuaiQy0wJ78MI3PSPCxoE2yyk,12117
6
+ densitty/detect.py,sha256=oMb8oPWjZJksJMP0yNdmSn7OmiMcfDkk4owCMvJwcbc,19796
7
+ densitty/lineart.py,sha256=Qkw_F1QEK9yd0ttNjg62bomIavMPza3pk8w7DA85zWs,4124
8
+ densitty/plot.py,sha256=pmxJA8l2xVVaUEeo3DI38WxXT-QSROyCrJ9kN_c0ZKE,8473
9
+ densitty/smoothing.py,sha256=8F4pWA5HS07Y2jDgJAY-cPSqa0uiBiqav_MtLjuiymQ,12348
10
+ densitty/truecolor.py,sha256=6UDLJRUKP8rProemJyfSIgtZ5IdB1v8R_PV41fW6CjA,6016
11
+ densitty/util.py,sha256=PW4KkxuFvlQFmlDgN4yxt6xV_PIAEBstGYYVhTr6mwQ,7304
12
+ densitty/util.pyi,sha256=ZaAduJYuysj2lrD9UVFHlSlDPySMnWVEc6nEkBKko5c,1361
13
+ densitty-0.9.0.dist-info/licenses/LICENSE,sha256=LexlQlxS7F07WxcVOOmZAZ_3vReYv0cM8Zg6pTx7_fI,1073
14
+ densitty-0.9.0.dist-info/METADATA,sha256=XNCk5nlIe5LBU9-krHRisAMyvVi6n0KTaZVDpq1v174,1849
15
+ densitty-0.9.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
+ densitty-0.9.0.dist-info/top_level.txt,sha256=Q50fHzFeZkhO_61VVAIJZyKU44Upacx_blojlLpYqNo,9
17
+ densitty-0.9.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- densitty/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- densitty/ansi.py,sha256=YHagUsCwbyNlOkV2pXJuxolWwjvs1E4hxMnJaohqTfE,3661
3
- densitty/ascii_art.py,sha256=-MUppQkEeCAqSxdLCfuDRD1NKtzSrJAt2WdJEL56_fI,589
4
- densitty/axis.py,sha256=XANbgvOltS0XhUvUB9dfUBvybEfOmcjI5xGc2NZDgak,10120
5
- densitty/binning.py,sha256=LioKw7A0xK_lzUEY2tINiL4fySxVNllxrgh2bD4T9AA,9549
6
- densitty/detect.py,sha256=LBRUDHxpqLq3p0zRUNS7-3YCBSyclfM0vVy0ZmzSVNs,17725
7
- densitty/lineart.py,sha256=Qkw_F1QEK9yd0ttNjg62bomIavMPza3pk8w7DA85zWs,4124
8
- densitty/plot.py,sha256=dxdPRensTxWd9CcD2SbekqAe0iOMcKSKR24wrNc5aCw,8340
9
- densitty/truecolor.py,sha256=uSrT4Qm0T0ZFwXxm1oyNTrdNCDO7ZfUTI687bX5r-8I,5990
10
- densitty/util.py,sha256=U0B5RSxQOdq5M6no3OC_buZizAcVjTPwwqKvk4QJ5Rc,7888
11
- densitty-0.8.2.dist-info/licenses/LICENSE,sha256=LexlQlxS7F07WxcVOOmZAZ_3vReYv0cM8Zg6pTx7_fI,1073
12
- densitty-0.8.2.dist-info/METADATA,sha256=QK1lEOpas0eZ86enDAObh3bxuS1GoQ_Gmk5X2c6lgpg,1646
13
- densitty-0.8.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- densitty-0.8.2.dist-info/top_level.txt,sha256=Q50fHzFeZkhO_61VVAIJZyKU44Upacx_blojlLpYqNo,9
15
- densitty-0.8.2.dist-info/RECORD,,