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

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/>. 

19 

20import contextlib 1cba

21import math 1cba

22import re 1cba

23 

24import gvar 1cba

25 

26 

27def exponent(x): 1cba

28 return math.floor(math.log10(abs(x))) 1ba

29 

30 

31def int_mantissa(x, n, e): 1cba

32 return round(x * 10 ** (n - 1 - e)) 1ba

33 

34 

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

41 

42 

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

53 

54 

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

63 

64 

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

76 

77 

78def tostring(x): 1cba

79 return '0' if x == 0 else f'{x:#.6g}' 1a

80 

81 

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. 

98 

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. 

141 

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

155 

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

163 

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

170 

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 ) 

195 

196 if not outersign: 1ba

197 mumant = musign + mumant 1ba

198 

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

218 

219 if outersign: 1ba

220 r = musign + r 1a

221 

222 return r 1ba

223 

224 

225def fmtspec_kwargs(spec): 1cba

226 """ 

227 Parse a string formatting pattern to be used with `uformat`. 

228 

229 Parameters 

230 ---------- 

231 spec : str 

232 The format specification. It must follow the format 

233 

234 [options](error digits)[:minimum exponent](mode) 

235 

236 where brackets indicate optional parts. 

237 

238 Returns 

239 ------- 

240 kwargs : dict 

241 The keyword arguments to be passed to `uformat`. 

242 

243 Notes 

244 ----- 

245 Full format: 

246 

247 Options: any combination these characters: 

248 

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. 

260 

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. 

264 

265 Minimum exponent: a decimal number expressing the minimum exponent at which 

266 exponential notation is used. 

267 

268 Mode: one of these characters: 

269 

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

305 

306 

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

313 

314 

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. 

319 

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`. 

328 

329 See Also 

330 -------- 

331 gvar.fmt, gvar.GVar.set 

332 

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

341 

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

346 

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