Passed
Push — 2.x ( 52afb0...6901b2 )
by Jordi
06:35
created

bika.lims.utils.analysis.format_uncertainty()   B

Complexity

Conditions 8

Size

Total Lines 90
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 23
dl 0
loc 90
rs 7.3333
c 0
b 0
f 0
cc 8
nop 3

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2021 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import math
22
23
from bika.lims import api
24
from bika.lims.interfaces import IAnalysisService
25
from bika.lims.interfaces import IBaseAnalysis
26
from bika.lims.interfaces import IReferenceSample
27
from bika.lims.interfaces.analysis import IRequestAnalysis
28
from bika.lims.utils import formatDecimalMark
29
30
31
def create_analysis(context, source, **kwargs):
32
    """Create a new Analysis.  The source can be an Analysis Service or
33
    an existing Analysis, and all possible field values will be set to the
34
    values found in the source object.
35
    :param context: The analysis will be created inside this object.
36
    :param source: The schema of this object will be used to populate analysis.
37
    :param kwargs: The values of any keys which match schema fieldnames will
38
    be inserted into the corresponding fields in the new analysis.
39
    :returns: Analysis object that was created
40
    :rtype: Analysis
41
    """
42
    # Ensure we have an object as source
43
    source = api.get_object(source)
44
    if not IBaseAnalysis.providedBy(source):
45
        raise ValueError("Type not supported: {}".format(repr(type(source))))
46
47
    # compute the id of the new analysis if necessary
48
    analysis_id = kwargs.get("id")
49
    if not analysis_id:
50
        keyword = source.getKeyword()
51
        analysis_id = generate_analysis_id(context, keyword)
52
53
    # get the service to be assigned to the analysis
54
    service = source
55
    if not IAnalysisService.providedBy(source):
56
        service = source.getAnalysisService()
57
58
    # use "Analysis" as portal_type unless explicitly set
59
    portal_type = kwargs.pop("portal_type", "Analysis")
60
61
    # initialize interims with those from the service if not explicitly set
62
    interim_fields = kwargs.pop("InterimFields", service.getInterimFields())
63
64
    # do not copy these fields from source
65
    skip_fields = [
66
        "Hidden",
67
        "Attachment",
68
        "Result",
69
        "ResultCaptureDate",
70
        "Worksheet"
71
    ]
72
73
    kwargs.update({
74
        "container": context,
75
        "portal_type": portal_type,
76
        "skip": skip_fields,
77
        "id": analysis_id,
78
        "AnalysisService": service,
79
        "InterimFields": interim_fields,
80
    })
81
    return api.copy_object(source, **kwargs)
82
83
84
def get_significant_digits(numeric_value):
85
    """
86
    Returns the precision for a given floatable value.
87
    If value is None or not floatable, returns None.
88
    Will return positive values if the result is below 1 and will
89
    return 0 values if the result is above or equal to 1.
90
    :param numeric_value: the value to get the precision from
91
    :returns: the numeric_value's precision
92
            Examples:
93
            numeric_value     Returns
94
            0               0
95
            0.22            1
96
            1.34            0
97
            0.0021          3
98
            0.013           2
99
            2               0
100
            22              0
101
    """
102
    try:
103
        numeric_value = float(numeric_value)
104
    except (TypeError, ValueError):
105
        return None
106
    if numeric_value == 0:
107
        return 0
108
    significant_digit = int(math.floor(math.log10(abs(numeric_value))))
109
    return 0 if significant_digit > 0 else abs(significant_digit)
110
111
112
def _format_decimal_or_sci(result, precision, threshold, sciformat):
113
    # Current result's precision is above the threshold?
114
    sig_digits = get_significant_digits(result)
115
116
    # Note that if result < 1, sig_digits > 0. Otherwise, sig_digits = 0
117
    # Eg:
118
    #       result = 0.2   -> sig_digit = 1
119
    #                0.002 -> sig_digit = 3
120
    #                0     -> sig_digit = 0
121
    #                2     -> sig_digit = 0
122
    # See get_significant_digits signature for further details!
123
    #
124
    # Also note if threshold is negative, the result will always be expressed
125
    # in scientific notation:
126
    # Eg.
127
    #       result=12345, threshold=-3, sig_digit=0 -> 1.2345e4 = 1.2345·10⁴
128
    #
129
    # So, if sig_digits is > 0, the power must be expressed in negative
130
    # Eg.
131
    #      result=0.0012345, threshold=3, sig_digit=3 -> 1.2345e-3=1.2345·10-³
132
    sci = sig_digits >= threshold and abs(
133
        threshold) > 0 and sig_digits <= precision
134
    sign = '-' if sig_digits > 0 else ''
135
    if sig_digits == 0 and abs(threshold) > 0 and abs(int(float(result))) > 0:
136
        # Number >= 1, need to check if the number of non-decimal
137
        # positions is above the threshold
138
        sig_digits = int(math.log(abs(float(result)), 10)) if abs(
139
            float(result)) >= 10 else 0
140
        sci = sig_digits >= abs(threshold)
141
142
    formatted = ''
143
    if sci:
144
        # First, cut the extra decimals according to the precision
145
        prec = precision if precision and precision > 0 else 0
146
        nresult = str("%%.%sf" % prec) % api.to_float(result, 0)
147
148
        if sign:
149
            # 0.0012345 -> 1.2345
150
            res = float(nresult) * (10 ** sig_digits)
151
        else:
152
            # Non-decimal positions
153
            # 123.45 -> 1.2345
154
            res = float(nresult) / (10 ** sig_digits)
155
        res = int(res) if res.is_integer() else res
156
157
        # Scientific notation
158
        if sciformat == 2:
159
            # ax10^b or ax10^-b
160
            formatted = "%s%s%s%s" % (res, "x10^", sign, sig_digits)
161
        elif sciformat == 3:
162
            # ax10<super>b</super> or ax10<super>-b</super>
163
            formatted = "%s%s%s%s%s" % (
164
                res, "x10<sup>", sign, sig_digits, "</sup>")
165
        elif sciformat == 4:
166
            # ax10^b or ax10^-b
167
            formatted = "%s%s%s%s" % (res, "·10^", sign, sig_digits)
168
        elif sciformat == 5:
169
            # ax10<super>b</super> or ax10<super>-b</super>
170
            formatted = "%s%s%s%s%s" % (
171
                res, "·10<sup>", sign, sig_digits, "</sup>")
172
        else:
173
            # Default format: aE^+b
174
            sig_digits = "%02d" % sig_digits
175
            formatted = "%s%s%s%s" % (res, "e", sign, sig_digits)
176
    else:
177
        # Decimal notation
178
        prec = precision if precision and precision > 0 else 0
179
        formatted = str("%%.%sf" % prec) % api.to_float(result, 0)
180
        if float(formatted) == 0 and '-' in formatted:
181
            # We don't want things like '-0.00'
182
            formatted = formatted.replace('-', '')
183
    return formatted
184
185
186
def format_uncertainty(analysis, decimalmark=".", sciformat=1):
187
    """Return formatted uncertainty value
188
189
    If the "Calculate precision from uncertainties" is enabled in
190
    the Analysis service, and
191
192
    a) If the non-decimal number of digits of the result is above
193
       the service's ExponentialFormatPrecision, the uncertainty will
194
       be formatted in scientific notation. The uncertainty exponential
195
       value used will be the same as the one used for the result. The
196
       uncertainty will be rounded according to the same precision as
197
       the result.
198
199
       Example:
200
       Given an Analysis with an uncertainty of 37 for a range of
201
       results between 30000 and 40000, with an
202
       ExponentialFormatPrecision equal to 4 and a result of 32092,
203
       this method will return 0.004E+04
204
205
    b) If the number of digits of the integer part of the result is
206
       below the ExponentialFormatPrecision, the uncertainty will be
207
       formatted as decimal notation and the uncertainty will be
208
       rounded one position after reaching the last 0 (precision
209
       calculated according to the uncertainty value).
210
211
       Example:
212
       Given an Analysis with an uncertainty of 0.22 for a range of
213
       results between 1 and 10 with an ExponentialFormatPrecision
214
       equal to 4 and a result of 5.234, this method will return 0.2
215
216
    If the "Calculate precision from Uncertainties" is disabled in the
217
    analysis service, the same rules described above applies, but the
218
    precision used for rounding the uncertainty is not calculated from
219
    the uncertainty neither the result. The fixed length precision is
220
    used instead.
221
222
    If the result is not floatable or no uncertainty defined, returns
223
    an empty string.
224
225
    The default decimal mark '.' will be replaced by the decimalmark
226
    specified.
227
228
    :param analysis: the analysis from which the uncertainty, precision
229
                     and other additional info have to be retrieved
230
    :param decimalmark: decimal mark to use. By default '.'
231
    :param sciformat: 1. The sci notation has to be formatted as aE^+b
232
                  2. The sci notation has to be formatted as ax10^b
233
                  3. As 2, but with super html entity for exp
234
                  4. The sci notation has to be formatted as a·10^b
235
                  5. As 4, but with super html entity for exp
236
                  By default 1
237
    :returns: the formatted uncertainty
238
    """
239
    try:
240
        result = float(analysis.getResult())
241
    except (ValueError, TypeError):
242
        pass
243
244
    uncertainty = analysis.getUncertainty()
245
246
    if not uncertainty:
247
        return ""
248
249
    # always convert exponential notation to decimal
250
    if "e" in uncertainty.lower():
251
        uncertainty = api.float_to_string(float(uncertainty))
252
253
    precision = -1
254
    # always get full precision of the uncertainty if user entered manually
255
    # => avoids rounding and cut-off
256
    allow_manual = analysis.getAllowManualUncertainty()
257
    manual_value = analysis.getField("Uncertainty").get(analysis)
258
    if allow_manual and manual_value:
259
        precision = uncertainty[::-1].find(".")
260
261
    if precision == -1:
262
        precision = analysis.getPrecision(result)
263
264
    # Scientific notation?
265
    # Get the default precision for scientific notation
266
    threshold = analysis.getExponentialFormatPrecision()
267
    formatted = _format_decimal_or_sci(
268
        uncertainty, precision, threshold, sciformat)
269
270
    # strip off trailing zeros and the orphane dot,
271
    # e.g.: 1.000000 -> 1
272
    if "." in formatted:
273
        formatted = formatted.rstrip("0").rstrip(".")
274
275
    return formatDecimalMark(formatted, decimalmark)
276
277
278
def format_numeric_result(analysis, result, decimalmark='.', sciformat=1):
279
    """
280
    Returns the formatted number part of a results value.  This is
281
    responsible for deciding the precision, and notation of numeric
282
    values in accordance to the uncertainty. If a non-numeric
283
    result value is given, the value will be returned unchanged.
284
285
    The following rules apply:
286
287
    If the "Calculate precision from uncertainties" is enabled in
288
    the Analysis service, and
289
290
    a) If the non-decimal number of digits of the result is above
291
       the service's ExponentialFormatPrecision, the result will
292
       be formatted in scientific notation.
293
294
       Example:
295
       Given an Analysis with an uncertainty of 37 for a range of
296
       results between 30000 and 40000, with an
297
       ExponentialFormatPrecision equal to 4 and a result of 32092,
298
       this method will return 3.2092E+04
299
300
    b) If the number of digits of the integer part of the result is
301
       below the ExponentialFormatPrecision, the result will be
302
       formatted as decimal notation and the resulta will be rounded
303
       in accordance to the precision (calculated from the uncertainty)
304
305
       Example:
306
       Given an Analysis with an uncertainty of 0.22 for a range of
307
       results between 1 and 10 with an ExponentialFormatPrecision
308
       equal to 4 and a result of 5.234, this method will return 5.2
309
310
    If the "Calculate precision from Uncertainties" is disabled in the
311
    analysis service, the same rules described above applies, but the
312
    precision used for rounding the result is not calculated from
313
    the uncertainty. The fixed length precision is used instead.
314
315
    For further details, visit
316
    https://jira.bikalabs.com/browse/LIMS-1334
317
318
    The default decimal mark '.' will be replaced by the decimalmark
319
    specified.
320
321
    :param analysis: the analysis from which the uncertainty, precision
322
                     and other additional info have to be retrieved
323
    :param result: result to be formatted.
324
    :param decimalmark: decimal mark to use. By default '.'
325
    :param sciformat: 1. The sci notation has to be formatted as aE^+b
326
                      2. The sci notation has to be formatted as ax10^b
327
                      3. As 2, but with super html entity for exp
328
                      4. The sci notation has to be formatted as a·10^b
329
                      5. As 4, but with super html entity for exp
330
                      By default 1
331
    :result: should be a string to preserve the decimal precision.
332
    :returns: the formatted result as string
333
    """
334
    try:
335
        result = float(result)
336
    except ValueError:
337
        return result
338
339
    # continuing with 'nan' result will cause formatting to fail.
340
    if math.isnan(result):
341
        return result
342
343
    # Scientific notation?
344
    # Get the default precision for scientific notation
345
    threshold = analysis.getExponentialFormatPrecision()
346
    precision = analysis.getPrecision(result)
347
    formatted = _format_decimal_or_sci(result, precision, threshold, sciformat)
348
    return formatDecimalMark(formatted, decimalmark)
349
350
351
def create_retest(analysis, **kwargs):
352
    """Creates a retest of the given analysis
353
    """
354
    if not IRequestAnalysis.providedBy(analysis):
355
        raise ValueError("Type not supported: {}".format(repr(type(analysis))))
356
357
    # Create a copy of the original analysis
358
    parent = api.get_parent(analysis)
359
    kwargs.update({
360
        "portal_type": api.get_portal_type(analysis),
361
        "RetestOf": analysis,
362
    })
363
    retest = create_analysis(parent, analysis, **kwargs)
364
365
    # Add the retest to the same worksheet, if any
366
    worksheet = analysis.getWorksheet()
367
    if worksheet:
368
        worksheet.addAnalysis(retest)
369
370
    return retest
371
372
373
def create_duplicate(analysis, **kwargs):
374
    """Creates a duplicate of the given analysis
375
    """
376
    if not IRequestAnalysis.providedBy(analysis):
377
        raise ValueError("Type not supported: {}".format(repr(type(analysis))))
378
379
    worksheet = analysis.getWorksheet()
380
    if not worksheet:
381
        raise ValueError("Cannot create a duplicate without worksheet")
382
383
    sample_id = analysis.getRequestID()
384
    kwargs.update({
385
        "portal_type": "DuplicateAnalysis",
386
        "Analysis": analysis,
387
        "Worksheet": worksheet,
388
        "ReferenceAnalysesGroupID": "{}-D".format(sample_id),
389
    })
390
391
    return create_analysis(worksheet, analysis, **kwargs)
392
393
394
def create_reference_analysis(reference_sample, source, **kwargs):
395
    """Creates a reference analysis inside the referencesample
396
    """
397
    ref = api.get_object(reference_sample)
398
    if not IReferenceSample.providedBy(ref):
399
        raise ValueError("Type not supported: {}".format(repr(type(ref))))
400
401
    # Set the type of the reference analysis
402
    ref_type = "b" if ref.getBlank() else "c"
403
    kwargs.update({
404
        "portal_type": "ReferenceAnalysis",
405
        "ReferenceType": ref_type,
406
    })
407
    return create_analysis(ref, source, **kwargs)
408
409
410
def generate_analysis_id(instance, keyword):
411
    """Generates a new analysis ID
412
    """
413
    count = 1
414
    new_id = keyword
415
    while new_id in instance.objectIds():
416
        new_id = "{}-{}".format(keyword, count)
417
        count += 1
418
    return new_id
419