Passed
Push — 2.x ( 30532a...211428 )
by Jordi
05:19
created

bika.lims.utils.analysis.create_retest()   A

Complexity

Conditions 4

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 17
dl 0
loc 28
rs 9.55
c 0
b 0
f 0
cc 4
nop 1
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 copy
22
import math
23
24
import zope.event
25
from bika.lims import api
26
from bika.lims.interfaces import IAnalysisService
27
from bika.lims.interfaces.analysis import IRequestAnalysis
28
from bika.lims.utils import formatDecimalMark
29
from Products.Archetypes.event import ObjectInitializedEvent
30
from Products.CMFPlone.utils import _createObjectByType
31
32
33
def duplicateAnalysis(analysis):
34
    """
35
    Duplicate an analysis consist on creating a new analysis with
36
    the same analysis service for the same sample. It is used in
37
    order to reduce the error procedure probability because both
38
    results must be similar.
39
    :base: the analysis object used as the creation base.
40
    """
41
    ar = analysis.aq_parent
42
    kw = analysis.getKeyword()
43
    # Rename the analysis to make way for it's successor.
44
    # Support multiple duplicates by renaming to *-0, *-1, etc
45
    cnt = [x for x in ar.objectValues("Analysis") if x.getId().startswith(kw)]
46
    a_id = "{0}-{1}".format(kw, len(cnt))
47
    dup = create_analysis(ar, analysis, id=a_id, Retested=True)
48
    return dup
49
50
51
def copy_analysis_field_values(source, analysis, **kwargs):
52
    src_schema = source.Schema()
53
    dst_schema = analysis.Schema()
54
    # Some fields should not be copied from source!
55
    # BUT, if these fieldnames are present in kwargs, the value will
56
    # be set accordingly.
57
    IGNORE_FIELDNAMES = [
58
        'UID', 'id', 'allowDiscussion', 'subject', 'location', 'contributors',
59
        'creators', 'effectiveDate', 'expirationDate', 'language', 'rights',
60
        'creation_date', 'modification_date', 'Hidden', 'Attachment']
61
    for field in src_schema.fields():
62
        fieldname = field.getName()
63
        if fieldname in IGNORE_FIELDNAMES and fieldname not in kwargs:
64
            continue
65
        if fieldname not in dst_schema:
66
            continue
67
        value = kwargs.get(fieldname, field.get(source))
68
69
        # Campbell's mental note:never ever use '.set()' directly to a
70
        # field. If you can't use the setter, then use the mutator in order
71
        # to give the value. We have realized that in some cases using
72
        # 'set' when the value is a string, it saves the value
73
        # as unicode instead of plain string.
74
        field = analysis.getField(fieldname)
75
        mutator = getattr(field, "mutator", None)
76
        if mutator:
77
            mutator_name = field.mutator
78
            mutator = getattr(analysis, mutator_name)
79
            mutator(value)
80
        else:
81
            field.set(analysis, value)
82
83
84
def create_analysis(context, source, **kwargs):
85
    """Create a new Analysis.  The source can be an Analysis Service or
86
    an existing Analysis, and all possible field values will be set to the
87
    values found in the source object.
88
    :param context: The analysis will be created inside this object.
89
    :param source: The schema of this object will be used to populate analysis.
90
    :param kwargs: The values of any keys which match schema fieldnames will
91
    be inserted into the corresponding fields in the new analysis.
92
    :returns: Analysis object that was created
93
    :rtype: Analysis
94
    """
95
    an_id = kwargs.get('id', source.getKeyword())
96
    analysis = _createObjectByType("Analysis", context, an_id)
97
    copy_analysis_field_values(source, analysis, **kwargs)
98
99
    # AnalysisService field is not present on actual AnalysisServices.
100
    if IAnalysisService.providedBy(source):
101
        analysis.setAnalysisService(source)
102
    else:
103
        analysis.setAnalysisService(source.getAnalysisService())
104
105
    # Set the interims from the Service
106
    service_interims = analysis.getAnalysisService().getInterimFields()
107
    # Avoid references from the analysis interims to the service interims
108
    service_interims = copy.deepcopy(service_interims)
109
    analysis.setInterimFields(service_interims)
110
111
    analysis.unmarkCreationFlag()
112
    zope.event.notify(ObjectInitializedEvent(analysis))
113
    return analysis
114
115
116
def get_significant_digits(numeric_value):
117
    """
118
    Returns the precision for a given floatable value.
119
    If value is None or not floatable, returns None.
120
    Will return positive values if the result is below 1 and will
121
    return 0 values if the result is above or equal to 1.
122
    :param numeric_value: the value to get the precision from
123
    :returns: the numeric_value's precision
124
            Examples:
125
            numeric_value     Returns
126
            0               0
127
            0.22            1
128
            1.34            0
129
            0.0021          3
130
            0.013           2
131
            2               0
132
            22              0
133
    """
134
    try:
135
        numeric_value = float(numeric_value)
136
    except (TypeError, ValueError):
137
        return None
138
    if numeric_value == 0:
139
        return 0
140
    significant_digit = int(math.floor(math.log10(abs(numeric_value))))
141
    return 0 if significant_digit > 0 else abs(significant_digit)
142
143
144
def _format_decimal_or_sci(result, precision, threshold, sciformat):
145
    # Current result's precision is above the threshold?
146
    sig_digits = get_significant_digits(result)
147
148
    # Note that if result < 1, sig_digits > 0. Otherwise, sig_digits = 0
149
    # Eg:
150
    #       result = 0.2   -> sig_digit = 1
151
    #                0.002 -> sig_digit = 3
152
    #                0     -> sig_digit = 0
153
    #                2     -> sig_digit = 0
154
    # See get_significant_digits signature for further details!
155
    #
156
    # Also note if threshold is negative, the result will always be expressed
157
    # in scientific notation:
158
    # Eg.
159
    #       result=12345, threshold=-3, sig_digit=0 -> 1.2345e4 = 1.2345·10⁴
160
    #
161
    # So, if sig_digits is > 0, the power must be expressed in negative
162
    # Eg.
163
    #      result=0.0012345, threshold=3, sig_digit=3 -> 1.2345e-3=1.2345·10-³
164
    sci = sig_digits >= threshold and abs(
165
        threshold) > 0 and sig_digits <= precision
166
    sign = '-' if sig_digits > 0 else ''
167
    if sig_digits == 0 and abs(threshold) > 0 and abs(int(float(result))) > 0:
168
        # Number >= 1, need to check if the number of non-decimal
169
        # positions is above the threshold
170
        sig_digits = int(math.log(abs(float(result)), 10)) if abs(
171
            float(result)) >= 10 else 0
172
        sci = sig_digits >= abs(threshold)
173
174
    formatted = ''
175
    if sci:
176
        # First, cut the extra decimals according to the precision
177
        prec = precision if precision and precision > 0 else 0
178
        nresult = str("%%.%sf" % prec) % api.to_float(result, 0)
179
180
        if sign:
181
            # 0.0012345 -> 1.2345
182
            res = float(nresult) * (10 ** sig_digits)
183
        else:
184
            # Non-decimal positions
185
            # 123.45 -> 1.2345
186
            res = float(nresult) / (10 ** sig_digits)
187
        res = int(res) if res.is_integer() else res
188
189
        # Scientific notation
190
        if sciformat == 2:
191
            # ax10^b or ax10^-b
192
            formatted = "%s%s%s%s" % (res, "x10^", sign, sig_digits)
193
        elif sciformat == 3:
194
            # ax10<super>b</super> or ax10<super>-b</super>
195
            formatted = "%s%s%s%s%s" % (
196
                res, "x10<sup>", sign, sig_digits, "</sup>")
197
        elif sciformat == 4:
198
            # ax10^b or ax10^-b
199
            formatted = "%s%s%s%s" % (res, "·10^", sign, sig_digits)
200
        elif sciformat == 5:
201
            # ax10<super>b</super> or ax10<super>-b</super>
202
            formatted = "%s%s%s%s%s" % (
203
                res, "·10<sup>", sign, sig_digits, "</sup>")
204
        else:
205
            # Default format: aE^+b
206
            sig_digits = "%02d" % sig_digits
207
            formatted = "%s%s%s%s" % (res, "e", sign, sig_digits)
208
    else:
209
        # Decimal notation
210
        prec = precision if precision and precision > 0 else 0
211
        formatted = str("%%.%sf" % prec) % api.to_float(result, 0)
212
        if float(formatted) == 0 and '-' in formatted:
213
            # We don't want things like '-0.00'
214
            formatted = formatted.replace('-', '')
215
    return formatted
216
217
218
def format_uncertainty(analysis, result, decimalmark='.', sciformat=1):
219
    """
220
    Returns the formatted uncertainty according to the analysis, result
221
    and decimal mark specified following these rules:
222
223
    If the "Calculate precision from uncertainties" is enabled in
224
    the Analysis service, and
225
226
    a) If the non-decimal number of digits of the result is above
227
       the service's ExponentialFormatPrecision, the uncertainty will
228
       be formatted in scientific notation. The uncertainty exponential
229
       value used will be the same as the one used for the result. The
230
       uncertainty will be rounded according to the same precision as
231
       the result.
232
233
       Example:
234
       Given an Analysis with an uncertainty of 37 for a range of
235
       results between 30000 and 40000, with an
236
       ExponentialFormatPrecision equal to 4 and a result of 32092,
237
       this method will return 0.004E+04
238
239
    b) If the number of digits of the integer part of the result is
240
       below the ExponentialFormatPrecision, the uncertainty will be
241
       formatted as decimal notation and the uncertainty will be
242
       rounded one position after reaching the last 0 (precision
243
       calculated according to the uncertainty value).
244
245
       Example:
246
       Given an Analysis with an uncertainty of 0.22 for a range of
247
       results between 1 and 10 with an ExponentialFormatPrecision
248
       equal to 4 and a result of 5.234, this method will return 0.2
249
250
    If the "Calculate precision from Uncertainties" is disabled in the
251
    analysis service, the same rules described above applies, but the
252
    precision used for rounding the uncertainty is not calculated from
253
    the uncertainty neither the result. The fixed length precision is
254
    used instead.
255
256
    For further details, visit
257
    https://jira.bikalabs.com/browse/LIMS-1334
258
259
    If the result is not floatable or no uncertainty defined, returns
260
    an empty string.
261
262
    The default decimal mark '.' will be replaced by the decimalmark
263
    specified.
264
265
    :param analysis: the analysis from which the uncertainty, precision
266
                     and other additional info have to be retrieved
267
    :param result: result of the analysis. Used to retrieve and/or
268
                   calculate the precision and/or uncertainty
269
    :param decimalmark: decimal mark to use. By default '.'
270
    :param sciformat: 1. The sci notation has to be formatted as aE^+b
271
                  2. The sci notation has to be formatted as ax10^b
272
                  3. As 2, but with super html entity for exp
273
                  4. The sci notation has to be formatted as a·10^b
274
                  5. As 4, but with super html entity for exp
275
                  By default 1
276
    :returns: the formatted uncertainty
277
    """
278
    try:
279
        result = float(result)
280
    except ValueError:
281
        return ""
282
283
    objres = None
284
    try:
285
        objres = float(analysis.getResult())
286
    except ValueError:
287
        pass
288
289
    uncertainty = None
290
    if result == objres:
291
        # To avoid problems with DLs
292
        uncertainty = analysis.getUncertainty()
293
    else:
294
        uncertainty = analysis.getUncertainty(result)
295
296
    if not uncertainty:
297
        return ""
298
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
310
    # Scientific notation?
311
    # Get the default precision for scientific notation
312
    threshold = analysis.getExponentialFormatPrecision()
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
320
    return formatDecimalMark(formatted, decimalmark)
321
322
323
def format_numeric_result(analysis, result, decimalmark='.', sciformat=1):
324
    """
325
    Returns the formatted number part of a results value.  This is
326
    responsible for deciding the precision, and notation of numeric
327
    values in accordance to the uncertainty. If a non-numeric
328
    result value is given, the value will be returned unchanged.
329
330
    The following rules apply:
331
332
    If the "Calculate precision from uncertainties" is enabled in
333
    the Analysis service, and
334
335
    a) If the non-decimal number of digits of the result is above
336
       the service's ExponentialFormatPrecision, the result will
337
       be formatted in scientific notation.
338
339
       Example:
340
       Given an Analysis with an uncertainty of 37 for a range of
341
       results between 30000 and 40000, with an
342
       ExponentialFormatPrecision equal to 4 and a result of 32092,
343
       this method will return 3.2092E+04
344
345
    b) If the number of digits of the integer part of the result is
346
       below the ExponentialFormatPrecision, the result will be
347
       formatted as decimal notation and the resulta will be rounded
348
       in accordance to the precision (calculated from the uncertainty)
349
350
       Example:
351
       Given an Analysis with an uncertainty of 0.22 for a range of
352
       results between 1 and 10 with an ExponentialFormatPrecision
353
       equal to 4 and a result of 5.234, this method will return 5.2
354
355
    If the "Calculate precision from Uncertainties" is disabled in the
356
    analysis service, the same rules described above applies, but the
357
    precision used for rounding the result is not calculated from
358
    the uncertainty. The fixed length precision is used instead.
359
360
    For further details, visit
361
    https://jira.bikalabs.com/browse/LIMS-1334
362
363
    The default decimal mark '.' will be replaced by the decimalmark
364
    specified.
365
366
    :param analysis: the analysis from which the uncertainty, precision
367
                     and other additional info have to be retrieved
368
    :param result: result to be formatted.
369
    :param decimalmark: decimal mark to use. By default '.'
370
    :param sciformat: 1. The sci notation has to be formatted as aE^+b
371
                      2. The sci notation has to be formatted as ax10^b
372
                      3. As 2, but with super html entity for exp
373
                      4. The sci notation has to be formatted as a·10^b
374
                      5. As 4, but with super html entity for exp
375
                      By default 1
376
    :result: should be a string to preserve the decimal precision.
377
    :returns: the formatted result as string
378
    """
379
    try:
380
        result = float(result)
381
    except ValueError:
382
        return result
383
384
    # continuing with 'nan' result will cause formatting to fail.
385
    if math.isnan(result):
386
        return result
387
388
    # Scientific notation?
389
    # Get the default precision for scientific notation
390
    threshold = analysis.getExponentialFormatPrecision()
391
    precision = analysis.getPrecision(result)
392
    formatted = _format_decimal_or_sci(result, precision, threshold, sciformat)
393
    return formatDecimalMark(formatted, decimalmark)
394
395
396
def create_retest(analysis):
397
    """Creates a retest of the given analysis
398
    """
399
    if not IRequestAnalysis.providedBy(analysis):
400
        raise ValueError("Type not supported: {}".format(repr(type(analysis))))
401
402
    # Support multiple retests by prefixing keyword with *-0, *-1, etc.
403
    parent = api.get_parent(analysis)
404
    keyword = analysis.getKeyword()
405
406
    # Get only those analyses with same keyword as original
407
    analyses = parent.getAnalyses(full_objects=True)
408
    analyses = filter(lambda an: an.getKeyword() == keyword, analyses)
409
    new_id = '{}-{}'.format(keyword, len(analyses))
410
411
    # Create a copy of the original analysis
412
    an_uid = api.get_uid(analysis)
413
    retest = create_analysis(parent, analysis, id=new_id, RetestOf=an_uid)
414
    retest.setResult("")
415
    retest.setResultCaptureDate(None)
416
417
    # Add the retest to the same worksheet, if any
418
    worksheet = analysis.getWorksheet()
419
    if worksheet:
420
        worksheet.addAnalysis(retest)
421
422
    retest.reindexObject()
423
    return retest
424