Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Limit of Quantification (LOQ) for services and analyses #2682

Merged
merged 30 commits into from
Mar 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
06a011e
Added QuantificationLimit field for services and analyses
xispa Feb 26, 2025
dab7bd3
Return empty instead of zopemessage
xispa Feb 26, 2025
84ab486
Support QL and BELOWQL wildcards for calculations
xispa Feb 26, 2025
8f1aeff
Return < QL when result is below the Quantification limit
xispa Feb 26, 2025
7e00d76
Display the quantification limit before lower detection limit
xispa Feb 26, 2025
9ff7cf3
Add validators for LDL and QL
xispa Feb 26, 2025
bc68ea5
Added validators
xispa Feb 26, 2025
119dc67
Missing line break
xispa Feb 26, 2025
99dd706
Merge branch '2.x' of github.com:senaite/senaite.core into quantifica…
xispa Feb 27, 2025
7fd588e
Merge branch '2.x' of github.com:senaite/senaite.core into quantifica…
xispa Feb 27, 2025
6210af3
Fix doctests
xispa Feb 27, 2025
a697091
When result is below detection limit, display < LDL instead
xispa Feb 27, 2025
99fb643
Quantification Limit (QL) -> Limit of Quantification (LOQ)
xispa Feb 28, 2025
813bdad
Fix test TestDecimalMarkWithSciNotation
xispa Feb 28, 2025
39306e5
Added "Limits" schemata to Analysis Service
xispa Feb 28, 2025
2c23009
Upgrade step to initialize LOQ of services
xispa Feb 28, 2025
8d818cd
Display "Not detected" for results < LLOD when LOQ is set
xispa Feb 28, 2025
bb8aa08
LOQ -> LLOQ
xispa Feb 28, 2025
cf13d0f
Added UpperLimitOfQUantification
xispa Feb 28, 2025
417dda7
Cleanup
xispa Feb 28, 2025
b8def71
Cleanup
xispa Mar 1, 2025
8c48c3d
Added doctests for Uncertainties
xispa Mar 1, 2025
7d81251
Make linter happy
xispa Mar 1, 2025
b182e85
Added doctests for Uncertainties
xispa Mar 2, 2025
831a0d3
Added doctests for limits (quantifiable and detection ranges)
xispa Mar 2, 2025
c65962c
Do not change the value type on getters
xispa Mar 3, 2025
78b74ff
Changelog
xispa Mar 5, 2025
157536c
Merge branch '2.x' into quantification-limit
xispa Mar 5, 2025
2ac0810
Merge branch '2.x' into quantification-limit
ramonski Mar 11, 2025
196d615
Removed default
ramonski Mar 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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