Coverage for src/lsqfitgp/_gvarext/_format.py: 95%
141 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-04 11:43 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-04 11:43 +0000
1# lsqfitgp/_gvarext/_format.py
2#
3# Copyright (c) 2023, 2024, 2026, 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 contextlib 1cba
21import math 1cba
22import re 1cba
24import gvar 1cba
27def exponent(x): 1cba
28 return math.floor(math.log10(abs(x))) 1ba
31def int_mantissa(x, n, e): 1cba
32 return round(x * 10 ** (n - 1 - e)) 1ba
35def naive_ndigits(x, n): 1cba
36 log10x = math.log10(abs(x)) 1ba
37 n_int = math.floor(n) 1ba
38 n_frac = n - n_int 1ba
39 log10x_frac = log10x - math.floor(log10x) 1ba
40 return n_int + (log10x_frac < n_frac) 1ba
43def ndigits(x, n): 1cba
44 ndig = naive_ndigits(x, n) 1ba
45 xexp = exponent(x) 1ba
46 rounded_x = int_mantissa(x, ndig, xexp) * 10**xexp 1ba
47 if rounded_x > x: 1ba
48 rounded_ndig = naive_ndigits(rounded_x, n) 1ba
49 if rounded_ndig > ndig: 1ba
50 x = rounded_x 1a
51 ndig = rounded_ndig 1a
52 return x, ndig 1ba
55def mantissa(x, n, e): 1cba
56 m = int_mantissa(x, n, e) 1ba
57 s = str(abs(int(m))) 1ba
58 assert len(s) == n or len(s) == n + 1 or (m == 0 and n < 0) 1ba
59 if n >= 1 and len(s) == n + 1: 1ba
60 e = e + 1 1a
61 s = s[:-1] 1a
62 return s, e 1ba
65def insert_dot(s, n, e, *, add_leading_zeros=True, trailing_zero_char='0'): 1cba
66 e = e + len(s) - n 1ba
67 n = len(s) 1ba
68 if e >= n - 1: 1ba
69 s = s + trailing_zero_char * (e - n + 1) 1a
70 elif e >= 0: 1ba
71 s = s[: 1 + e] + '.' + s[1 + e :] 1ba
72 elif e <= -1 and add_leading_zeros: 1ba
73 s = '0' * -e + s 1ba
74 s = s[:1] + '.' + s[1:] 1ba
75 return s 1ba
78def tostring(x): 1cba
79 return '0' if x == 0 else f'{x:#.6g}' 1a
82def uformat( # noqa: C901, PLR0915 1cba
83 mu,
84 s,
85 errdig=2,
86 sep=None,
87 *,
88 shareexp=True,
89 outersign=False,
90 uniexp=False,
91 minnegexp=6,
92 minposexp=4,
93 padzero=None,
94 possign=False,
95):
96 """
97 Format a number with uncertainty.
99 Parameters
100 ----------
101 mu : number
102 The central value.
103 s : number
104 The error.
105 errdig : number
106 The number of digits of the error to be shown. Must be >= 1. It can be
107 a noninteger, in which case the number of digits switches between the
108 lower nearest integer to the upper nearest integer as the first decimal
109 digit (after rounding) crosses 10 raised to the fractional part of
110 `errdig`. Default 1.5.
111 sep : None or str
112 The separator put between the central value and the error. Eventual
113 spaces must be included. If None, put the error between parentheses,
114 sharing decimal places/exponential notation with the central value.
115 Default None.
116 shareexp : bool, default True
117 Applies if `sep` is not ``None``. When using exponential notation,
118 whether to share the exponent between central value and error with outer
119 parentheses.
120 outersign : bool
121 Applied when sep is not None and shareexp is True. Whether to put the
122 sign outside or within the parentheses. Default False
123 uniexp : bool
124 When using exponential notation, whether to use unicode characters
125 instead of the standard ASCII notation. Default False.
126 minnegexp : int
127 The number of places after the comma at which the notation switches
128 to exponential notation. Default 4. The number of places from the
129 greater between central value and error is considered.
130 minposexp : int
131 The power of ten of the least significant digit at which exponential
132 notation is used. Default 0. Setting higher values may force padding
133 the error with zeros, depending on `errdig`.
134 padzero : str, optional
135 If provided, a character representing 0 to pad with when not using
136 exponential notation due to `minposexp` even if the least significant
137 digit is not on the units, instead of showing more actual digits than
138 those specified.
139 possign : bool, default False
140 Whether to put a `+` before the central value when it is positive.
142 Returns
143 -------
144 r : str
145 The quantity (mu +/- s) nicely formatted.
146 """
147 if errdig < 1: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true1ba
148 msg = 'errdig < 1'
149 raise ValueError(msg)
150 if not math.isfinite(mu) or not math.isfinite(s) or s <= 0: 1ba
151 if sep is None: 1a
152 return f'{tostring(mu)}({tostring(s)})' 1a
153 else:
154 return f'{tostring(mu)}{sep}{tostring(s)}' 1a
156 s, sndig = ndigits(s, errdig) 1ba
157 sexp = exponent(s) 1ba
158 muexp = exponent(mu) if mu != 0 else sexp - sndig - 1 1ba
159 smant, sexp = mantissa(s, sndig, sexp) 1ba
160 mundig = sndig + muexp - sexp 1ba
161 mumant, muexp = mantissa(mu, mundig, muexp) 1ba
162 musign = '-' if mu < 0 else '+' if possign else '' 1ba
164 if mundig >= sndig: 1ba
165 use_exp = muexp >= mundig + minposexp or muexp <= -minnegexp 1ba
166 base_exp = muexp 1ba
167 else:
168 use_exp = sexp >= sndig + minposexp or sexp <= -minnegexp 1a
169 base_exp = sexp 1a
171 if use_exp: 1ba
172 mumant = insert_dot(mumant, mundig, muexp - base_exp) 1a
173 smant = insert_dot( 1a
174 smant, sndig, sexp - base_exp, add_leading_zeros=sep is not None
175 )
176 elif base_exp >= max(mundig, sndig) and padzero is None: 1ba
177 mumant = str(abs(round(mu))) 1a
178 smant = str(abs(round(s))) 1a
179 else:
180 zerochar = '0' if padzero is None else padzero 1ba
181 mumant = insert_dot(mumant, mundig, muexp, trailing_zero_char=zerochar) 1ba
182 if ( 1ba
183 len(mumant) >= 2
184 and mumant.startswith('0')
185 and all(c == zerochar for c in mumant[1:])
186 ):
187 mumant = zerochar + mumant[1:] 1a
188 smant = insert_dot( 1ba
189 smant,
190 sndig,
191 sexp,
192 add_leading_zeros=sep is not None,
193 trailing_zero_char=zerochar,
194 )
196 if not outersign: 1ba
197 mumant = musign + mumant 1ba
199 if use_exp: 1ba
200 if uniexp: 1a
201 asc = '0123456789+-' 1a
202 uni = '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻' 1a
203 table = str.maketrans(asc, uni) 1a
204 exp = str(base_exp).translate(table) 1a
205 suffix = '×10' + exp 1a
206 else:
207 suffix = f'e{base_exp:+}' 1a
208 if sep is None: 1a
209 r = mumant + '(' + smant + ')' + suffix 1a
210 elif shareexp: 1a
211 r = '(' + mumant + sep + smant + ')' + suffix 1a
212 else:
213 r = mumant + suffix + sep + smant + suffix 1a
214 elif sep is None: 1ba
215 r = mumant + '(' + smant + ')' 1ba
216 else:
217 r = mumant + sep + smant 1a
219 if outersign: 1ba
220 r = musign + r 1a
222 return r 1ba
225def fmtspec_kwargs(spec): 1cba
226 """
227 Parse a string formatting pattern to be used with `uformat`.
229 Parameters
230 ----------
231 spec : str
232 The format specification. It must follow the format
234 [options](error digits)[:minimum exponent](mode)
236 where brackets indicate optional parts.
238 Returns
239 -------
240 kwargs : dict
241 The keyword arguments to be passed to `uformat`.
243 Notes
244 -----
245 Full format:
247 Options: any combination these characters:
249 '+' :
250 Put a '+' before positive central values.
251 '-' :
252 Put the sign outside the parentheses used to group the central value and
253 error mantissas in exponential notation.
254 '#' :
255 Do not show non-significative digits at all costs, replacing them with
256 lowercase 'o', representing a rounding 0 rather than a significative 0.
257 '$' :
258 In exponential notation, repeat the exponent for the central value and
259 error.
261 Error digits: a decimal number expressing the number of leading error digits
262 to show. Non-integer values indicate that the number of digits switches from
263 the floor to the ceil at some value of the mantissa.
265 Minimum exponent: a decimal number expressing the minimum exponent at which
266 exponential notation is used.
268 Mode: one of these characters:
270 'p' :
271 Put the error between parentheses.
272 's' :
273 Separate the central value from the error with '+/-'.
274 'u' :
275 Separate the central value from the error with '±'.
276 'U' :
277 Separate the central value from the error with '±', and use unicode
278 superscript characters for exponential notation.
279 """
280 pat = r'([-+#$]*)(\d*\.?\d*)(:\d+)?(p|s|u|U)' 1ba
281 m = re.fullmatch(pat, spec) 1ba
282 if not m: 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true1ba
283 msg = f'format specification {spec!r} not understood, format is r"{pat}"'
284 raise ValueError(msg)
285 kw = {} 1ba
286 options = m.group(1) 1ba
287 kw['possign'] = '+' in options 1ba
288 kw['outersign'] = '-' in options 1ba
289 kw['padzero'] = 'o' if '#' in options else None 1ba
290 kw['shareexp'] = '$' not in options 1ba
291 if m.group(2): 1ba
292 kw['errdig'] = float(m.group(2)) 1ba
293 else:
294 kw['errdig'] = 1.5 1a
295 if m.group(3): 1ba
296 nexp = int(m.group(3)[1:]) 1a
297 else:
298 nexp = 5 1ba
299 kw['minposexp'] = max(0, nexp - math.floor(kw['errdig'])) 1ba
300 kw['minnegexp'] = nexp 1ba
301 mode = m.group(4) 1ba
302 kw['sep'] = dict(p=None, s=' +/- ', u=' ± ', U=' ± ')[mode] 1ba
303 kw['uniexp'] = mode == 'U' 1ba
304 return kw 1ba
307def gvar_formatter(g, spec): 1cba
308 """Format a gvar with `uformat`, as formatter for `gvar.GVar.set`."""
309 mu = gvar.mean(g) 1ba
310 s = gvar.sdev(g) 1ba
311 kw = fmtspec_kwargs(spec) 1ba
312 return uformat(mu, s, **kw) 1ba
315@contextlib.contextmanager 1cba
316def gvar_format(spec=None, *, lsqfitgp_format=True): 1cba
317 """
318 Context manager to set the default format specification of gvars.
320 Parameters
321 ----------
322 spec : str, optional
323 The format specification. If not specified and `lsqfitgp_format` is
324 ``True``, use ``'#1.5p'``.
325 lsqfitgp_format : bool, default True
326 Whether to use a modified version of the `gvar` formatting
327 specification, provided by `lsqfitgp`.
329 See Also
330 --------
331 gvar.fmt, gvar.GVar.set
333 Notes
334 -----
335 See `fmtspec_kwargs` for the format specification, and `uformat` for all
336 details.
337 """ # noqa: DOC402
338 if lsqfitgp_format: 338 ↛ 349line 338 didn't jump to line 349 because the condition on line 338 was always true1ba
339 if spec is None: 1ba
340 spec = '#1.5p' 1b
342 def formatter(g, spec, defaultspec=spec): 1ba
343 if spec == '': 343 ↛ 345line 343 didn't jump to line 345 because the condition on line 343 was always true1ba
344 spec = defaultspec 1ba
345 return gvar_formatter(g, spec) 1ba
347 kw = dict(formatter=formatter) 1ba
348 else:
349 kw = {} if spec is None else dict(default_format=spec)
350 try: 1ba
351 old_settings = gvar.GVar.set(**kw) 1ba
352 yield 1ba
353 finally:
354 gvar.GVar.set(**old_settings) 1ba