Skip to content

Commit 2114287

Browse files
authored
Convert uncertainty field to string (#2096)
* Implemented float to string API function * Converted Uncertainty field to StringField * Converted methods to handle strings * Added migration step * Ensure we have a string result for interpolations * Handle different exponential notation * Set unfloatable uncertainty values to None * Don't float default value * added default return values * Fix test * Return empty string when field is not set * Convert to float in formatting function * Handle integer values * Fixed wrong interpolator * Always format manual uncertainty with full precision * convert to float for comparison * fixed test * set uncertainty to None if on a detection limit * Remove unsed import * Added doctest * Added another test * Doctest description * Added test for uncertainty value as percentage of the result * Added test for floating point arithmetic * Removed newlines
1 parent 30532ab commit 2114287

File tree

8 files changed

+699
-67
lines changed

8 files changed

+699
-67
lines changed

src/bika/lims/api/__init__.py

+45-5
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@
1818
# Copyright 2018-2021 by it's authors.
1919
# Some rights reserved, see README and LICENSE.
2020

21-
import Missing
2221
import re
22+
from collections import OrderedDict
23+
from datetime import datetime
24+
from datetime import timedelta
25+
from itertools import groupby
26+
2327
import six
28+
29+
import Missing
2430
from AccessControl.PermissionRole import rolesForPermissionOn
2531
from Acquisition import aq_base
2632
from Acquisition import aq_inner
@@ -29,12 +35,8 @@
2935
from bika.lims.interfaces import IClient
3036
from bika.lims.interfaces import IContact
3137
from bika.lims.interfaces import ILabContact
32-
from collections import OrderedDict
33-
from datetime import datetime
3438
from DateTime import DateTime
35-
from datetime import timedelta
3639
from DateTime.interfaces import DateTimeError
37-
from itertools import groupby
3840
from plone import api as ploneapi
3941
from plone.api.exc import InvalidParameterError
4042
from plone.app.layout.viewlets.content import ContentHistoryView
@@ -1379,6 +1381,44 @@ def to_float(value, default=_marker):
13791381
return float(value)
13801382

13811383

1384+
def float_to_string(value, default=_marker):
1385+
"""Convert a float value to string without exponential notation
1386+
1387+
This function preserves the whole fraction
1388+
1389+
:param value: The float value to be converted to a string
1390+
:type value: str, float, int
1391+
:returns: String representation of the float w/o exponential notation
1392+
:rtype: str
1393+
"""
1394+
if not is_floatable(value):
1395+
if default is not _marker:
1396+
return default
1397+
fail("Value %s is not floatable" % repr(value))
1398+
1399+
# Leave floatable string values unchanged
1400+
if isinstance(value, six.string_types):
1401+
return value
1402+
1403+
value = float(value)
1404+
str_value = str(value)
1405+
1406+
if "." in str_value:
1407+
# might be something like 1.23e-26
1408+
front, back = str_value.split(".")
1409+
else:
1410+
# or 1e-07 for 0.0000001
1411+
back = str_value
1412+
1413+
if "e-" in back:
1414+
fraction, zeros = back.split("e-")
1415+
# we want to cover the faction and the zeros
1416+
precision = len(fraction) + int(zeros)
1417+
template = "{:.%df}" % precision
1418+
str_value = template.format(value)
1419+
return str_value
1420+
1421+
13821422
def to_searchable_text_metadata(value):
13831423
"""Parse the given metadata value to searchable text
13841424

src/bika/lims/content/abstractanalysis.py

+44-35
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@
5151
from bika.lims.workflow import getTransitionDate
5252
from DateTime import DateTime
5353
from Products.Archetypes.Field import DateTimeField
54-
from Products.Archetypes.Field import FixedPointField
5554
from Products.Archetypes.Field import IntegerField
5655
from Products.Archetypes.Field import StringField
5756
from Products.Archetypes.references import HoldingReference
@@ -109,7 +108,7 @@
109108

110109
# The actual uncertainty for this analysis' result, populated from the ranges
111110
# specified in the analysis service when the result is submitted.
112-
Uncertainty = FixedPointField(
111+
Uncertainty = StringField(
113112
'Uncertainty',
114113
read_permission=View,
115114
write_permission="Field: Edit Result",
@@ -240,56 +239,66 @@ def getDefaultUncertainty(self, result=None):
240239
return None
241240

242241
for d in uncertainties:
243-
_min = float(d['intercept_min'])
244-
_max = float(d['intercept_max'])
245-
if _min <= res and res <= _max:
246-
if str(d['errorvalue']).strip().endswith('%'):
242+
243+
# convert to min/max
244+
unc_min = api.to_float(d["intercept_min"], default=0)
245+
unc_max = api.to_float(d["intercept_max"], default=0)
246+
247+
if unc_min <= res and res <= unc_max:
248+
_err = str(d["errorvalue"]).strip()
249+
if _err.endswith("%"):
247250
try:
248-
percvalue = float(d['errorvalue'].replace('%', ''))
251+
percvalue = float(_err.replace("%", ""))
249252
except ValueError:
250253
return None
254+
# calculate uncertainty from result
251255
uncertainty = res / 100 * percvalue
252256
else:
253-
uncertainty = float(d['errorvalue'])
257+
uncertainty = api.to_float(_err, default=0)
254258

255-
return uncertainty
259+
# convert back to string value
260+
return api.float_to_string(uncertainty, default=None)
256261
return None
257262

258263
@security.public
259264
def getUncertainty(self, result=None):
260265
"""Returns the uncertainty for this analysis and result.
266+
261267
Returns the value from Schema's Uncertainty field if the Service has
262-
the option 'Allow manual uncertainty'. Otherwise, do a callback to
263-
getDefaultUncertainty(). Returns None if no result specified and the
264-
current result for this analysis is below or above detections limits.
268+
the option 'Allow manual uncertainty'.
269+
Otherwise, do a callback to getDefaultUncertainty().
270+
271+
Returns empty string if no result specified and the current result for this
272+
analysis is below or above detections limits.
265273
"""
266-
uncertainty = self.getField('Uncertainty').get(self)
267-
if result is None and (self.isAboveUpperDetectionLimit() or
268-
self.isBelowLowerDetectionLimit()):
269-
return None
274+
uncertainty = self.getField("Uncertainty").get(self)
275+
if result is None:
276+
if self.isAboveUpperDetectionLimit():
277+
return None
278+
if self.isBelowLowerDetectionLimit():
279+
return None
280+
281+
if uncertainty and self.getAllowManualUncertainty():
282+
return api.float_to_string(uncertainty, default=None)
270283

271-
if uncertainty and self.getAllowManualUncertainty() is True:
272-
try:
273-
uncertainty = float(uncertainty)
274-
return uncertainty
275-
except (TypeError, ValueError):
276-
# if uncertainty is not a number, return default value
277-
pass
278284
return self.getDefaultUncertainty(result)
279285

280286
@security.public
281287
def setUncertainty(self, unc):
282-
"""Sets the uncertainty for this analysis. If the result is a
283-
Detection Limit or the value is below LDL or upper UDL, sets the
284-
uncertainty value to 0
288+
"""Sets the uncertainty for this analysis
289+
290+
If the result is a Detection Limit or the value is below LDL or upper
291+
UDL, set the uncertainty to None``
285292
"""
286293
# Uncertainty calculation on DL
287294
# https://jira.bikalabs.com/browse/LIMS-1808
288-
if self.isAboveUpperDetectionLimit() or \
289-
self.isBelowLowerDetectionLimit():
290-
self.getField('Uncertainty').set(self, None)
291-
else:
292-
self.getField('Uncertainty').set(self, unc)
295+
if self.isAboveUpperDetectionLimit():
296+
unc = None
297+
if self.isBelowLowerDetectionLimit():
298+
unc = None
299+
300+
field = self.getField("Uncertainty")
301+
field.set(self, api.float_to_string(unc, default=None))
293302

294303
@security.public
295304
def setDetectionLimitOperand(self, value):
@@ -972,10 +981,10 @@ def getPrecision(self, result=None):
972981
if allow_manual or precision_unc:
973982
uncertainty = self.getUncertainty(result)
974983
if uncertainty is None:
975-
return self.getField('Precision').get(self)
976-
if uncertainty == 0 and result is None:
977-
return self.getField('Precision').get(self)
978-
if uncertainty == 0:
984+
return self.getField("Precision").get(self)
985+
if api.to_float(uncertainty) == 0 and result is None:
986+
return self.getField("Precision").get(self)
987+
if api.to_float(uncertainty) == 0:
979988
strres = str(result)
980989
numdecimals = strres[::-1].find('.')
981990
return numdecimals

src/bika/lims/content/abstractroutineanalysis.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from bika.lims.interfaces.analysis import IRequestAnalysis
3636
from bika.lims.workflow import getTransitionDate
3737
from Products.Archetypes.Field import BooleanField
38-
from Products.Archetypes.Field import FixedPointField
38+
from Products.Archetypes.Field import StringField
3939
from Products.Archetypes.Schema import Schema
4040
from Products.ATContentTypes.utils import DT2dt
4141
from Products.ATContentTypes.utils import dt2DT
@@ -47,7 +47,7 @@
4747

4848
# The actual uncertainty for this analysis' result, populated when the result
4949
# is submitted.
50-
Uncertainty = FixedPointField(
50+
Uncertainty = StringField(
5151
'Uncertainty',
5252
read_permission=View,
5353
write_permission="Field: Edit Result",

src/bika/lims/utils/analysis.py

+23-7
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def _format_decimal_or_sci(result, precision, threshold, sciformat):
175175
if sci:
176176
# First, cut the extra decimals according to the precision
177177
prec = precision if precision and precision > 0 else 0
178-
nresult = str("%%.%sf" % prec) % result
178+
nresult = str("%%.%sf" % prec) % api.to_float(result, 0)
179179

180180
if sign:
181181
# 0.0012345 -> 1.2345
@@ -208,7 +208,7 @@ def _format_decimal_or_sci(result, precision, threshold, sciformat):
208208
else:
209209
# Decimal notation
210210
prec = precision if precision and precision > 0 else 0
211-
formatted = str("%%.%sf" % prec) % result
211+
formatted = str("%%.%sf" % prec) % api.to_float(result, 0)
212212
if float(formatted) == 0 and '-' in formatted:
213213
# We don't want things like '-0.00'
214214
formatted = formatted.replace('-', '')
@@ -223,7 +223,7 @@ def format_uncertainty(analysis, result, decimalmark='.', sciformat=1):
223223
If the "Calculate precision from uncertainties" is enabled in
224224
the Analysis service, and
225225
226-
a) If the the non-decimal number of digits of the result is above
226+
a) If the non-decimal number of digits of the result is above
227227
the service's ExponentialFormatPrecision, the uncertainty will
228228
be formatted in scientific notation. The uncertainty exponential
229229
value used will be the same as the one used for the result. The
@@ -286,21 +286,37 @@ def format_uncertainty(analysis, result, decimalmark='.', sciformat=1):
286286
except ValueError:
287287
pass
288288

289+
uncertainty = None
289290
if result == objres:
290291
# To avoid problems with DLs
291292
uncertainty = analysis.getUncertainty()
292293
else:
293294
uncertainty = analysis.getUncertainty(result)
294295

295-
if uncertainty is None or uncertainty == 0:
296+
if not uncertainty:
296297
return ""
297298

299+
precision = -1
300+
# always get full precision of the uncertainty if user entered manually
301+
# => avoids rounding and cut-off
302+
allow_manual = analysis.getAllowManualUncertainty()
303+
manual_value = analysis.getField("Uncertainty").get(analysis)
304+
if allow_manual and manual_value:
305+
precision = uncertainty[::-1].find(".")
306+
307+
if precision == -1:
308+
precision = analysis.getPrecision(result)
309+
298310
# Scientific notation?
299311
# Get the default precision for scientific notation
300312
threshold = analysis.getExponentialFormatPrecision()
301-
precision = analysis.getPrecision(result)
302-
formatted = _format_decimal_or_sci(uncertainty, precision, threshold,
303-
sciformat)
313+
formatted = _format_decimal_or_sci(
314+
uncertainty, precision, threshold, sciformat)
315+
316+
# strip off trailing zeros and the orphane dot
317+
if "." in formatted:
318+
formatted = formatted.rstrip("0").rstrip(".")
319+
304320
return formatDecimalMark(formatted, decimalmark)
305321

306322

src/senaite/core/tests/doctests/API.rst

+57
Original file line numberDiff line numberDiff line change
@@ -1547,6 +1547,63 @@ With default fallback:
15471547
2
15481548

15491549

1550+
Convert float to string
1551+
.......................
1552+
1553+
Values below zero get converted by the `float` class to the exponential notation, e.g.
1554+
1555+
>>> value = "0.000000000123"
1556+
>>> float_value = float(value)
1557+
1558+
>>> float_value
1559+
1.23e-10
1560+
1561+
>>> other_value = "0.0000001"
1562+
>>> other_float_value = float(other_value)
1563+
1564+
>>> other_float_value
1565+
1e-07
1566+
1567+
Converting it back to a string would keep this notation:
1568+
1569+
>>> str(float_value)
1570+
'1.23e-10'
1571+
1572+
>>> str(other_float_value)
1573+
'1e-07'
1574+
1575+
The function `float_to_string` converts the float value without exponential notation:
1576+
1577+
>>> api.float_to_string(float_value)
1578+
'0.000000000123'
1579+
1580+
>>> api.float_to_string(float_value) == value
1581+
True
1582+
1583+
Passing in the string value should convert it to the same value:
1584+
1585+
>>> api.float_to_string(value) == value
1586+
True
1587+
1588+
When the fraction contains more digits, it will retain them all and takes care of the trailing zero:
1589+
1590+
>>> new_value = 0.000000000123777
1591+
>>> api.float_to_string(new_value)
1592+
'0.000000000123777'
1593+
1594+
Converting integers work as well:
1595+
1596+
>>> int_value = 123
1597+
>>> api.float_to_string(int_value)
1598+
'123.0'
1599+
1600+
The function also ensures that floatable string values remain unchanged:
1601+
1602+
>>> str_value = "1.99887766554433221100"
1603+
>>> api.float_to_string(str_value) == str_value
1604+
True
1605+
1606+
15501607
Convert to minutes
15511608
..................
15521609

0 commit comments

Comments
 (0)