"""
crate_anon/nlp_manager/parse_clinical.py
===============================================================================
Copyright (C) 2015, University of Cambridge, Department of Psychiatry.
Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
This file is part of CRATE.
CRATE is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CRATE is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CRATE. If not, see <https://www.gnu.org/licenses/>.
===============================================================================
**Python regex-based NLP processors for clinical assessment data.**
Most inherit from
:class:`crate_anon.nlp_manager.regex_parser.SimpleNumericalResultParser` and
are constructed with these arguments:
nlpdef:
a :class:`crate_anon.nlp_manager.nlp_definition.NlpDefinition`
cfgsection:
the name of a CRATE NLP config file section (from which we may
choose to get extra config information)
commit:
force a COMMIT whenever we insert data? You should specify this
in multiprocess mode, or you may get database deadlocks.
± these:
debug:
show debugging information
"""
import logging
from typing import Any, Dict, Generator, List, Optional, Tuple
from sqlalchemy import Column, Integer, Float, String, Text
from crate_anon.common.regex_helpers import WORD_BOUNDARY
from crate_anon.nlp_manager.constants import ProcessorConfigKeys
from crate_anon.nlp_manager.nlp_definition import NlpDefinition
from crate_anon.nlp_manager.regex_parser import (
BaseNlpParser,
common_tense,
compile_regex,
FN_CONTENT,
FN_END,
FN_RELATION,
FN_RELATION_TEXT,
FN_START,
FN_TENSE,
FN_TENSE_TEXT,
FN_UNITS,
FN_VALUE_TEXT,
FN_VARIABLE_NAME,
FN_VARIABLE_TEXT,
HELP_CONTENT,
HELP_END,
HELP_RELATION,
HELP_RELATION_TEXT,
HELP_START,
HELP_TENSE,
HELP_TENSE_TEXT,
HELP_UNITS,
HELP_VALUE_TEXT,
HELP_VARIABLE_TEXT,
make_simple_numeric_regex,
MAX_RELATION_LENGTH,
MAX_RELATION_TEXT_LENGTH,
MAX_TENSE_LENGTH,
MAX_TENSE_TEXT_LENGTH,
MAX_UNITS_LENGTH,
MAX_VALUE_TEXT_LENGTH,
NumericalResultParser,
OPTIONAL_RESULTS_IGNORABLES,
RELATION,
SimpleNumericalResultParser,
TENSE_INDICATOR,
to_float,
to_pos_float,
ValidatorBase,
)
from crate_anon.nlp_manager.regex_numbers import SIGNED_FLOAT
from crate_anon.nlp_manager.regex_units import (
assemble_units,
CM,
FEET,
INCHES,
KG,
kg_from_st_lb_oz,
KG_PER_SQ_M,
LB,
M,
m_from_ft_in,
m_from_m_cm,
MM_HG,
STONES,
)
log = logging.getLogger(__name__)
# =============================================================================
# Anthropometrics
# =============================================================================
# -----------------------------------------------------------------------------
# Height
# -----------------------------------------------------------------------------
[docs]class Height(NumericalResultParser):
"""
CLINICAL EXAMINATION.
Height. Handles metric (e.g. "1.8m") and imperial (e.g. "5 ft 2 in").
"""
METRIC_HEIGHT = rf"""
( # capture group 4
(?:
( {SIGNED_FLOAT} ) # capture group 5
{OPTIONAL_RESULTS_IGNORABLES}
( {M} ) # capture group 6
{OPTIONAL_RESULTS_IGNORABLES}
( {SIGNED_FLOAT} ) # capture group 7
{OPTIONAL_RESULTS_IGNORABLES}
( {CM} ) # capture group 8
)
| (?:
( {SIGNED_FLOAT} ) # capture group 9
{OPTIONAL_RESULTS_IGNORABLES}
( {M} ) # capture group 10
)
| (?:
( {SIGNED_FLOAT} ) # capture group 11
{OPTIONAL_RESULTS_IGNORABLES}
( {CM} ) # capture group 12
)
)
"""
IMPERIAL_HEIGHT = rf"""
( # capture group 13
(?:
( {SIGNED_FLOAT} ) # capture group 14
{OPTIONAL_RESULTS_IGNORABLES}
( {FEET} ) # capture group 15
{OPTIONAL_RESULTS_IGNORABLES}
( {SIGNED_FLOAT} ) # capture group 16
{OPTIONAL_RESULTS_IGNORABLES}
( {INCHES} ) # capture group 17
)
| (?:
( {SIGNED_FLOAT} ) # capture group 18
{OPTIONAL_RESULTS_IGNORABLES}
( {FEET} ) # capture group 19
)
| (?:
( {SIGNED_FLOAT} ) # capture group 20
{OPTIONAL_RESULTS_IGNORABLES}
( {INCHES} ) # capture group 21
)
)
"""
HEIGHT = r"(?: \b height \b)"
REGEX = rf"""
( {HEIGHT} ) # group 1 for "height" or equivalent
{OPTIONAL_RESULTS_IGNORABLES}
( {TENSE_INDICATOR} )? # optional group 2 for tense
{OPTIONAL_RESULTS_IGNORABLES}
( {RELATION} )? # optional group 3 for relation
{OPTIONAL_RESULTS_IGNORABLES}
(?:
{METRIC_HEIGHT}
| {IMPERIAL_HEIGHT}
)
"""
COMPILED_REGEX = compile_regex(REGEX)
NAME = "Height"
PREFERRED_UNIT_COLUMN = "value_m"
[docs] def __init__(
self,
nlpdef: Optional[NlpDefinition],
cfg_processor_name: Optional[str],
commit: bool = False,
debug: bool = False,
) -> None:
# see documentation above
super().__init__(
nlpdef=nlpdef,
cfg_processor_name=cfg_processor_name,
variable=self.NAME,
target_unit=self.PREFERRED_UNIT_COLUMN,
regex_str_for_debugging=self.REGEX,
commit=commit,
)
if debug:
print(f"Regex for {self.classname()}: {self.REGEX}")
[docs] def parse(
self, text: str, debug: bool = False
) -> Generator[Tuple[str, Dict[str, Any]], None, None]:
"""
Parser for Height. Specialized for complex unit conversion.
"""
for m in self.COMPILED_REGEX.finditer(text): # watch out: 'm'/metres
if debug:
log.info(f"Match {m} for {text!r}")
startpos = m.start()
endpos = m.end()
matching_text = m.group(0) # the whole thing
variable_text = m.group(1)
tense_text = m.group(2)
relation_text = m.group(3)
metric_expression = m.group(4)
metric_m_and_cm_m = m.group(5)
metric_m_and_cm_m_units = m.group(6)
metric_m_and_cm_cm = m.group(7)
metric_m_and_cm_cm_units = m.group(8)
metric_m_only_m = m.group(9)
metric_m_only_m_units = m.group(10)
metric_cm_only_cm = m.group(11)
metric_cm_only_cm_units = m.group(12)
imperial_expression = m.group(13)
imperial_ft_and_in_ft = m.group(14)
imperial_ft_and_in_ft_units = m.group(15)
imperial_ft_and_in_in = m.group(16)
imperial_ft_and_in_in_units = m.group(17)
imperial_ft_only_ft = m.group(18)
imperial_ft_only_ft_units = m.group(19)
imperial_in_only_in = m.group(20)
imperial_in_only_in_units = m.group(21)
expression = None
value_m = None
units = None
if metric_expression:
expression = metric_expression
if metric_m_and_cm_m and metric_m_and_cm_cm:
metres = to_pos_float(metric_m_and_cm_m)
# ... beware: 'm' above
cm = to_pos_float(metric_m_and_cm_cm)
value_m = m_from_m_cm(metres=metres, centimetres=cm)
units = assemble_units(
[metric_m_and_cm_m_units, metric_m_and_cm_cm_units]
)
elif metric_m_only_m:
value_m = to_pos_float(metric_m_only_m)
units = metric_m_only_m_units
elif metric_cm_only_cm:
cm = to_pos_float(metric_cm_only_cm)
value_m = m_from_m_cm(centimetres=cm)
units = metric_cm_only_cm_units
elif imperial_expression:
expression = imperial_expression
if imperial_ft_and_in_ft and imperial_ft_and_in_in:
ft = to_pos_float(imperial_ft_and_in_ft)
inches = to_pos_float(imperial_ft_and_in_in)
value_m = m_from_ft_in(feet=ft, inches=inches)
units = assemble_units(
[
imperial_ft_and_in_ft_units,
imperial_ft_and_in_in_units,
]
)
elif imperial_ft_only_ft:
ft = to_pos_float(imperial_ft_only_ft)
value_m = m_from_ft_in(feet=ft)
units = imperial_ft_only_ft_units
elif imperial_in_only_in:
inches = to_pos_float(imperial_in_only_in)
value_m = m_from_ft_in(inches=inches)
units = imperial_in_only_in_units
tense, relation = common_tense(tense_text, relation_text)
result = {
FN_VARIABLE_NAME: self.variable,
FN_CONTENT: matching_text,
FN_START: startpos,
FN_END: endpos,
FN_VARIABLE_TEXT: variable_text,
FN_RELATION_TEXT: relation_text,
FN_RELATION: relation,
FN_VALUE_TEXT: expression,
FN_UNITS: units,
self.target_unit: value_m,
FN_TENSE_TEXT: tense_text,
FN_TENSE: tense,
}
# log.debug(result)
yield self.tablename, result
[docs] def test(self, verbose: bool = False) -> None:
# docstring in superclass
self.test_numerical_parser(
[
("Height", []), # should fail; no values
("her height was 1.6m", [1.6]),
("Height = 1.23 m", [1.23]),
("her height is 1.5m", [1.5]),
("""Height 5'8" """, [m_from_ft_in(feet=5, inches=8)]),
("Height 5 ft 8 in", [m_from_ft_in(feet=5, inches=8)]),
("Height 5 feet 8 inches", [m_from_ft_in(feet=5, inches=8)]),
],
verbose=verbose,
)
self.detailed_test(
"Height 5 ft 11 in",
[
{
self.target_unit: m_from_ft_in(feet=5, inches=11),
FN_UNITS: "ft in",
}
],
verbose=verbose,
)
# todo: Height NLP: deal with "tall" and plain "is", e.g.
# she is 6'2"; she is 1.5m tall
[docs]class HeightValidator(ValidatorBase):
"""
Validator for Height (see help for explanation).
"""
[docs] @classmethod
def get_variablename_regexstrlist(cls) -> Tuple[str, List[str]]:
return Height.NAME, [Height.HEIGHT]
# -----------------------------------------------------------------------------
# Weight (mass)
# -----------------------------------------------------------------------------
[docs]class Weight(NumericalResultParser):
"""
CLINICAL EXAMINATION.
Weight. Handles metric (e.g. "57kg") and imperial (e.g. "10 st 2 lb").
Requires units to be specified.
"""
METRIC_WEIGHT = rf"""
( # capture group 4
( {SIGNED_FLOAT} ) # capture group 5
{OPTIONAL_RESULTS_IGNORABLES}
( {KG} ) # capture group 6
)
"""
IMPERIAL_WEIGHT = rf"""
( # capture group 7
(?:
( {SIGNED_FLOAT} ) # capture group 8
{OPTIONAL_RESULTS_IGNORABLES}
( {STONES} ) # capture group 9
{OPTIONAL_RESULTS_IGNORABLES}
( {SIGNED_FLOAT} ) # capture group 10
{OPTIONAL_RESULTS_IGNORABLES}
( {LB} ) # capture group 11
)
| (?:
( {SIGNED_FLOAT} ) # capture group 12
{OPTIONAL_RESULTS_IGNORABLES}
( {STONES} ) # capture group 13
)
| (?:
( {SIGNED_FLOAT} ) # capture group 14
{OPTIONAL_RESULTS_IGNORABLES}
( {LB} ) # capture group 15
)
)
"""
WEIGHT = r"(?: \b weigh[ts] \b )" # weight, weighs
REGEX = rf"""
( {WEIGHT} ) # group 1 for "weight" or equivalent
{OPTIONAL_RESULTS_IGNORABLES}
( {TENSE_INDICATOR} )? # optional group 2 for tense
{OPTIONAL_RESULTS_IGNORABLES}
( {RELATION} )? # optional group 3 for relation
{OPTIONAL_RESULTS_IGNORABLES}
(?:
{METRIC_WEIGHT}
| {IMPERIAL_WEIGHT}
)
"""
COMPILED_REGEX = compile_regex(REGEX)
NAME = "Weight"
PREFERRED_UNIT_COLUMN = "value_kg"
[docs] def __init__(
self,
nlpdef: Optional[NlpDefinition],
cfg_processor_name: Optional[str],
commit: bool = False,
debug: bool = False,
) -> None:
# see documentation above
super().__init__(
nlpdef=nlpdef,
cfg_processor_name=cfg_processor_name,
variable=self.NAME,
target_unit=self.PREFERRED_UNIT_COLUMN,
regex_str_for_debugging=self.REGEX,
commit=commit,
)
if debug:
print(f"Regex for {self.classname()}: {self.REGEX}")
[docs] def parse(
self, text: str, debug: bool = False
) -> Generator[Tuple[str, Dict[str, Any]], None, None]:
"""
Parser for Weight. Specialized for complex unit conversion.
"""
for m in self.COMPILED_REGEX.finditer(text):
if debug:
log.info(f"Match {m} for {text!r}")
startpos = m.start()
endpos = m.end()
matching_text = m.group(0) # the whole thing
variable_text = m.group(1)
tense_text = m.group(2)
relation_text = m.group(3)
metric_expression = m.group(4)
metric_value = m.group(5)
metric_units = m.group(6)
imperial_expression = m.group(7)
imperial_st_and_lb_st = m.group(8)
imperial_st_and_lb_st_units = m.group(9)
imperial_st_and_lb_lb = m.group(10)
imperial_st_and_lb_lb_units = m.group(11)
imperial_st_only_st = m.group(12)
imperial_st_only_st_units = m.group(13)
imperial_lb_only_lb = m.group(14)
imperial_lb_only_lb_units = m.group(15)
expression = None
value_kg = None
units = None
if metric_expression:
expression = metric_expression
value_kg = to_float(metric_value)
units = metric_units
elif imperial_expression:
expression = imperial_expression
if imperial_st_and_lb_st and imperial_st_and_lb_lb:
st = to_float(imperial_st_and_lb_st)
lb = to_float(imperial_st_and_lb_lb)
value_kg = kg_from_st_lb_oz(stones=st, pounds=lb)
units = assemble_units(
[
imperial_st_and_lb_st_units,
imperial_st_and_lb_lb_units,
]
)
elif imperial_st_only_st:
st = to_float(imperial_st_only_st)
value_kg = kg_from_st_lb_oz(stones=st)
units = imperial_st_only_st_units
elif imperial_lb_only_lb:
lb = to_float(imperial_lb_only_lb)
value_kg = kg_from_st_lb_oz(pounds=lb)
units = imperial_lb_only_lb_units
# All left as signed float, as you definitely see things like
# "weight -0.3 kg" for weight changes.
tense, relation = common_tense(tense_text, relation_text)
result = {
FN_VARIABLE_NAME: self.variable,
FN_CONTENT: matching_text,
FN_START: startpos,
FN_END: endpos,
FN_VARIABLE_TEXT: variable_text,
FN_RELATION_TEXT: relation_text,
FN_RELATION: relation,
FN_VALUE_TEXT: expression,
FN_UNITS: units,
self.target_unit: value_kg,
FN_TENSE_TEXT: tense_text,
FN_TENSE: tense,
}
# log.debug(result)
yield self.tablename, result
[docs] def test(self, verbose: bool = False) -> None:
# docstring in superclass
self.test_numerical_parser(
[
("Weight", []), # should fail; no values
("her weight was 60.2kg", [60.2]),
("her weight was 60.2", []), # needs units
("Weight = 52.3kg", [52.3]),
("Weight: 80.8kgs", [80.8]),
("she weighs 61kg", [61]),
("she weighs 61 kg", [61]),
("she weighs 61 kgs", [61]),
("she weighs 61 kilo", [61]),
("she weighs 61 kilos", [61]),
("she weighs 8 stones ", [kg_from_st_lb_oz(stones=8)]),
("she weighs 200 lb", [kg_from_st_lb_oz(pounds=200)]),
("she weighs 200 pounds", [kg_from_st_lb_oz(pounds=200)]),
(
"she weighs 6 st 12 lb",
[kg_from_st_lb_oz(stones=6, pounds=12)],
),
("change in weight -0.4kg", [-0.4]),
(
"change in weight - 0.4kg",
[0.4],
), # ASCII hyphen (hyphen-minus)
("change in weight ‐ 0.4kg", [0.4]), # Unicode hyphen
# ("failme", [999]),
("change in weight −0.4kg", [-0.4]), # Unicode minus
("change in weight –0.4kg", [-0.4]), # en dash
("change in weight —0.4kg", [0.4]), # em dash
],
verbose=verbose,
)
self.detailed_test(
"Weight: 80.8kgs",
[
{
self.target_unit: 80.8,
FN_UNITS: "kgs",
}
],
verbose=verbose,
)
[docs]class WeightValidator(ValidatorBase):
"""
Validator for Weight (see help for explanation).
"""
[docs] @classmethod
def get_variablename_regexstrlist(cls) -> Tuple[str, List[str]]:
return Weight.NAME, [Weight.WEIGHT]
# -----------------------------------------------------------------------------
# Body mass index (BMI)
# -----------------------------------------------------------------------------
[docs]class Bmi(SimpleNumericalResultParser):
"""
CLINICAL EXAMINATION.
Body mass index (BMI), in kg / m^2.
"""
BMI = rf"""
{WORD_BOUNDARY}
(?: BMI | body \s+ mass \s+ index )
{WORD_BOUNDARY}
"""
REGEX = make_simple_numeric_regex(quantity=BMI, units=KG_PER_SQ_M)
NAME = "BMI"
PREFERRED_UNIT_COLUMN = "value_kg_per_sq_m"
UNIT_MAPPING = {
KG_PER_SQ_M: 1, # preferred unit
}
# deal with "a BMI of 30"?
[docs] def __init__(
self,
nlpdef: Optional[NlpDefinition],
cfg_processor_name: Optional[str],
commit: bool = False,
) -> None:
# see documentation above
super().__init__(
nlpdef=nlpdef,
cfg_processor_name=cfg_processor_name,
regex_str=self.REGEX,
variable=self.NAME,
target_unit=self.PREFERRED_UNIT_COLUMN,
units_to_factor=self.UNIT_MAPPING,
commit=commit,
take_absolute=True,
)
[docs] def test(self, verbose: bool = False) -> None:
# docstring in superclass
self.test_numerical_parser(
[
("BMI", []), # should fail; no values
("body mass index was 30", [30]),
("his BMI (30) is too high", [30]),
("BMI 25 kg/sq m", [25]),
("BMI was 18.4 kg/m^-2", [18.4]),
("ACE 79", []),
("BMI-23", [23]),
],
verbose=verbose,
)
[docs]class BmiValidator(ValidatorBase):
"""
Validator for Bmi (see help for explanation).
"""
[docs] @classmethod
def get_variablename_regexstrlist(cls) -> Tuple[str, List[str]]:
return Bmi.NAME, [Bmi.BMI]
# =============================================================================
# Bedside investigations: BP
# =============================================================================
[docs]class Bp(BaseNlpParser):
"""
CLINICAL EXAMINATION.
Blood pressure, in mmHg. (Systolic and diastolic.)
"""
# Since we produce two variables, SBP and DBP, and we use something a
# little more complex than
# :class:`crate_anon.nlp_manager.regex_parser.NumeratorOutOfDenominatorParser`, # noqa
# we subclass :class:`crate_anon.nlp_manager.base_nlp_parser.BaseNlpParser`
# directly.)
BP = r"(?: \b blood \s+ pressure \b | \b B\.?P\.? \b )"
SYSTOLIC_BP = rf"(?: \b systolic \s+ {BP} | \b S\.?B\.?P\.? \b )"
DIASTOLIC_BP = rf"(?: \b diastolic \s+ {BP} | \b D\.?B\.?P\.? \b )"
TWO_NUMBER_BP = rf"""
( {SIGNED_FLOAT} )
\s* (?: \b over \b | \/ ) \s*
( {SIGNED_FLOAT} )
"""
ONE_NUMBER_BP = SIGNED_FLOAT
COMPILED_BP = compile_regex(BP)
COMPILED_SBP = compile_regex(SYSTOLIC_BP)
COMPILED_DBP = compile_regex(DIASTOLIC_BP)
COMPILED_ONE_NUMBER_BP = compile_regex(ONE_NUMBER_BP)
COMPILED_TWO_NUMBER_BP = compile_regex(TWO_NUMBER_BP)
REGEX = rf"""
( # group for "BP" or equivalent
{SYSTOLIC_BP} # ... from more to less specific
| {DIASTOLIC_BP}
| {BP}
)
{OPTIONAL_RESULTS_IGNORABLES}
( {TENSE_INDICATOR} )? # optional group for tense indicator
{OPTIONAL_RESULTS_IGNORABLES}
( {RELATION} )? # optional group for relation
{OPTIONAL_RESULTS_IGNORABLES}
(
{SIGNED_FLOAT} # systolic
(?:
\s* (?: \b over \b | \/ ) \s* # /
{SIGNED_FLOAT} # diastolic
)?
)
{OPTIONAL_RESULTS_IGNORABLES}
( # group for units
{MM_HG}
)?
"""
COMPILED_REGEX = compile_regex(REGEX)
FN_SYSTOLIC_BP_MMHG = "systolic_bp_mmhg"
FN_DIASTOLIC_BP_MMHG = "diastolic_bp_mmhg"
NAME = "BP"
UNIT_MAPPING = {
MM_HG: 1, # preferred unit
}
[docs] def __init__(
self,
nlpdef: Optional[NlpDefinition],
cfg_processor_name: Optional[str],
commit: bool = False,
) -> None:
# see documentation above
super().__init__(
nlpdef=nlpdef,
cfg_processor_name=cfg_processor_name,
commit=commit,
friendly_name=self.NAME,
)
if nlpdef is None: # only None for debugging!
self.tablename = self.classname().lower()
else:
self.tablename = self._cfgsection.opt_str(
ProcessorConfigKeys.DESTTABLE, required=True
)
[docs] def dest_tables_columns(self) -> Dict[str, List[Column]]:
# docstring in superclass
return {
self.tablename: [
Column(FN_CONTENT, Text, comment=HELP_CONTENT),
Column(FN_START, Integer, comment=HELP_START),
Column(FN_END, Integer, comment=HELP_END),
Column(FN_VARIABLE_TEXT, Text, comment=HELP_VARIABLE_TEXT),
Column(
FN_RELATION_TEXT,
String(MAX_RELATION_TEXT_LENGTH),
comment=HELP_RELATION_TEXT,
),
Column(
FN_RELATION,
String(MAX_RELATION_LENGTH),
comment=HELP_RELATION,
),
Column(
FN_VALUE_TEXT,
String(MAX_VALUE_TEXT_LENGTH),
comment=HELP_VALUE_TEXT,
),
Column(FN_UNITS, String(MAX_UNITS_LENGTH), comment=HELP_UNITS),
Column(
self.FN_SYSTOLIC_BP_MMHG,
Float,
comment="Systolic blood pressure in mmHg",
),
Column(
self.FN_DIASTOLIC_BP_MMHG,
Float,
comment="Diastolic blood pressure in mmHg",
),
Column(
FN_TENSE_TEXT,
String(MAX_TENSE_TEXT_LENGTH),
comment=HELP_TENSE_TEXT,
),
Column(FN_TENSE, String(MAX_TENSE_LENGTH), comment=HELP_TENSE),
]
}
[docs] def parse(
self, text: str, debug: bool = False
) -> Generator[Tuple[str, Dict[str, Any]], None, None]:
"""
Parser for BP. Specialized because we're fetching two numbers.
"""
for m in self.COMPILED_REGEX.finditer(text):
if debug:
log.info(f"Match {m} for {text!r}")
startpos = m.start()
endpos = m.end()
matching_text = m.group(0) # the whole thing
variable_text = m.group(1)
tense_text = m.group(2)
relation_text = m.group(3)
value_text = m.group(4)
units = m.group(5)
sbp = None
dbp = None
if self.COMPILED_SBP.match(variable_text):
if self.COMPILED_ONE_NUMBER_BP.match(value_text):
sbp = to_pos_float(value_text)
elif self.COMPILED_DBP.match(variable_text):
if self.COMPILED_ONE_NUMBER_BP.match(value_text):
dbp = to_pos_float(value_text)
elif self.COMPILED_BP.match(variable_text):
bpmatch = self.COMPILED_TWO_NUMBER_BP.match(value_text)
if bpmatch:
sbp = to_pos_float(bpmatch.group(1))
dbp = to_pos_float(bpmatch.group(2))
if sbp is None and dbp is None:
# This is OK; e.g. "BP 110", which we will ignore.
# log.warning(
# "Failed interpretation: matching_text={matching_text}, "
# "variable_text={variable_text}, "
# "tense_indicator={tense_indicator}, "
# "relation={relation}, "
# "value_text={value_text}, "
# "units={units}".format(
# matching_text=repr(matching_text),
# variable_text=repr(variable_text),
# tense_indicator=repr(tense_indicator),
# relation=repr(relation),
# value_text=repr(value_text),
# units=repr(units),
# )
# )
continue
tense, relation = common_tense(tense_text, relation_text)
yield self.tablename, {
FN_CONTENT: matching_text,
FN_START: startpos,
FN_END: endpos,
FN_VARIABLE_TEXT: variable_text,
FN_RELATION_TEXT: relation_text,
FN_RELATION: relation,
FN_VALUE_TEXT: value_text,
FN_UNITS: units,
self.FN_SYSTOLIC_BP_MMHG: sbp,
self.FN_DIASTOLIC_BP_MMHG: dbp,
FN_TENSE_TEXT: tense_text,
FN_TENSE: tense,
}
[docs] def test_bp_parser(
self,
test_expected_list: List[Tuple[str, List[Tuple[float, float]]]],
verbose: bool = False,
) -> None:
"""
Called by :func:`test`.
Args:
test_expected_list:
tuple ``source_text, expected_values`` where
``expected_values`` is a list of tuples like ``sbp, dbp``.
verbose:
be verbose?
"""
log.info(f"Testing parser: {self.classname()}")
if verbose:
log.debug(f"... regex:\n{self.REGEX}")
for test_string, expected_values in test_expected_list:
actual_values = list(
(x[self.FN_SYSTOLIC_BP_MMHG], x[self.FN_DIASTOLIC_BP_MMHG])
for t, x in self.parse(test_string)
)
assert actual_values == expected_values, (
"Parser {name}: Expected {expected}, got {actual}, when "
"parsing {test_string}; full result={full}".format(
name=self.classname(),
expected=expected_values,
actual=actual_values,
test_string=repr(test_string),
full=repr(list(self.parse(test_string))),
)
)
log.info("... OK")
[docs] def test(self, verbose: bool = False) -> None:
# docstring in superclass
self.test_bp_parser(
[
("BP", []), # should fail; no values
("his blood pressure was 120/80", [(120, 80)]),
("BP 120/80 mmhg", [(120, 80)]),
("systolic BP 120", [(120, None)]),
("diastolic BP 80", [(None, 80)]),
("BP-130/70", [(130, 70)]),
("BP 110 /80", [(110, 80)]),
("BP 110 /80 -", [(110, 80)]), # real example
("BP 120 / 70 -", [(120, 70)]), # real example
("BP :115 / 70 -", [(115, 70)]), # real example
("B.P 110", []), # real example
],
verbose=verbose,
)
# 1. Unsure if best to take abs value.
# One reason not to might be if people express changes, e.g.
# "BP change -40/-10", but I very much doubt it.
# Went with abs value using to_pos_float().
# 2. "BP 110" - too unreliable; not definitely a blood pressure.
[docs]class BpValidator(ValidatorBase):
"""
Validator for Bp (see help for explanation).
"""
[docs] @classmethod
def get_variablename_regexstrlist(cls) -> Tuple[str, List[str]]:
return Bp.NAME, [Bp.REGEX]
# =============================================================================
# All classes in this module
# =============================================================================
ALL_CLINICAL_NLP_AND_VALIDATORS = [
(Bmi, BmiValidator),
(Bp, BpValidator),
(Height, HeightValidator),
(Weight, WeightValidator),
]