Passed
Push — 2.x ( 4ac573...e592c3 )
by Ramon
07:11
created

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

Complexity

Conditions 9

Size

Total Lines 103
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 23
dl 0
loc 103
rs 6.6666
c 0
b 0
f 0
cc 9
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-2025 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import copy
22
import math
23
24
from bika.lims import api
25
from bika.lims.interfaces import IAnalysisService
26
from bika.lims.interfaces import IBaseAnalysis
27
from bika.lims.interfaces import IReferenceSample
28
from bika.lims.interfaces.analysis import IRequestAnalysis
29
from bika.lims.utils import formatDecimalMark
30
from bika.lims.utils import format_supsub
31
32
33
def create_analysis(context, source, **kwargs):
34
    """Create a new Analysis.  The source can be an Analysis Service or
35
    an existing Analysis, and all possible field values will be set to the
36
    values found in the source object.
37
    :param context: The analysis will be created inside this object.
38
    :param source: The schema of this object will be used to populate analysis.
39
    :param kwargs: The values of any keys which match schema fieldnames will
40
    be inserted into the corresponding fields in the new analysis.
41
    :returns: Analysis object that was created
42
    :rtype: Analysis
43
    """
44
    # Ensure we have an object as source
45
    source = api.get_object(source)
46
    if not IBaseAnalysis.providedBy(source):
47
        raise ValueError("Type not supported: {}".format(repr(type(source))))
48
49
    # compute the id of the new analysis if necessary
50
    analysis_id = kwargs.get("id")
51
    if not analysis_id:
52
        keyword = source.getKeyword()
53
        analysis_id = generate_analysis_id(context, keyword)
54
55
    # get the service to be assigned to the analysis
56
    service = source
57
    if not IAnalysisService.providedBy(source):
58
        service = source.getAnalysisService()
59
60
    # use "Analysis" as portal_type unless explicitly set
61
    portal_type = kwargs.pop("portal_type", "Analysis")
62
63
    # initialize interims with those from the service if not explicitly set
64
    interim_fields = kwargs.pop("InterimFields", service.getInterimFields())
65
66
    # do not copy these fields from source
67
    skip_fields = [
68
        "Attachment",
69
        "Result",
70
        "ResultCaptureDate",
71
        "Worksheet"
72
    ]
73
74
    kwargs.update({
75
        "container": context,
76
        "portal_type": portal_type,
77
        "skip": skip_fields,
78
        "id": analysis_id,
79
        "AnalysisService": service,
80
        "InterimFields": interim_fields,
81
    })
82
    return api.copy_object(source, **kwargs)
83
84
85
def get_significant_digits(numeric_value):
86
    """
87
    Returns the precision for a given floatable value.
88
    If value is None or not floatable, returns None.
89
    Will return positive values if the result is below 1 and will
90
    return 0 values if the result is above or equal to 1.
91
    :param numeric_value: the value to get the precision from
92
    :returns: the numeric_value's precision
93
            Examples:
94
            numeric_value     Returns
95
            0               0
96
            0.22            1
97
            1.34            0
98
            0.0021          3
99
            0.013           2
100
            2               0
101
            22              0
102
    """
103
    try:
104
        numeric_value = float(numeric_value)
105
    except (TypeError, ValueError):
106
        return None
107
    if numeric_value == 0:
108
        return 0
109
    significant_digit = int(math.floor(math.log10(abs(numeric_value))))
110
    return 0 if significant_digit > 0 else abs(significant_digit)
111
112
113
def _format_decimal_or_sci(result, precision, threshold, sciformat):
114
    # Current result's precision is above the threshold?
115
    sig_digits = get_significant_digits(result)
116
117
    # Note that if result < 1, sig_digits > 0. Otherwise, sig_digits = 0
118
    # Eg:
119
    #       result = 0.2   -> sig_digit = 1
120
    #                0.002 -> sig_digit = 3
121
    #                0     -> sig_digit = 0
122
    #                2     -> sig_digit = 0
123
    # See get_significant_digits signature for further details!
124
    #
125
    # Also note if threshold is negative, the result will always be expressed
126
    # in scientific notation:
127
    # Eg.
128
    #       result=12345, threshold=-3, sig_digit=0 -> 1.2345e4 = 1.2345·10⁴
129
    #
130
    # So, if sig_digits is > 0, the power must be expressed in negative
131
    # Eg.
132
    #      result=0.0012345, threshold=3, sig_digit=3 -> 1.2345e-3=1.2345·10-³
133
    sci = sig_digits >= threshold and abs(
134
        threshold) > 0 and sig_digits <= precision
135
    sign = '-' if sig_digits > 0 else ''
136
    if sig_digits == 0 and abs(threshold) > 0 and abs(int(float(result))) > 0:
137
        # Number >= 1, need to check if the number of non-decimal
138
        # positions is above the threshold
139
        sig_digits = int(math.log(abs(float(result)), 10)) if abs(
140
            float(result)) >= 10 else 0
141
        sci = sig_digits >= abs(threshold)
142
143
    formatted = ''
144
    if sci:
145
        # First, cut the extra decimals according to the precision
146
        prec = precision if precision and precision > 0 else 0
147
        nresult = str("%%.%sf" % prec) % api.to_float(result, 0)
148
149
        if sign:
150
            # 0.0012345 -> 1.2345
151
            res = float(nresult) * (10 ** sig_digits)
152
        else:
153
            # Non-decimal positions
154
            # 123.45 -> 1.2345
155
            res = float(nresult) / (10 ** sig_digits)
156
        res = int(res) if res.is_integer() else res
157
158
        # Scientific notation
159
        if sciformat == 2:
160
            # ax10^b or ax10^-b
161
            formatted = "%s%s%s%s" % (res, "x10^", sign, sig_digits)
162
        elif sciformat == 3:
163
            # ax10<super>b</super> or ax10<super>-b</super>
164
            formatted = "%s%s%s%s%s" % (
165
                res, "x10<sup>", sign, sig_digits, "</sup>")
166
        elif sciformat == 4:
167
            # ax10^b or ax10^-b
168
            formatted = "%s%s%s%s" % (res, "·10^", sign, sig_digits)
169
        elif sciformat == 5:
170
            # ax10<super>b</super> or ax10<super>-b</super>
171
            formatted = "%s%s%s%s%s" % (
172
                res, "·10<sup>", sign, sig_digits, "</sup>")
173
        else:
174
            # Default format: aE^+b
175
            sig_digits = "%02d" % sig_digits
176
            formatted = "%s%s%s%s" % (res, "e", sign, sig_digits)
177
    else:
178
        # Decimal notation
179
        prec = precision if precision and precision > 0 else 0
180
        formatted = str("%%.%sf" % prec) % api.to_float(result, 0)
181
        if float(formatted) == 0 and '-' in formatted:
182
            # We don't want things like '-0.00'
183
            formatted = formatted.replace('-', '')
184
    return formatted
185
186
187
def format_uncertainty(analysis, decimalmark=".", sciformat=1):
188
    """Return formatted uncertainty value
189
190
    If the "Calculate precision from uncertainties" is enabled in
191
    the Analysis service, and
192
193
    a) If the non-decimal number of digits of the result is above
194
       the service's ExponentialFormatPrecision, the uncertainty will
195
       be formatted in scientific notation. The uncertainty exponential
196
       value used will be the same as the one used for the result. The
197
       uncertainty will be rounded according to the same precision as
198
       the result.
199
200
       Example:
201
       Given an Analysis with an uncertainty of 37 for a range of
202
       results between 30000 and 40000, with an
203
       ExponentialFormatPrecision equal to 4 and a result of 32092,
204
       this method will return 0.004E+04
205
206
    b) If the number of digits of the integer part of the result is
207
       below the ExponentialFormatPrecision, the uncertainty will be
208
       formatted as decimal notation and the uncertainty will be
209
       rounded one position after reaching the last 0 (precision
210
       calculated according to the uncertainty value).
211
212
       Example:
213
       Given an Analysis with an uncertainty of 0.22 for a range of
214
       results between 1 and 10 with an ExponentialFormatPrecision
215
       equal to 4 and a result of 5.234, this method will return 0.2
216
217
    If the "Calculate precision from Uncertainties" is disabled in the
218
    analysis service, the same rules described above applies, but the
219
    precision used for rounding the uncertainty is not calculated from
220
    the uncertainty neither the result. The fixed length precision is
221
    used instead.
222
223
    If the result is not floatable, the uncertainty is not defined or its value
224
    is not above 0, an empty string is returned.
225
226
    The default decimal mark '.' will be replaced by the decimalmark
227
    specified.
228
229
    :param analysis: the analysis from which the uncertainty, precision
230
                     and other additional info have to be retrieved
231
    :param decimalmark: decimal mark to use. By default '.'
232
    :param sciformat: 1. The sci notation has to be formatted as aE^+b
233
                  2. The sci notation has to be formatted as ax10^b
234
                  3. As 2, but with super html entity for exp
235
                  4. The sci notation has to be formatted as a·10^b
236
                  5. As 4, but with super html entity for exp
237
                  By default 1
238
    :returns: the formatted uncertainty
239
    """
240
    if not api.is_floatable(analysis.getResult()):
241
        # do not display uncertainty, result is not floatable
242
        return ""
243
244
    if analysis.isOutsideTheQuantifiableRange():
245
        # Displaying uncertainty for results outside the quantifiable range is
246
        # not meaningful because the Lower Limit of Quantification (LLOQ) and
247
        # Upper Limit of Quantification (ULOQ) define the range within which
248
        # a parameter can be reliably and accurately measured. Results outside
249
        # this range are prone to significant variability and may be
250
        # indistinguishable from background noise or method imprecision.
251
        # As such, any numeric value reported outside the quantifiable range
252
        # lacks the reliability required for meaningful interpretation.
253
        # It is important to note that the quantifiable range is always nested
254
        # within the detection range, which is defined by the Lower Limit of
255
        # Detection (LLOD) and Upper Limit of Detection (ULOD).
256
        return ""
257
258
    uncertainty = analysis.getUncertainty()
259
    if api.to_float(uncertainty, default=-1) < 0:
260
        # uncertainty is not defined or below 0
261
        return ""
262
263
    # always convert exponential notation to decimal
264
    if "e" in uncertainty.lower():
265
        uncertainty = api.float_to_string(float(uncertainty))
266
267
    precision = -1
268
    # always get full precision of the uncertainty if user entered manually
269
    # => avoids rounding and cut-off
270
    allow_manual = analysis.getAllowManualUncertainty()
271
    manual_value = analysis.getField("Uncertainty").get(analysis)
272
    if allow_manual and manual_value:
273
        precision = uncertainty[::-1].find(".")
274
275
    if precision == -1:
276
        precision = analysis.getPrecision()
277
278
    # Scientific notation?
279
    # Get the default precision for scientific notation
280
    threshold = analysis.getExponentialFormatPrecision()
281
    formatted = _format_decimal_or_sci(
282
        uncertainty, precision, threshold, sciformat)
283
284
    # strip off trailing zeros and the orphane dot,
285
    # e.g.: 1.000000 -> 1
286
    if "." in formatted:
287
        formatted = formatted.rstrip("0").rstrip(".")
288
289
    return formatDecimalMark(formatted, decimalmark)
290
291
292
def format_numeric_result(analysis, decimalmark='.', sciformat=1):
293
    """
294
    Returns the formatted number part of a results value.  This is
295
    responsible for deciding the precision, and notation of numeric
296
    values in accordance to the uncertainty. If a non-numeric
297
    result value is given, the value will be returned unchanged.
298
299
    The following rules apply:
300
301
    If the "Calculate precision from uncertainties" is enabled in
302
    the Analysis service, and
303
304
    a) If the non-decimal number of digits of the result is above
305
       the service's ExponentialFormatPrecision, the result will
306
       be formatted in scientific notation.
307
308
       Example:
309
       Given an Analysis with an uncertainty of 37 for a range of
310
       results between 30000 and 40000, with an
311
       ExponentialFormatPrecision equal to 4 and a result of 32092,
312
       this method will return 3.2092E+04
313
314
    b) If the number of digits of the integer part of the result is
315
       below the ExponentialFormatPrecision, the result will be
316
       formatted as decimal notation and the resulta will be rounded
317
       in accordance to the precision (calculated from the uncertainty)
318
319
       Example:
320
       Given an Analysis with an uncertainty of 0.22 for a range of
321
       results between 1 and 10 with an ExponentialFormatPrecision
322
       equal to 4 and a result of 5.234, this method will return 5.2
323
324
    If the "Calculate precision from Uncertainties" is disabled in the
325
    analysis service, the same rules described above applies, but the
326
    precision used for rounding the result is not calculated from
327
    the uncertainty. The fixed length precision is used instead.
328
329
    For further details, visit
330
    https://jira.bikalabs.com/browse/LIMS-1334
331
332
    The default decimal mark '.' will be replaced by the decimalmark
333
    specified.
334
335
    :param analysis: the analysis from which the uncertainty, precision
336
                     and other additional info have to be retrieved
337
    :param result: result to be formatted.
338
    :param decimalmark: decimal mark to use. By default '.'
339
    :param sciformat: 1. The sci notation has to be formatted as aE^+b
340
                      2. The sci notation has to be formatted as ax10^b
341
                      3. As 2, but with super html entity for exp
342
                      4. The sci notation has to be formatted as a·10^b
343
                      5. As 4, but with super html entity for exp
344
                      By default 1
345
    :result: should be a string to preserve the decimal precision.
346
    :returns: the formatted result as string
347
    """
348
    result = analysis.getResult()
349
    try:
350
        result = float(result)
351
    except ValueError:
352
        return result
353
354
    # continuing with 'nan' result will cause formatting to fail.
355
    if math.isnan(result):
356
        return result
357
358
    # Scientific notation?
359
    # Get the default precision for scientific notation
360
    threshold = analysis.getExponentialFormatPrecision()
361
    precision = analysis.getPrecision()
362
    formatted = _format_decimal_or_sci(result, precision, threshold, sciformat)
363
    return formatDecimalMark(formatted, decimalmark)
364
365
366
def create_retest(analysis, **kwargs):
367
    """Creates a retest of the given analysis
368
    """
369
    if not IRequestAnalysis.providedBy(analysis):
370
        raise ValueError("Type not supported: {}".format(repr(type(analysis))))
371
372
    # Create a copy of the original analysis
373
    parent = api.get_parent(analysis)
374
    kwargs.update({
375
        "portal_type": api.get_portal_type(analysis),
376
        "RetestOf": analysis,
377
    })
378
    retest = create_analysis(parent, analysis, **kwargs)
379
380
    # Add the retest to the same worksheet, if any
381
    worksheet = analysis.getWorksheet()
382
    if worksheet:
383
        worksheet.addAnalysis(retest)
384
385
    return retest
386
387
388
def create_duplicate(analysis, **kwargs):
389
    """Creates a duplicate of the given analysis
390
    """
391
    if not IRequestAnalysis.providedBy(analysis):
392
        raise ValueError("Type not supported: {}".format(repr(type(analysis))))
393
394
    worksheet = analysis.getWorksheet()
395
    if not worksheet:
396
        raise ValueError("Cannot create a duplicate without worksheet")
397
398
    sample_id = analysis.getRequestID()
399
    kwargs.update({
400
        "portal_type": "DuplicateAnalysis",
401
        "Analysis": analysis,
402
        "Worksheet": worksheet,
403
        "ReferenceAnalysesGroupID": "{}-D".format(sample_id),
404
    })
405
406
    return create_analysis(worksheet, analysis, **kwargs)
407
408
409
def create_reference_analysis(reference_sample, source, **kwargs):
410
    """Creates a reference analysis inside the referencesample
411
    """
412
    ref = api.get_object(reference_sample)
413
    if not IReferenceSample.providedBy(ref):
414
        raise ValueError("Type not supported: {}".format(repr(type(ref))))
415
416
    # Set the type of the reference analysis
417
    ref_type = "b" if ref.getBlank() else "c"
418
    kwargs.update({
419
        "portal_type": "ReferenceAnalysis",
420
        "ReferenceType": ref_type,
421
    })
422
    return create_analysis(ref, source, **kwargs)
423
424
425
def generate_analysis_id(instance, keyword):
426
    """Generates a new analysis ID
427
    """
428
    count = 1
429
    new_id = keyword
430
    while new_id in instance.objectIds():
431
        new_id = "{}-{}".format(keyword, count)
432
        count += 1
433
    return new_id
434
435
436
def format_interim(interim_field, html=True):
437
    """Returns a copy of the interim field plus additional attributes suitable
438
    for visualization, like formatted_result and formatted_unit
439
    """
440
    separator = "<br/>" if html else ", "
441
442
    # copy to prevent persistent changes
443
    item = copy.deepcopy(interim_field)
444
445
    # get the raw value
446
    value = item.get("value", "")
447
    values = filter(None, api.to_list(value))
448
449
    # if choices, display texts instead of values
450
    choices = item.get("choices")
451
    if choices:
452
        # generate a {value:text} dict
453
        choices = choices.split("|")
454
        choices = dict(map(lambda ch: ch.strip().split(":"), choices))
455
456
        # set the text as the formatted value
457
        texts = [choices.get(v, "") for v in values]
458
        values = filter(None, texts)
459
460
    else:
461
        # default formatting
462
        setup = api.get_setup()
463
        decimal_mark = setup.getResultsDecimalMark()
464
        values = [formatDecimalMark(val, decimal_mark) for val in values]
465
466
    item["formatted_value"] = separator.join(values)
467
468
    # unit formatting
469
    unit = item.get("unit", "")
470
    item["formatted_unit"] = format_supsub(unit) if html else unit
471
472
    return item
473