Passed
Push — master ( c0868d...0b39e4 )
by Ramon
04:34
created

bika.lims.api.analysis   A

Complexity

Total Complexity 36

Size/Duplication

Total Lines 221
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 36
eloc 104
dl 0
loc 221
rs 9.52
c 0
b 0
f 0

3 Functions

Rating   Name   Duplication   Size   Complexity  
C get_formatted_interval() 0 29 10
F is_out_of_range() 0 120 20
B is_result_range_compliant() 0 33 6
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-2019 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
from collections import Mapping
22
from bika.lims import api
23
from bika.lims.api import _marker
24
from bika.lims.config import MIN_OPERATORS, MAX_OPERATORS
25
from bika.lims.content.analysisspec import ResultsRangeDict
26
from bika.lims.interfaces import IAnalysis, IReferenceAnalysis, \
27
    IResultOutOfRange
28
from zope.component._api import getAdapters
29
30
from bika.lims.interfaces import IDuplicateAnalysis
31
from bika.lims.interfaces import ISubmitted
32
from bika.lims.interfaces.analysis import IRequestAnalysis
33
34
35
def is_out_of_range(brain_or_object, result=_marker):
36
    """Checks if the result for the analysis passed in is out of range and/or
37
    out of shoulders range.
38
39
            min                                                   max
40
            warn            min                   max             warn
41
    ·········|---------------|=====================|---------------|·········
42
    ----- out-of-range -----><----- in-range ------><----- out-of-range -----
43
             <-- shoulder --><----- in-range ------><-- shoulder -->
44
45
    :param brain_or_object: A single catalog brain or content object
46
    :param result: Tentative result. If None, use the analysis result
47
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
48
    :returns: Tuple of two elements. The first value is `True` if the result is
49
    out of range and `False` if it is in range. The second value is `True` if
50
    the result is out of shoulder range and `False` if it is in shoulder range
51
    :rtype: (bool, bool)
52
    """
53
    analysis = api.get_object(brain_or_object)
54
    if not IAnalysis.providedBy(analysis) and \
55
            not IReferenceAnalysis.providedBy(analysis):
56
        api.fail("{} is not supported. Needs to be IAnalysis or "
57
                 "IReferenceAnalysis".format(repr(analysis)))
58
59
    if result is _marker:
60
        result = api.safe_getattr(analysis, "getResult", None)
61
62
    if result in [None, '']:
63
        # Empty result
64
        return False, False
65
66
    if IDuplicateAnalysis.providedBy(analysis):
67
        # Result range for duplicate analyses is calculated from the original
68
        # result, applying a variation % in shoulders. If the analysis has
69
        # result options enabled or string results enabled, system returns an
70
        # empty result range for the duplicate: result must match %100 with the
71
        # original result
72
        original = analysis.getAnalysis()
73
        original_result = original.getResult()
74
75
        # Does original analysis have a valid result?
76
        if original_result in [None, '']:
77
            return False, False
78
79
        # Does original result type matches with duplicate result type?
80
        if api.is_floatable(result) != api.is_floatable(original_result):
81
            return True, True
82
83
        # Does analysis has result options enabled or non-floatable?
84
        if analysis.getResultOptions() or not api.is_floatable(original_result):
85
            # Let's always assume the result is 'out from shoulders', cause we
86
            # consider the shoulders are precisely the duplicate variation %
87
            out_of_range = original_result != result
88
            return out_of_range, out_of_range
89
90
    elif not api.is_floatable(result):
91
        # A non-duplicate with non-floatable result. There is no chance to know
92
        # if the result is out-of-range
93
        return False, False
94
95
    # Convert result to a float
96
    result = api.to_float(result)
97
98
    # Note that routine analyses, duplicates and reference analyses all them
99
    # implement the function getResultRange:
100
    # - For routine analyses, the function returns the valid range based on the
101
    #   specs assigned during the creation process.
102
    # - For duplicates, the valid range is the result of the analysis the
103
    #   the duplicate was generated from +/- the duplicate variation.
104
    # - For reference analyses, getResultRange returns the valid range as
105
    #   indicated in the Reference Sample from which the analysis was created.
106
    result_range = api.safe_getattr(analysis, "getResultsRange", None)
107
    if not result_range:
108
        # No result range defined or the passed in object does not suit
109
        return False, False
110
111
    # Maybe there is a custom adapter
112
    adapters = getAdapters((analysis,), IResultOutOfRange)
113
    for name, adapter in adapters:
114
        ret = adapter(result=result, specification=result_range)
115
        if not ret or not ret.get('out_of_range', False):
116
            continue
117
        if not ret.get('acceptable', True):
118
            # Out of range + out of shoulders
119
            return True, True
120
        # Out of range, but in shoulders
121
        return True, False
122
123
    result_range = ResultsRangeDict(result_range)
124
125
    # The assignment of result as default fallback for min and max guarantees
126
    # the result will be in range also if no min/max values are defined
127
    specs_min = api.to_float(result_range.min, result)
128
    specs_max = api.to_float(result_range.max, result)
129
130
    in_range = False
131
    min_operator = result_range.min_operator
132
    if min_operator == "geq":
133
        in_range = result >= specs_min
134
    else:
135
        in_range = result > specs_min
136
137
    max_operator = result_range.max_operator
138
    if in_range:
139
        if max_operator == "leq":
140
            in_range = result <= specs_max
141
        else:
142
            in_range = result < specs_max
143
144
    # If in range, no need to check shoulders
145
    if in_range:
146
        return False, False
147
148
    # Out of range, check shoulders. If no explicit warn_min or warn_max have
149
    # been defined, no shoulders must be considered for this analysis. Thus, use
150
    # specs' min and max as default fallback values
151
    warn_min = api.to_float(result_range.warn_min, specs_min)
152
    warn_max = api.to_float(result_range.warn_max, specs_max)
153
    in_shoulder = warn_min <= result <= warn_max
154
    return True, not in_shoulder
155
156
157
def get_formatted_interval(results_range, default=_marker):
158
    """Returns a string representation of the interval defined by the results
159
    range passed in
160
    :param results_range: a dict or a ResultsRangeDict
161
    """
162
    if not isinstance(results_range, Mapping):
163
        if default is not _marker:
164
            return default
165
        api.fail("Type not supported")
166
    results_range = ResultsRangeDict(results_range)
167
    min_str = results_range.min if api.is_floatable(results_range.min) else None
168
    max_str = results_range.max if api.is_floatable(results_range.max) else None
169
    if min_str is None and max_str is None:
170
        if default is not _marker:
171
            return default
172
        api.fail("Min and max values are not floatable or not defined")
173
174
    min_operator = results_range.min_operator
175
    max_operator = results_range.max_operator
176
    if max_str is None:
177
        return "{}{}".format(MIN_OPERATORS.getValue(min_operator), min_str)
178
    if min_str is None:
179
        return "{}{}".format(MAX_OPERATORS.getValue(max_operator), max_str)
180
181
    # Both values set. Return an interval
182
    min_bracket = min_operator == 'geq' and '[' or '('
183
    max_bracket = max_operator == 'leq' and ']' or ')'
184
185
    return "{}{};{}{}".format(min_bracket, min_str, max_str, max_bracket)
186
187
188
def is_result_range_compliant(analysis):
189
    """Returns whether the result range from the analysis matches with the
190
    result range for the service counterpart defined in the Sample
191
    """
192
    if not IRequestAnalysis.providedBy(analysis):
193
        return True
194
195
    if IDuplicateAnalysis.providedBy(analysis):
196
        # Does not make sense to apply compliance to a duplicate, cause its
197
        # valid range depends on the result of the original analysis
198
        return True
199
200
    rr = analysis.getResultsRange()
201
    service_uid = rr.get("uid", None)
202
    if not api.is_uid(service_uid):
203
        return True
204
205
    # Compare with Sample
206
    sample = analysis.getRequest()
207
208
    # If no Specification is set, assume is compliant
209
    specification = sample.getRawSpecification()
210
    if not specification:
211
        return True
212
213
    # Compare with the Specification that was initially set to the Sample
214
    sample_rr = sample.getResultsRange(search_by=service_uid)
215
    if not sample_rr:
216
        # This service is not defined in Sample's ResultsRange, we
217
        # assume this *does not* break the compliance
218
        return True
219
220
    return rr == sample_rr
221