# -*- coding: utf-8 -*-
"""
This file contains Qudi methods for handling real-world values with units.
Copyright (c) 2021, the qudi developers. See the AUTHORS.md file at the top-level directory of this
distribution and on <https://github.com/Ulm-IQO/qudi-core/>
This file is part of qudi.
Qudi is free software: you can redistribute it and/or modify it under the terms of
the GNU Lesser General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version.
Qudi is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along with qudi.
If not, see <https://www.gnu.org/licenses/>.
"""
__all__ = ('create_formatted_output', 'get_relevant_digit', 'get_si_norm', 'get_unit_prefix_dict',
'round_value_to_error', 'ScaledFloat')
import math
import numpy as np
try:
import pyqtgraph.functions as fn
except ImportError:
fn = None
[docs]
def get_unit_prefix_dict():
"""Return the dictionary, which assigns the prefix of a unit to its proper order of magnitude.
Parameters
----------
None
Returns
-------
dict
Keys are string prefix and values are magnitude values.
"""
unit_prefix_dict = {
'y': 1e-24,
'z': 1e-21,
'a': 1e-18,
'f': 1e-15,
'p': 1e-12,
'n': 1e-9,
'µ': 1e-6,
'm': 1e-3,
'': 1,
'k': 1e3,
'M': 1e6,
'G': 1e9,
'T': 1e12,
'P': 1e15,
'E': 1e18,
'Z': 1e21,
'Y': 1e24
}
return unit_prefix_dict
[docs]
class ScaledFloat(float):
"""
Format code 'r' for scaled output.
Examples
--------
'{:.0r}A'.format(ScaledFloat(50)) --> 50 A
'{:.1r}A'.format(ScaledFloat(1.5e3)) --> 1.5 kA
'{:.1r}A'.format(ScaledFloat(2e-3)) --> 2.0 mA
'{:rg}A'.format(ScaledFloat(2e-3)) --> 2 mA
'{:rf}A'.format(ScaledFloat(2e-3)) --> 2.000000 mA
"""
@property
def scale(self):
"""
Returns the scale. (No prefix if 0)
Examples
--------
1e-3: m
1e6: M
"""
# Zero makes the log crash and should not have a prefix
if self == 0:
return ''
exponent = math.floor(math.log10(abs(self)) / 3)
if exponent < -8:
exponent = -8
if exponent > 8:
exponent = 8
prefix = 'yzafpnµm kMGTPEZY'
return prefix[8 + exponent].strip()
@property
def scale_val(self):
"""Returns the scale value which can be used to devide the actual value.
Examples
--------
m: 1e-3
M: 1e6
"""
scale_str = self.scale
return get_unit_prefix_dict()[scale_str]
def __format__(self, fmt):
"""
Formats the string using format fmt.
r for scaled output.
Parameters
----------
fmt : str
Format string.
"""
autoscale = False
if len(fmt) >= 2:
if fmt[-2] == 'r':
autoscale = True
fmt = fmt[:-2] + fmt[-1]
elif fmt[-1] == 'r':
autoscale = True
fmt = fmt[:-1] + 'f'
elif fmt[-1] == 'r':
autoscale = True
fmt = fmt[:-1] + 'f'
if autoscale:
scale = self.scale
if scale == 'u':
index = 'micro'
else:
index = scale
value = self / get_unit_prefix_dict()[index]
return '{:s} {:s}'.format(value.__format__(fmt), scale)
else:
return super().__format__(fmt)
[docs]
def round_value_to_error(value, error):
"""The scientifically correct way of rounding a value according to an error.
Parameters
----------
value : float or int
The measurement value.
error : float or int
The error for that measurement value.
Returns
-------
tuple
A tuple containing the following elements:
float
The rounded value according to the error.
float
The rounded error.
int
The digit to which the rounding procedure was performed. A positive
number indicates the position of the digit right from the comma, zero means
the first digit left from the comma, and negative numbers are the digits left
from the comma. This follows the convention used in the native `round` method
and `numpy.round`.
Notes
-----
- The input type of `value` or `error` will not be changed. If `float` is the input, `float` will be the output; the same applies to `integer`.
- This method does not return strings, as each display method might want to display the rounded values in a different way (in exponential representation, in a different magnitude, etc.).
- This function can handle an invalid error, i.e., if the error is zero, NaN, or infinite. The absolute tolerance to detect a number as zero is set to 1e-18.
Procedure explanation:
The scientific way of displaying a measurement result in the presence of an error is applied here. It follows this procedure:
Take the first leading non-zero number in the error value and check whether the number is a digit within 3 to 9. If so, the rounding value
is the specified digit. Otherwise, if the first leading digit is 1 or 2, then the next right digit is the rounding value.
The error is rounded according to that digit, and the same applies to the value.
Examples
--------
Example 1:
>>> x_meas = 2.05650234
>>> delta_x = 0.0634
>>> result = some_function(x_meas, delta_x)
>>> print(result)
(2.06, 0.06, 2)
Example 2:
>>> x_meas = 0.34545
>>> delta_x = 0.19145
>>> result = some_function(x_meas, delta_x)
>>> print(result)
(0.35, 0.19, 2)
Example 3:
>>> x_meas = 239579.23
>>> delta_x = 1289.234
>>> result = some_function(x_meas, delta_x)
>>> print(result)
(239600.0, 1300.0, -2)
Example 4:
>>> x_meas = 961453
>>> delta_x = 3789
>>> result = some_function(x_meas, delta_x)
>>> print(result)
(961000, 4000, -3)
"""
atol = 1e-18 # absolute tolerance for the detection of zero.
# check if error is zero, since that is an invalid input!
if np.isclose(error, 0.0, atol=atol) or np.isnan(error) or np.isinf(error):
# self.log.error('Cannot round to the error, since either a zero error ')
# logger.warning('Cannot round to the error, since either a zero error '
# 'value was passed for the number {0}, or the error is '
# 'NaN: Error value: {1}. '.format(value, error))
# set the round digit to float precision
round_digit = -12
return value, error, round_digit
# error can only be positive!
log_val = np.log10(abs(error))
if log_val < 0:
round_digit = -(int(log_val) - 1)
else:
round_digit = -(int(log_val))
first_err_digit = '{:e}'.format(error)[0]
if first_err_digit in ('1', '2'):
round_digit += 1
# Use the python round function, since np.round uses the __repr__ conversion
# function which shows enough digits to unambiguously identify the number.
# But the __str__ conversion should round the number to a reasonable number
# of digits, which is the standard output of the python round function.
# Therefore take the python round function.
return round(value, round_digit), round(error, round_digit), round_digit
[docs]
def get_relevant_digit(entry):
"""By using log10, abs and int operations, the proper relevant digit is
obtained.
Parameters
----------
entry : float
Returns
-------
int
The leading relevant exponent.
"""
# the log10 can only be calculated of a positive number.
entry = np.abs(entry)
# the log of zero crashes, so return 0
if entry == 0:
return 0
if np.log10(entry) >= 0:
return int(np.log10(entry))
else:
# catch the asymmetric behaviour of the log and int operation.
return int(int(np.abs(np.log10(entry))) + 1 + np.log10(entry)) - (
int(np.abs(np.log10(entry))) + 1)
[docs]
def get_si_norm(entry):
"""A rather different way to display the value in SI notation.
Parameters
----------
entry : float
The float number from which normalization factor should
be obtained.
Returns
-------
norm_val : float
The value in a normalized representation.
normalization : float
The factor by which to divide the number.
"""
val = get_relevant_digit(entry)
fact = int(val / 3)
power = int(3 * fact)
norm = 10 ** power
return entry / norm, norm