bika.lims.api.analysis.is_result_range_compliant()   B
last analyzed

Complexity

Conditions 6

Size

Total Lines 33
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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