Skip to content

Commit

Permalink
Merge branch '2.x' into fix-2d-csv-interface
Browse files Browse the repository at this point in the history
  • Loading branch information
ramonski authored Mar 11, 2025
2 parents 8e29a2e + e592c34 commit 61c5bda
Show file tree
Hide file tree
Showing 13 changed files with 1,014 additions and 169 deletions.
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Changelog
------------------

- #2689 Fix 2-Dimensional CSV import interface
- #2682 Added Limit of Quantification (LOQ) for services and analyses
- #2687 Remove legacy and obsolete rejection.js
- #2685 Fix missing default instrument import template
- #2684 Fix Traceback for unicode titled instruments
Expand Down
292 changes: 156 additions & 136 deletions src/bika/lims/content/abstractanalysis.py

Large diffs are not rendered by default.

121 changes: 101 additions & 20 deletions src/bika/lims/content/abstractbaseanalysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,37 +175,98 @@
)
)

# If the value is below this limit, it means that the measurement lacks
# accuracy and this will be shown in manage_results and also on the final
# report.
LowerDetectionLimit = StringField(
"LowerDetectionLimit",
schemata="Analysis",
schemata="Limits",
default="0.0",
validators=("lower_limit_of_detection_validator",),
widget=DecimalWidget(
label=_(
u"label_analysis_lower_limit_of_detection_title",
default=u"Lower Limit of Detection (LLOD)"
),
description=_(
u"label_analysis_lower_limit_of_detection_description",
default=u"The Lower Limit of Detection (LLOD) is the lowest "
u"concentration of a parameter that can be reliably "
u"detected by a specified testing methodology with a "
u"defined level of confidence. Results below this "
u"threshold are typically reported as '< LLOD' (or 'Not "
u"Detected'), indicating that the parameter's "
u"concentration, if present, is below the detection "
u"capability of the method at a reliable level."
)
)
)

LowerLimitOfQuantification = StringField(
"LowerLimitOfQuantification",
schemata="Limits",
default="0.0",
validators=("lower_limit_of_quantification_validator",),
widget=DecimalWidget(
label=_(
u"label_analysis_lower_limit_of_quantification_title",
default=u"Lower Limit Of Quantification (LLOQ)"
),
description=_(
u"label_analysis_lower_limit_of_quantification_description",
default=u"The Lower Limit of Quantification (LLOQ) is the lowest "
u"concentration of a parameter that can be reliably and "
u"accurately measured using the specified testing "
u"methodology, with acceptable levels of precision and "
u"accuracy. Results below this value cannot be quantified "
u"with confidence and are typically reported as '< LOQ' "
u"(or 'Detected but < LOQ'), indicating that while the "
u"parameter may be present, its exact concentration "
u"cannot be determined reliably."
)
)
)

UpperLimitOfQuantification = StringField(
"UpperLimitOfQuantification",
schemata="Limits",
default="1000000000.0",
validators=("upper_limit_of_quantification_validator",),
widget=DecimalWidget(
label=_("Lower Detection Limit (LDL)"),
label=_(
u"label_analysis_upper_limit_of_quantification_title",
default=u"Upper Limit Of Quantification (ULOQ)"),
description=_(
"The Lower Detection Limit is the lowest value to which the "
"measured parameter can be measured using the specified testing "
"methodology. Results entered which are less than this value will "
"be reported as < LDL")
u"label_analysis_upper_limit_of_quantification_description",
default=u"The Upper Limit of Quantification (ULOQ) is the highest "
u"concentration of a parameter that can be reliably and "
u"accurately measured using the specified testing "
u"methodology, with acceptable levels of precision and "
u"accuracy. Results above this value cannot be quantified "
u"with confidence and are typically reported as '> ULOQ', "
u"indicating that its exact concentration cannot be "
u"determined reliably."
)
)
)

# If the value is above this limit, it means that the measurement lacks
# accuracy and this will be shown in manage_results and also on the final
# report.
UpperDetectionLimit = StringField(
"UpperDetectionLimit",
schemata="Analysis",
schemata="Limits",
default="1000000000.0",
widget=DecimalWidget(
label=_("Upper Detection Limit (UDL)"),
label=_(
u"label_analysis_upper_limit_of_detection_title",
default=u"Upper Limit of Detection (ULOD)"),
description=_(
"The Upper Detection Limit is the highest value to which the "
"measured parameter can be measured using the specified testing "
"methodology. Results entered which are greater than this value "
"will be reported as > UDL")
u"label_analysis_upper_limit_of_detection_description",
default=u"The Upper Limit of Detection (ULOD) is the highest "
u"concentration of a parameter that can be reliably "
u"measured using a specified testing methodology. Beyond "
u"this limit, results may no longer be accurate or valid "
u"due to instrument saturation or methodological "
u"limitations. Results exceeding this threshold are "
u"typically reported as '> ULOD', indicating that the "
u"parameter's concentration is above the reliable "
u"detection range of the method."
)
)
)

Expand All @@ -230,7 +291,7 @@
# displayed in the results table.
DetectionLimitSelector = BooleanField(
'DetectionLimitSelector',
schemata="Analysis",
schemata="Limits",
default=False,
widget=BooleanWidget(
label=_("Display a Detection Limit selector"),
Expand All @@ -249,7 +310,7 @@
# further information.
AllowManualDetectionLimit = BooleanField(
'AllowManualDetectionLimit',
schemata="Analysis",
schemata="Limits",
default=False,
widget=BooleanWidget(
label=_("Allow Manual Detection Limit input"),
Expand Down Expand Up @@ -804,6 +865,8 @@
Precision,
ExponentialFormatPrecision,
LowerDetectionLimit,
LowerLimitOfQuantification,
UpperLimitOfQuantification,
UpperDetectionLimit,
DetectionLimitSelector,
AllowManualDetectionLimit,
Expand Down Expand Up @@ -962,6 +1025,24 @@ def getUpperDetectionLimit(self):
value = value.rstrip("0").rstrip(".")
return value

@security.public
def setLowerLimitOfQuantification(self, value):
"""Sets the Lower Limit of Quantification (LLOQ) and ensures its value
is stored as a string without exponential notation and with whole
fraction preserved
"""
value = api.float_to_string(value)
self.getField("LowerLimitOfQuantification").set(self, value)

@security.public
def setUpperLimitOfQuantification(self, value):
"""Sets the Upper Limit of Quantification (ULOW) and ensures its value
is stored as a string without exponential notation and with whole
fraction preserved
"""
value = api.float_to_string(value)
self.getField("UpperLimitOfQuantification").set(self, value)

@security.public
def isSelfVerificationEnabled(self):
"""Returns if the user that submitted a result for this analysis must
Expand Down
28 changes: 21 additions & 7 deletions src/bika/lims/utils/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,23 @@ def format_uncertainty(analysis, decimalmark=".", sciformat=1):
By default 1
:returns: the formatted uncertainty
"""
try:
result = float(analysis.getResult())
except (ValueError, TypeError):
pass
if not api.is_floatable(analysis.getResult()):
# do not display uncertainty, result is not floatable
return ""

if analysis.isOutsideTheQuantifiableRange():
# Displaying uncertainty for results outside the quantifiable range is
# not meaningful because the Lower Limit of Quantification (LLOQ) and
# Upper Limit of Quantification (ULOQ) define the range within which
# a parameter can be reliably and accurately measured. Results outside
# this range are prone to significant variability and may be
# indistinguishable from background noise or method imprecision.
# As such, any numeric value reported outside the quantifiable range
# lacks the reliability required for meaningful interpretation.
# It is important to note that the quantifiable range is always nested
# within the detection range, which is defined by the Lower Limit of
# Detection (LLOD) and Upper Limit of Detection (ULOD).
return ""

uncertainty = analysis.getUncertainty()
if api.to_float(uncertainty, default=-1) < 0:
Expand All @@ -260,7 +273,7 @@ def format_uncertainty(analysis, decimalmark=".", sciformat=1):
precision = uncertainty[::-1].find(".")

if precision == -1:
precision = analysis.getPrecision(result)
precision = analysis.getPrecision()

# Scientific notation?
# Get the default precision for scientific notation
Expand All @@ -276,7 +289,7 @@ def format_uncertainty(analysis, decimalmark=".", sciformat=1):
return formatDecimalMark(formatted, decimalmark)


def format_numeric_result(analysis, result, decimalmark='.', sciformat=1):
def format_numeric_result(analysis, decimalmark='.', sciformat=1):
"""
Returns the formatted number part of a results value. This is
responsible for deciding the precision, and notation of numeric
Expand Down Expand Up @@ -332,6 +345,7 @@ def format_numeric_result(analysis, result, decimalmark='.', sciformat=1):
:result: should be a string to preserve the decimal precision.
:returns: the formatted result as string
"""
result = analysis.getResult()
try:
result = float(result)
except ValueError:
Expand All @@ -344,7 +358,7 @@ def format_numeric_result(analysis, result, decimalmark='.', sciformat=1):
# Scientific notation?
# Get the default precision for scientific notation
threshold = analysis.getExponentialFormatPrecision()
precision = analysis.getPrecision(result)
precision = analysis.getPrecision()
formatted = _format_decimal_or_sci(result, precision, threshold, sciformat)
return formatDecimalMark(formatted, decimalmark)

Expand Down
98 changes: 95 additions & 3 deletions src/bika/lims/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@
from bika.lims import logger
from bika.lims.api import APIError
from bika.lims.catalog import SETUP_CATALOG
from senaite.core.i18n import translate as _t
from bika.lims.utils import to_utf8
from Products.CMFCore.utils import getToolByName
from Products.CMFPlone.utils import safe_unicode
from Products.validation import validation
from Products.validation.interfaces.IValidator import IValidator
from Products.ZCTextIndex.ParseTree import ParseError
from senaite.core.i18n import translate as _t
from zope.interface import implements


Expand Down Expand Up @@ -501,8 +501,13 @@ def __call__(self, value, *args, **kwargs):
})
return to_utf8(translate(msg))

# Allow to use Wildcards, LDL and UDL values in calculations
allowedwds = ["LDL", "UDL", "BELOWLDL", "ABOVEUDL"]
# Allow to use Wildcards, LDL, UDL and LLOQ values in calculations
allowedwds = [
"LDL", "BELOWLDL",
"UDL", "ABOVEUDL",
"LOQ", "LLOQ", "BELOWLOQ", "BELOWLLOQ",
"ULOQ", "ABOVEULOQ",
]
keysandwildcards = re.compile(r"\[([^\]]+)\]").findall(value)
keysandwildcards = [k for k in keysandwildcards if "." in k]
keysandwildcards = [k.split(".", 1) for k in keysandwildcards]
Expand Down Expand Up @@ -1456,3 +1461,90 @@ def validate_record(self, record):


validation.register(ServiceConditionsValidator())


class LowerLimitOfDetectionValidator(object):
"""Validates that the Lower Limit of Detection (LLOD) is lower than or
equal to the Lower Limit of Quantification (LLOQ)
"""
implements(IValidator)
name = "lower_limit_of_detection_validator"

def __call__(self, value, **kwargs):
instance = kwargs["instance"]
field_name = kwargs["field"].getName()

# get the value (or fallback to field's default)
default = instance.getField(field_name).getDefault(instance)
llod = api.to_float(value, default)

form = kwargs["REQUEST"].form
lloq = form.get("LowerLimitOfQuantification", None)
lloq = api.to_float(lloq, llod)
if llod > lloq:
return _t(_(
u"validator_llod_above_lloq",
default=u"The Lower Limit of Detection (LLOD) cannot be "
u"greater than the Lower Limit of Quantification "
u"(LLOQ)."
))


class LowerLimitOfQuantificationValidator(object):
"""Validates that the Lower Limit of Quantification (LLOQ) is lower than
the Upper Limit of Quantification (ULOQ)
"""
implements(IValidator)
name = "lower_limit_of_quantification_validator"

def __call__(self, value, **kwargs):
instance = kwargs["instance"]
field_name = kwargs["field"].getName()

# get the value (or fallback to field's default)
default = instance.getField(field_name).getDefault(instance)
lloq = api.to_float(value, default)

# compare with the lower limit of detection
form = kwargs["REQUEST"].form
uloq = form.get("UpperLimitOfQuantification", None)
uloq = api.to_float(uloq, lloq)
if lloq >= uloq:
return _t(_(
u"validator_lloq_above_uloq",
default=u"The Lower Limit of Quantification (LLOQ) cannot be "
u"greater than or equal to the Upper Limit of "
u"Quantification (ULOQ)."
))


class UpperLimitOfQuantificationValidator(object):
"""Validates that the Upper Limit of Quantification (ULOD) is lower than
or equal to the Upper Limit of Detection (ULOD)
"""
implements(IValidator)
name = "upper_limit_of_quantification_validator"

def __call__(self, value, **kwargs):
instance = kwargs["instance"]
field_name = kwargs["field"].getName()

# get the value (or fallback to field's default)
default = instance.getField(field_name).getDefault(instance)
uloq = api.to_float(value, default)

# compare with the lower limit of detection
form = kwargs["REQUEST"].form
ulod = form.get("UpperDetectionLimit", None)
ulod = api.to_float(ulod, uloq)
if uloq > ulod:
return _t(_(
u"validator_uloq_above_ulod",
default=u"The Upper Limit of Quantification (LLOQ) cannot be "
u"greater than the Upper Limit of Detection (ULOD)."
))


validation.register(LowerLimitOfDetectionValidator())
validation.register(LowerLimitOfQuantificationValidator())
validation.register(UpperLimitOfQuantificationValidator())
2 changes: 1 addition & 1 deletion src/senaite/core/profiles/default/metadata.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0"?>
<metadata>
<version>2658</version>
<version>2659</version>
<dependencies>
<dependency>profile-Products.ATContentTypes:base</dependency>
<dependency>profile-Products.CMFEditions:CMFEditions</dependency>
Expand Down
Loading

0 comments on commit 61c5bda

Please sign in to comment.