Coverage for src/lsqfitgp/_gvarext/_format.py: 96%
139 statements
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-15 19:54 +0000
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-15 19:54 +0000
1# lsqfitgp/_gvarext/_format.py
2#
3# Copyright (c) 2023, 2024, Giacomo Petrillo
4#
5# This file is part of lsqfitgp.
6#
7# lsqfitgp is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# lsqfitgp is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with lsqfitgp. If not, see <http://www.gnu.org/licenses/>.
20import math 1feabcd
21import re 1feabcd
22import contextlib 1feabcd
24import gvar 1feabcd
26def exponent(x): 1feabcd
27 return int(math.floor(math.log10(abs(x)))) 1eabcd
29def int_mantissa(x, n, e): 1feabcd
30 return round(x * 10 ** (n - 1 - e)) 1eabcd
32def naive_ndigits(x, n): 1feabcd
33 log10x = math.log10(abs(x)) 1eabcd
34 n_int = int(math.floor(n)) 1eabcd
35 n_frac = n - n_int 1eabcd
36 log10x_frac = log10x - math.floor(log10x) 1eabcd
37 return n_int + (log10x_frac < n_frac) 1eabcd
39def ndigits(x, n): 1feabcd
40 ndig = naive_ndigits(x, n) 1eabcd
41 xexp = exponent(x) 1eabcd
42 rounded_x = int_mantissa(x, ndig, xexp) * 10 ** xexp 1eabcd
43 if rounded_x > x: 1eabcd
44 rounded_ndig = naive_ndigits(rounded_x, n) 1eabcd
45 if rounded_ndig > ndig: 1eabcd
46 x = rounded_x 1abcd
47 ndig = rounded_ndig 1abcd
48 return x, ndig 1eabcd
50def mantissa(x, n, e): 1feabcd
51 m = int_mantissa(x, n, e) 1eabcd
52 s = str(abs(int(m))) 1eabcd
53 assert len(s) == n or len(s) == n + 1 or (m == 0 and n < 0) 1eabcd
54 if n >= 1 and len(s) == n + 1: 1eabcd
55 e = e + 1 1abcd
56 s = s[:-1] 1abcd
57 return s, e 1eabcd
59def insert_dot(s, n, e, *, add_leading_zeros=True, trailing_zero_char='0'): 1feabcd
60 e = e + len(s) - n 1eabcd
61 n = len(s) 1eabcd
62 if e >= n - 1: 1eabcd
63 s = s + trailing_zero_char * (e - n + 1) 1abcd
64 elif e >= 0: 1eabcd
65 s = s[:1 + e] + '.' + s[1 + e:] 1eabcd
66 elif e <= -1 and add_leading_zeros: 1eabcd
67 s = '0' * -e + s 1eabcd
68 s = s[:1] + '.' + s[1:] 1eabcd
69 return s 1eabcd
71def tostring(x): 1feabcd
72 return '0' if x == 0 else f'{x:#.6g}' 1abcd
74def uformat(mu, s, errdig=2, sep=None, *, 1feabcd
75 shareexp=True,
76 outersign=False,
77 uniexp=False,
78 minnegexp=6,
79 minposexp=4,
80 padzero=None,
81 possign=False,
82 ):
83 """
84 Format a number with uncertainty.
86 Parameters
87 ----------
88 mu : number
89 The central value.
90 s : number
91 The error.
92 errdig : number
93 The number of digits of the error to be shown. Must be >= 1. It can be
94 a noninteger, in which case the number of digits switches between the
95 lower nearest integer to the upper nearest integer as the first decimal
96 digit (after rounding) crosses 10 raised to the fractional part of
97 `errdig`. Default 1.5.
98 sep : None or str
99 The separator put between the central value and the error. Eventual
100 spaces must be included. If None, put the error between parentheses,
101 sharing decimal places/exponential notation with the central value.
102 Default None.
103 shareexp : bool, default True
104 Applies if `sep` is not ``None``. When using exponential notation,
105 whether to share the exponent between central value and error with outer
106 parentheses.
107 outersign : bool
108 Applied when sep is not None and shareexp is True. Whether to put the
109 sign outside or within the parentheses. Default False
110 uniexp : bool
111 When using exponential notation, whether to use unicode characters
112 instead of the standard ASCII notation. Default False.
113 minnegexp : int
114 The number of places after the comma at which the notation switches
115 to exponential notation. Default 4. The number of places from the
116 greater between central value and error is considered.
117 minposexp : int
118 The power of ten of the least significant digit at which exponential
119 notation is used. Default 0. Setting higher values may force padding
120 the error with zeros, depending on `errdig`.
121 padzero : str, optional
122 If provided, a character representing 0 to pad with when not using
123 exponential notation due to `minposexp` even if the least significant
124 digit is not on the units, instead of showing more actual digits than
125 those specified.
126 possign : bool, default False
127 Whether to put a `+` before the central value when it is positive.
129 Returns
130 -------
131 r : str
132 The quantity (mu +/- s) nicely formatted.
133 """
134 if errdig < 1: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true1eabcd
135 raise ValueError('errdig < 1')
136 if not math.isfinite(mu) or not math.isfinite(s) or s <= 0: 1eabcd
137 if sep is None: 1abcd
138 return f'{tostring(mu)}({tostring(s)})' 1abcd
139 else:
140 return f'{tostring(mu)}{sep}{tostring(s)}' 1abcd
142 s, sndig = ndigits(s, errdig) 1eabcd
143 sexp = exponent(s) 1eabcd
144 muexp = exponent(mu) if mu != 0 else sexp - sndig - 1 1eabcd
145 smant, sexp = mantissa(s, sndig, sexp) 1eabcd
146 mundig = sndig + muexp - sexp 1eabcd
147 mumant, muexp = mantissa(mu, mundig, muexp) 1eabcd
148 musign = '-' if mu < 0 else '+' if possign else '' 1eabcd
150 if mundig >= sndig: 1eabcd
151 use_exp = muexp >= mundig + minposexp or muexp <= -minnegexp 1eabcd
152 base_exp = muexp 1eabcd
153 else:
154 use_exp = sexp >= sndig + minposexp or sexp <= -minnegexp 1abcd
155 base_exp = sexp 1abcd
157 if use_exp: 1eabcd
158 mumant = insert_dot(mumant, mundig, muexp - base_exp) 1abcd
159 smant = insert_dot(smant, sndig, sexp - base_exp, add_leading_zeros=sep is not None) 1abcd
160 elif base_exp >= max(mundig, sndig) and padzero is None: 1eabcd
161 mumant = str(abs(round(mu))) 1abcd
162 smant = str(abs(round(s))) 1abcd
163 else:
164 zerochar = '0' if padzero is None else padzero 1eabcd
165 mumant = insert_dot(mumant, mundig, muexp, trailing_zero_char=zerochar) 1eabcd
166 if len(mumant) >= 2 and mumant.startswith('0') and all(c == zerochar for c in mumant[1:]): 1eabcd
167 mumant = zerochar + mumant[1:] 1abcd
168 smant = insert_dot(smant, sndig, sexp, add_leading_zeros=sep is not None, trailing_zero_char=zerochar) 1eabcd
170 if not outersign: 1eabcd
171 mumant = musign + mumant 1eabcd
173 if use_exp: 1eabcd
174 if uniexp: 1abcd
175 asc = '0123456789+-' 1abcd
176 uni = '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻' 1abcd
177 table = str.maketrans(asc, uni) 1abcd
178 exp = str(base_exp).translate(table) 1abcd
179 suffix = '×10' + exp 1abcd
180 else:
181 suffix = f'e{base_exp:+}' 1abcd
182 if sep is None: 1abcd
183 r = mumant + '(' + smant + ')' + suffix 1abcd
184 elif shareexp: 1abcd
185 r = '(' + mumant + sep + smant + ')' + suffix 1abcd
186 else:
187 r = mumant + suffix + sep + smant + suffix 1abcd
188 elif sep is None: 1eabcd
189 r = mumant + '(' + smant + ')' 1eabcd
190 else:
191 r = mumant + sep + smant 1abcd
193 if outersign: 1eabcd
194 r = musign + r 1abcd
196 return r 1eabcd
198def fmtspec_kwargs(spec): 1feabcd
199 """
200 Parse a string formatting pattern to be used with `uformat`.
202 Parameters
203 ----------
204 spec : str
205 The format specification. It must follow the format
207 [options](error digits)[:minimum exponent](mode)
209 where brackets indicate optional parts.
211 Returns
212 -------
213 kwargs : dict
214 The keyword arguments to be passed to `uformat`.
216 Notes
217 -----
218 Full format:
220 Options: any combination these characters:
222 '+' :
223 Put a '+' before positive central values.
224 '-' :
225 Put the sign outside the parentheses used to group the central value and
226 error mantissas in exponential notation.
227 '#' :
228 Do not show non-significative digits at all costs, replacing them with
229 lowercase 'o', representing a rounding 0 rather than a significative 0.
230 '$' :
231 In exponential notation, repeat the exponent for the central value and
232 error.
234 Error digits: a decimal number expressing the number of leading error digits
235 to show. Non-integer values indicate that the number of digits switches from
236 the floor to the ceil at some value of the mantissa.
238 Minimum exponent: a decimal number expressing the minimum exponent at which
239 exponential notation is used.
241 Mode: one of these characters:
243 'p' :
244 Put the error between parentheses.
245 's' :
246 Separate the central value from the error with '+/-'.
247 'u' :
248 Separate the central value from the error with '±'.
249 'U' :
250 Separate the central value from the error with '±', and use unicode
251 superscript characters for exponential notation.
252 """
253 pat = r'([-+#$]*)(\d*\.?\d*)(:\d+)?(p|s|u|U)' 1eabcd
254 m = re.fullmatch(pat, spec) 1eabcd
255 if not m: 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true1eabcd
256 raise ValueError(f'format specification {spec!r} not understood, format is r"{pat}"')
257 kw = {} 1eabcd
258 options = m.group(1) 1eabcd
259 kw['possign'] = '+' in options 1eabcd
260 kw['outersign'] = '-' in options 1eabcd
261 kw['padzero'] = 'o' if '#' in options else None 1eabcd
262 kw['shareexp'] = '$' not in options 1eabcd
263 if m.group(2): 1eabcd
264 kw['errdig'] = float(m.group(2)) 1eabcd
265 else:
266 kw['errdig'] = 1.5 1abcd
267 if m.group(3): 1eabcd
268 nexp = int(m.group(3)[1:]) 1abcd
269 else:
270 nexp = 5 1eabcd
271 kw['minposexp'] = max(0, nexp - math.floor(kw['errdig'])) 1eabcd
272 kw['minnegexp'] = nexp 1eabcd
273 mode = m.group(4) 1eabcd
274 kw['sep'] = dict(p=None, s=' +/- ', u=' ± ', U=' ± ')[mode] 1eabcd
275 kw['uniexp'] = mode == 'U' 1eabcd
276 return kw 1eabcd
278def gvar_formatter(g, spec): 1feabcd
279 """
280 A formatter for `gvar.GVar.set` that uses `uformat`.
281 """
282 mu = gvar.mean(g) 1eabcd
283 s = gvar.sdev(g) 1eabcd
284 kw = fmtspec_kwargs(spec) 1eabcd
285 return uformat(mu, s, **kw) 1eabcd
287@contextlib.contextmanager 1feabcd
288def gvar_format(spec=None, *, lsqfitgp_format=True): 1feabcd
289 """
290 Context manager to set the default format specification of gvars.
292 Parameters
293 ----------
294 spec : str, optional
295 The format specification. If not specified and `lsqfitgp_format` is
296 ``True``, use ``'#1.5p'``.
297 lsqfitgp_format : bool, default True
298 Whether to use a modified version of the `gvar` formatting
299 specification, provided by `lsqfitgp`.
301 Notes
302 -----
303 See `fmtspec_kwargs` for the format specification, and `uformat` for all
304 details.
306 See also
307 --------
308 gvar.fmt, gvar.GVar.set
309 """
310 if lsqfitgp_format: 310 ↛ 319line 310 didn't jump to line 319 because the condition on line 310 was always true1eabcd
311 if spec is None: 1eabcd
312 spec = '#1.5p' 1e
313 def formatter(g, spec, defaultspec=spec): 1eabcd
314 if spec == '': 314 ↛ 316line 314 didn't jump to line 316 because the condition on line 314 was always true1eabcd
315 spec = defaultspec 1eabcd
316 return gvar_formatter(g, spec) 1eabcd
317 kw = dict(formatter=formatter) 1eabcd
318 else:
319 kw = {} if spec is None else dict(default_format=spec)
320 try: 1eabcd
321 old_settings = gvar.GVar.set(**kw) 1eabcd
322 yield 1eabcd
323 finally:
324 gvar.GVar.set(**old_settings) 1eabcd