Skip to content

Commit

Permalink
Added Limit of Quantification (LOQ) for services and analyses (#2682)
Browse files Browse the repository at this point in the history
* Added QuantificationLimit field for services and analyses

* Return empty instead of zopemessage

* Support QL and BELOWQL wildcards for calculations

* Return < QL when result is below the Quantification limit

* Display the quantification limit before lower detection limit

* Add validators for LDL and QL

* Added validators

* Missing line break

* Fix doctests

* When result is below detection limit, display < LDL instead

* Quantification Limit (QL) -> Limit of Quantification (LOQ)

For formal scientific, technical, or regulatory contexts, Limit of
Quantification (LOQ) is better choice than Quantification Limit (QL) as it
aligns with standard usage and is more widely recognized.

LOQ is the term standardized by international organizations such as IUPAC,
ISO, and regulatory agencies like the FDA and EPA.

* Fix test TestDecimalMarkWithSciNotation

* Added "Limits" schemata to Analysis Service

* Upgrade step to initialize LOQ of services

* Display "Not detected" for results < LLOD when LOQ is set

* LOQ -> LLOQ

* Added UpperLimitOfQUantification

* Cleanup

* Cleanup

* Added doctests for Uncertainties

* Make linter happy

* Added doctests for Uncertainties

* Added doctests for limits (quantifiable and detection ranges)

* Do not change the value type on getters

* Changelog

* Removed default

---------

Co-authored-by: Ramon Bartl <[email protected]>
  • Loading branch information
xispa and ramonski authored Mar 11, 2025
1 parent 4ac5732 commit e592c34
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 @@ -4,6 +4,7 @@ Changelog
2.6.0 (unreleased)
------------------

- #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 e592c34

Please sign in to comment.