Passed
Push — 2.x ( d668b2...78a11b )
by Ramon
05:54
created

bika.lims.utils.analysis   B

Complexity

Total Complexity 48

Size/Duplication

Total Lines 432
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 48
eloc 159
dl 0
loc 432
rs 8.5599
c 0
b 0
f 0

9 Functions

Rating   Name   Duplication   Size   Complexity  
A create_retest() 0 20 3
A create_reference_analysis() 0 14 3
C format_uncertainty() 0 103 9
A format_numeric_result() 0 71 3
A generate_analysis_id() 0 9 2
A get_significant_digits() 0 26 4
A create_analysis() 0 51 4
A create_duplicate() 0 19 3
F _format_decimal_or_sci() 0 72 17

How to fix   Complexity   

Complexity

Complex classes like bika.lims.utils.analysis often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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, result, decimalmark='.', sciformat=1):
187
    """
188
    Returns the formatted uncertainty according to the analysis, result
189
    and decimal mark specified following these rules:
190
191
    If the "Calculate precision from uncertainties" is enabled in
192
    the Analysis service, and
193
194
    a) If the non-decimal number of digits of the result is above
195
       the service's ExponentialFormatPrecision, the uncertainty will
196
       be formatted in scientific notation. The uncertainty exponential
197
       value used will be the same as the one used for the result. The
198
       uncertainty will be rounded according to the same precision as
199
       the result.
200
201
       Example:
202
       Given an Analysis with an uncertainty of 37 for a range of
203
       results between 30000 and 40000, with an
204
       ExponentialFormatPrecision equal to 4 and a result of 32092,
205
       this method will return 0.004E+04
206
207
    b) If the number of digits of the integer part of the result is
208
       below the ExponentialFormatPrecision, the uncertainty will be
209
       formatted as decimal notation and the uncertainty will be
210
       rounded one position after reaching the last 0 (precision
211
       calculated according to the uncertainty value).
212
213
       Example:
214
       Given an Analysis with an uncertainty of 0.22 for a range of
215
       results between 1 and 10 with an ExponentialFormatPrecision
216
       equal to 4 and a result of 5.234, this method will return 0.2
217
218
    If the "Calculate precision from Uncertainties" is disabled in the
219
    analysis service, the same rules described above applies, but the
220
    precision used for rounding the uncertainty is not calculated from
221
    the uncertainty neither the result. The fixed length precision is
222
    used instead.
223
224
    For further details, visit
225
    https://jira.bikalabs.com/browse/LIMS-1334
226
227
    If the result is not floatable or no uncertainty defined, returns
228
    an empty string.
229
230
    The default decimal mark '.' will be replaced by the decimalmark
231
    specified.
232
233
    :param analysis: the analysis from which the uncertainty, precision
234
                     and other additional info have to be retrieved
235
    :param result: result of the analysis. Used to retrieve and/or
236
                   calculate the precision and/or uncertainty
237
    :param decimalmark: decimal mark to use. By default '.'
238
    :param sciformat: 1. The sci notation has to be formatted as aE^+b
239
                  2. The sci notation has to be formatted as ax10^b
240
                  3. As 2, but with super html entity for exp
241
                  4. The sci notation has to be formatted as a·10^b
242
                  5. As 4, but with super html entity for exp
243
                  By default 1
244
    :returns: the formatted uncertainty
245
    """
246
    try:
247
        result = float(result)
248
    except ValueError:
249
        return ""
250
251
    objres = None
252
    try:
253
        objres = float(analysis.getResult())
254
    except ValueError:
255
        pass
256
257
    uncertainty = None
258
    if result == objres:
259
        # To avoid problems with DLs
260
        uncertainty = analysis.getUncertainty()
261
    else:
262
        uncertainty = analysis.getUncertainty(result)
263
264
    if not uncertainty:
265
        return ""
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(result)
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
    if "." in formatted:
286
        formatted = formatted.rstrip("0").rstrip(".")
287
288
    return formatDecimalMark(formatted, decimalmark)
289
290
291
def format_numeric_result(analysis, result, decimalmark='.', sciformat=1):
292
    """
293
    Returns the formatted number part of a results value.  This is
294
    responsible for deciding the precision, and notation of numeric
295
    values in accordance to the uncertainty. If a non-numeric
296
    result value is given, the value will be returned unchanged.
297
298
    The following rules apply:
299
300
    If the "Calculate precision from uncertainties" is enabled in
301
    the Analysis service, and
302
303
    a) If the non-decimal number of digits of the result is above
304
       the service's ExponentialFormatPrecision, the result will
305
       be formatted in scientific notation.
306
307
       Example:
308
       Given an Analysis with an uncertainty of 37 for a range of
309
       results between 30000 and 40000, with an
310
       ExponentialFormatPrecision equal to 4 and a result of 32092,
311
       this method will return 3.2092E+04
312
313
    b) If the number of digits of the integer part of the result is
314
       below the ExponentialFormatPrecision, the result will be
315
       formatted as decimal notation and the resulta will be rounded
316
       in accordance to the precision (calculated from the uncertainty)
317
318
       Example:
319
       Given an Analysis with an uncertainty of 0.22 for a range of
320
       results between 1 and 10 with an ExponentialFormatPrecision
321
       equal to 4 and a result of 5.234, this method will return 5.2
322
323
    If the "Calculate precision from Uncertainties" is disabled in the
324
    analysis service, the same rules described above applies, but the
325
    precision used for rounding the result is not calculated from
326
    the uncertainty. The fixed length precision is used instead.
327
328
    For further details, visit
329
    https://jira.bikalabs.com/browse/LIMS-1334
330
331
    The default decimal mark '.' will be replaced by the decimalmark
332
    specified.
333
334
    :param analysis: the analysis from which the uncertainty, precision
335
                     and other additional info have to be retrieved
336
    :param result: result to be formatted.
337
    :param decimalmark: decimal mark to use. By default '.'
338
    :param sciformat: 1. The sci notation has to be formatted as aE^+b
339
                      2. The sci notation has to be formatted as ax10^b
340
                      3. As 2, but with super html entity for exp
341
                      4. The sci notation has to be formatted as a·10^b
342
                      5. As 4, but with super html entity for exp
343
                      By default 1
344
    :result: should be a string to preserve the decimal precision.
345
    :returns: the formatted result as string
346
    """
347
    try:
348
        result = float(result)
349
    except ValueError:
350
        return result
351
352
    # continuing with 'nan' result will cause formatting to fail.
353
    if math.isnan(result):
354
        return result
355
356
    # Scientific notation?
357
    # Get the default precision for scientific notation
358
    threshold = analysis.getExponentialFormatPrecision()
359
    precision = analysis.getPrecision(result)
360
    formatted = _format_decimal_or_sci(result, precision, threshold, sciformat)
361
    return formatDecimalMark(formatted, decimalmark)
362
363
364
def create_retest(analysis, **kwargs):
365
    """Creates a retest of the given analysis
366
    """
367
    if not IRequestAnalysis.providedBy(analysis):
368
        raise ValueError("Type not supported: {}".format(repr(type(analysis))))
369
370
    # Create a copy of the original analysis
371
    parent = api.get_parent(analysis)
372
    kwargs.update({
373
        "portal_type": api.get_portal_type(analysis),
374
        "RetestOf": analysis,
375
    })
376
    retest = create_analysis(parent, analysis, **kwargs)
377
378
    # Add the retest to the same worksheet, if any
379
    worksheet = analysis.getWorksheet()
380
    if worksheet:
381
        worksheet.addAnalysis(retest)
382
383
    return retest
384
385
386
def create_duplicate(analysis, **kwargs):
387
    """Creates a duplicate of the given analysis
388
    """
389
    if not IRequestAnalysis.providedBy(analysis):
390
        raise ValueError("Type not supported: {}".format(repr(type(analysis))))
391
392
    worksheet = analysis.getWorksheet()
393
    if not worksheet:
394
        raise ValueError("Cannot create a duplicate without worksheet")
395
396
    sample_id = analysis.getRequestID()
397
    kwargs.update({
398
        "portal_type": "DuplicateAnalysis",
399
        "Analysis": analysis,
400
        "Worksheet": worksheet,
401
        "ReferenceAnalysesGroupID": "{}-D".format(sample_id),
402
    })
403
404
    return create_analysis(worksheet, analysis, **kwargs)
405
406
407
def create_reference_analysis(reference_sample, source, **kwargs):
408
    """Creates a reference analysis inside the referencesample
409
    """
410
    ref = api.get_object(reference_sample)
411
    if not IReferenceSample.providedBy(ref):
412
        raise ValueError("Type not supported: {}".format(repr(type(ref))))
413
414
    # Set the type of the reference analysis
415
    ref_type = "b" if ref.getBlank() else "c"
416
    kwargs.update({
417
        "portal_type": "ReferenceAnalysis",
418
        "ReferenceType": ref_type,
419
    })
420
    return create_analysis(ref, source, **kwargs)
421
422
423
def generate_analysis_id(instance, keyword):
424
    """Generates a new analysis ID
425
    """
426
    count = 1
427
    new_id = keyword
428
    while new_id in instance.objectIds():
429
        new_id = "{}-{}".format(keyword, count)
430
        count += 1
431
    return new_id
432