Coverage for /home/martinb/.local/share/virtualenvs/camcops/lib/python3.6/site-packages/cardinal_pythonlib/nhs.py : 19%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
2# cardinal_pythonlib/nhs.py
4"""
5===============================================================================
7 Original code copyright (C) 2009-2021 Rudolf Cardinal (rudolf@pobox.com).
9 This file is part of cardinal_pythonlib.
11 Licensed under the Apache License, Version 2.0 (the "License");
12 you may not use this file except in compliance with the License.
13 You may obtain a copy of the License at
15 https://www.apache.org/licenses/LICENSE-2.0
17 Unless required by applicable law or agreed to in writing, software
18 distributed under the License is distributed on an "AS IS" BASIS,
19 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 See the License for the specific language governing permissions and
21 limitations under the License.
23===============================================================================
25**Support functions regarding NHS numbers, etc.**
27"""
29import re
30import random
31from typing import List, Optional, Union
33from cardinal_pythonlib.logs import get_brace_style_log_with_null_handler
35log = get_brace_style_log_with_null_handler(__name__)
38# =============================================================================
39# NHS number validation
40# =============================================================================
42NHS_DIGIT_WEIGHTINGS = [10, 9, 8, 7, 6, 5, 4, 3, 2]
45def nhs_check_digit(ninedigits: Union[str, List[Union[str, int]]]) -> int:
46 """
47 Calculates an NHS number check digit.
49 Args:
50 ninedigits: string or list
52 Returns:
53 check digit
55 Method:
57 1. Multiply each of the first nine digits by the corresponding
58 digit weighting (see :const:`NHS_DIGIT_WEIGHTINGS`).
59 2. Sum the results.
60 3. Take remainder after division by 11.
61 4. Subtract the remainder from 11
62 5. If this is 11, use 0 instead
63 If it's 10, the number is invalid
64 If it doesn't match the actual check digit, the number is invalid
66 """
67 if len(ninedigits) != 9 or not all(str(x).isdigit() for x in ninedigits):
68 raise ValueError("bad string to nhs_check_digit")
69 check_digit = 11 - (sum([
70 int(d) * f
71 for (d, f) in zip(ninedigits, NHS_DIGIT_WEIGHTINGS)
72 ]) % 11)
73 # ... % 11 yields something in the range 0-10
74 # ... 11 - that yields something in the range 1-11
75 if check_digit == 11:
76 check_digit = 0
77 return check_digit
80def is_valid_nhs_number(n: int) -> bool:
81 """
82 Validates an integer as an NHS number.
84 Args:
85 n: NHS number
87 Returns:
88 valid?
90 Checksum details are at
91 https://www.datadictionary.nhs.uk/version2/data_dictionary/data_field_notes/n/nhs_number_de.asp
92 """ # noqa
93 if not isinstance(n, int):
94 log.debug("is_valid_nhs_number: parameter was not of integer type")
95 return False
97 s = str(n)
98 # Not 10 digits long?
99 if len(s) != 10:
100 log.debug("is_valid_nhs_number: not 10 digits")
101 return False
103 main_digits = [int(s[i]) for i in range(9)]
104 actual_check_digit = int(s[9]) # tenth digit
105 expected_check_digit = nhs_check_digit(main_digits)
106 if expected_check_digit == 10:
107 log.debug("is_valid_nhs_number: calculated check digit invalid")
108 return False
109 if expected_check_digit != actual_check_digit:
110 log.debug("is_valid_nhs_number: check digit mismatch")
111 return False
112 # Hooray!
113 return True
116def generate_random_nhs_number() -> int:
117 """
118 Returns a random valid NHS number, as an ``int``.
119 """
120 check_digit = 10 # NHS numbers with this check digit are all invalid
121 while check_digit == 10:
122 digits = [random.randint(1, 9)] # don't start with a zero
123 digits.extend([random.randint(0, 9) for _ in range(8)])
124 # ... length now 9
125 check_digit = nhs_check_digit(digits)
126 # noinspection PyUnboundLocalVariable
127 digits.append(check_digit)
128 return int("".join([str(d) for d in digits]))
131def test_nhs_rng(n: int = 100) -> None:
132 """
133 Tests the NHS random number generator.
134 """
135 for i in range(n):
136 x = generate_random_nhs_number()
137 assert is_valid_nhs_number(x), f"Invalid NHS number: {x}"
140def generate_nhs_number_from_first_9_digits(first9digits: str) -> Optional[int]:
141 """
142 Returns a valid NHS number, as an ``int``, given the first 9 digits.
143 The particular purpose is to make NHS numbers that *look* fake (rather
144 than truly random NHS numbers which might accidentally be real).
146 For example:
148 .. code-block:: none
150 123456789_ : no; checksum 10
151 987654321_ : yes, valid if completed to 9876543210
152 999999999_ : yes, valid if completed to 9999999999
153 """
154 if len(first9digits) != 9:
155 log.warning("Not 9 digits")
156 return None
157 try:
158 first9int = int(first9digits)
159 except (TypeError, ValueError):
160 log.warning("Not an integer")
161 return None # not an int
162 if len(str(first9int)) != len(first9digits):
163 # e.g. leading zeros, or some such
164 log.warning("Leading zeros?")
165 return None
166 check_digit = nhs_check_digit(first9digits)
167 if check_digit == 10: # NHS numbers with this check digit are all invalid
168 log.warning("Can't have check digit of 10")
169 return None
170 return int(first9digits + str(check_digit))
173# =============================================================================
174# Get an NHS number out of text
175# =============================================================================
177WHITESPACE_REGEX = re.compile(r'\s')
178NON_NUMERIC_REGEX = re.compile("[^0-9]") # or "\D"
181def nhs_number_from_text_or_none(s: str) -> Optional[int]:
182 """
183 Returns a validated NHS number (as an integer) from a string, or ``None``
184 if it is not valid.
186 It's a 10-digit number, so note that database 32-bit INT values are
187 insufficient; use BIGINT. Python will handle large integers happily.
189 NHS number rules:
190 https://www.datadictionary.nhs.uk/version2/data_dictionary/data_field_notes/n/nhs_number_de.asp?shownav=0
191 """ # noqa
192 # None in, None out.
193 funcname = "nhs_number_from_text_or_none: "
194 if not s:
195 log.debug(funcname + "incoming parameter was None")
196 return None
198 # (a) If it's not a 10-digit number, bye-bye.
200 # Remove whitespace
201 s = WHITESPACE_REGEX.sub("", s) # replaces all instances
202 # Contains non-numeric characters?
203 if NON_NUMERIC_REGEX.search(s):
204 log.debug(funcname + "contains non-numeric characters")
205 return None
206 # Not 10 digits long?
207 if len(s) != 10:
208 log.debug(funcname + "not 10 digits long")
209 return None
211 # (b) Validation
212 n = int(s)
213 if not is_valid_nhs_number(n):
214 log.debug(funcname + "failed validation")
215 return None
217 # Happy!
218 return n