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

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

19 

20import math 1feabcd

21import re 1feabcd

22import contextlib 1feabcd

23 

24import gvar 1feabcd

25 

26def exponent(x): 1feabcd

27 return int(math.floor(math.log10(abs(x)))) 1eabcd

28 

29def int_mantissa(x, n, e): 1feabcd

30 return round(x * 10 ** (n - 1 - e)) 1eabcd

31 

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

38 

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

49 

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

58 

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

70 

71def tostring(x): 1feabcd

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

73 

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. 

85  

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. 

128 

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

141 

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

149 

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

156 

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

169 

170 if not outersign: 1eabcd

171 mumant = musign + mumant 1eabcd

172 

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

192 

193 if outersign: 1eabcd

194 r = musign + r 1abcd

195 

196 return r 1eabcd

197 

198def fmtspec_kwargs(spec): 1feabcd

199 """ 

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

201 

202 Parameters 

203 ---------- 

204 spec : str 

205 The format specification. It must follow the format 

206 

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

208 

209 where brackets indicate optional parts. 

210 

211 Returns 

212 ------- 

213 kwargs : dict 

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

215 

216 Notes 

217 ----- 

218 Full format: 

219 

220 Options: any combination these characters: 

221  

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. 

233 

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. 

237 

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

239 exponential notation is used. 

240 

241 Mode: one of these characters: 

242  

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

277 

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

286 

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. 

291  

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

300 

301 Notes 

302 ----- 

303 See `fmtspec_kwargs` for the format specification, and `uformat` for all 

304 details. 

305 

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