Hide keyboard shortcuts

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 

3 

4""" 

5=============================================================================== 

6 

7 Original code copyright (C) 2009-2021 Rudolf Cardinal (rudolf@pobox.com). 

8 

9 This file is part of cardinal_pythonlib. 

10 

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 

14 

15 https://www.apache.org/licenses/LICENSE-2.0 

16 

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. 

22 

23=============================================================================== 

24 

25**Support functions regarding NHS numbers, etc.** 

26 

27""" 

28 

29import re 

30import random 

31from typing import List, Optional, Union 

32 

33from cardinal_pythonlib.logs import get_brace_style_log_with_null_handler 

34 

35log = get_brace_style_log_with_null_handler(__name__) 

36 

37 

38# ============================================================================= 

39# NHS number validation 

40# ============================================================================= 

41 

42NHS_DIGIT_WEIGHTINGS = [10, 9, 8, 7, 6, 5, 4, 3, 2] 

43 

44 

45def nhs_check_digit(ninedigits: Union[str, List[Union[str, int]]]) -> int: 

46 """ 

47 Calculates an NHS number check digit. 

48 

49 Args: 

50 ninedigits: string or list 

51 

52 Returns: 

53 check digit 

54 

55 Method: 

56 

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 

65 

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 

78 

79 

80def is_valid_nhs_number(n: int) -> bool: 

81 """ 

82 Validates an integer as an NHS number. 

83  

84 Args: 

85 n: NHS number 

86 

87 Returns: 

88 valid? 

89 

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 

96 

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 

102 

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 

114 

115 

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])) 

129 

130 

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}" 

138 

139 

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). 

145 

146 For example: 

147 

148 .. code-block:: none 

149 

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)) 

171 

172 

173# ============================================================================= 

174# Get an NHS number out of text 

175# ============================================================================= 

176 

177WHITESPACE_REGEX = re.compile(r'\s') 

178NON_NUMERIC_REGEX = re.compile("[^0-9]") # or "\D" 

179 

180 

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. 

185  

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. 

188  

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 

197 

198 # (a) If it's not a 10-digit number, bye-bye. 

199 

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 

210 

211 # (b) Validation 

212 n = int(s) 

213 if not is_valid_nhs_number(n): 

214 log.debug(funcname + "failed validation") 

215 return None 

216 

217 # Happy! 

218 return n