sortition-algorithms 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,351 @@
1
+ from collections import defaultdict
2
+ from collections.abc import Iterable, Iterator
3
+ from typing import Any
4
+
5
+ from attrs import define
6
+
7
+ from sortition_algorithms import errors, utils
8
+
9
+ # TODO: put this in docs and link to them from here.
10
+ """
11
+ Note on terminology. The word "categories" can mean various things, so for this
12
+ code we use the terms "Feature" and "Value".
13
+
14
+ - Feature example: gender
15
+ - Value examples: male, female, non-binary
16
+
17
+ In the UI we may use the terms
18
+
19
+ - "category" to mean "feature" and
20
+ - "bucket" to mean "value".
21
+ """
22
+
23
+ FEATURE_FILE_FIELD_NAMES = ("feature", "value", "min", "max")
24
+ FEATURE_FILE_FIELD_NAMES_FLEX = (
25
+ *FEATURE_FILE_FIELD_NAMES,
26
+ "min_flex",
27
+ "max_flex",
28
+ )
29
+ FEATURE_FILE_FIELD_NAMES_OLD = ("category", "name", "min", "max")
30
+ FEATURE_FILE_FIELD_NAMES_FLEX_OLD = (
31
+ *FEATURE_FILE_FIELD_NAMES_OLD,
32
+ "min_flex",
33
+ "max_flex",
34
+ )
35
+ ALL_FEATURE_FIELD_NAMES = frozenset([*FEATURE_FILE_FIELD_NAMES_FLEX, *FEATURE_FILE_FIELD_NAMES_OLD])
36
+
37
+ MAX_FLEX_UNSET = -1
38
+
39
+
40
+ @define(kw_only=True, slots=True)
41
+ class FeatureValueCounts:
42
+ min: int
43
+ max: int
44
+ selected: int = 0
45
+ remaining: int = 0
46
+ min_flex: int = 0
47
+ max_flex: int = MAX_FLEX_UNSET
48
+
49
+ def set_default_max_flex(self, max_flex: int) -> None:
50
+ # this must be bigger than the largest max - and could even be more than number of people
51
+ if self.max_flex == MAX_FLEX_UNSET:
52
+ self.max_flex = max_flex
53
+
54
+ def add_remaining(self) -> None:
55
+ self.remaining += 1
56
+
57
+ def add_selected(self) -> None:
58
+ self.selected += 1
59
+
60
+ def remove_remaining(self) -> None:
61
+ self.remaining -= 1
62
+ if self.remaining == 0 and self.selected < self.min:
63
+ msg = "SELECTION IMPOSSIBLE: FAIL - no one/not enough left after deletion."
64
+ raise errors.SelectionError(msg)
65
+
66
+ def percent_selected(self, number_people_wanted: int) -> float:
67
+ return self.selected * 100 / float(number_people_wanted)
68
+
69
+
70
+ class FeatureValues:
71
+ """
72
+ A full set of values for a single feature.
73
+
74
+ If the feature is gender, the values could be: male, female, non_binary_other
75
+
76
+ The values are FeatureValueCounts objects - the min, max and current counts of the
77
+ selected people in that feature value.
78
+ """
79
+
80
+ def __init__(self) -> None:
81
+ self.feature_values: dict[str, FeatureValueCounts] = {}
82
+
83
+ def __eq__(self, other: Any) -> bool:
84
+ if not isinstance(other, self.__class__):
85
+ return False
86
+ return self.feature_values == other.feature_values
87
+
88
+ def add_value_counts(self, value_name: str, fv_counts: FeatureValueCounts) -> None:
89
+ self.feature_values[value_name] = fv_counts
90
+
91
+ def set_default_max_flex(self, max_flex: int) -> None:
92
+ """Note this only sets it if left at the default value"""
93
+ for fv_counts in self.feature_values.values():
94
+ fv_counts.set_default_max_flex(max_flex)
95
+
96
+ @property
97
+ def values(self) -> list[str]:
98
+ return list(self.feature_values.keys())
99
+
100
+ def values_counts(self) -> Iterator[tuple[str, FeatureValueCounts]]:
101
+ yield from self.feature_values.items()
102
+
103
+ def add_remaining(self, value_name: str) -> None:
104
+ self.feature_values[value_name].add_remaining()
105
+
106
+ def add_selected(self, value_name: str) -> None:
107
+ self.feature_values[value_name].add_selected()
108
+
109
+ def remove_remaining(self, value_name: str) -> None:
110
+ self.feature_values[value_name].remove_remaining()
111
+
112
+ def minimum_selection(self) -> int:
113
+ """
114
+ For this feature, we have to select at least the sum of the minimum of each value
115
+ """
116
+ return sum(c.min for c in self.feature_values.values())
117
+
118
+ def maximum_selection(self) -> int:
119
+ """
120
+ For this feature, we have to select at most the sum of the maximum of each value
121
+ """
122
+ return sum(c.max for c in self.feature_values.values())
123
+
124
+
125
+ class FeatureCollection:
126
+ """
127
+ A full set of features for a stratification.
128
+
129
+ The keys here are the names of the features. They could be: gender, age_bracket, education_level etc
130
+
131
+ The values are FeatureValues objects - the breakdown of the values for a feature.
132
+ """
133
+
134
+ # TODO: consider splitting the updates/remaining into a parallel set of classes
135
+ # then this can just have targets, and the running totals can be in classes we can
136
+ # regenerate now and then
137
+
138
+ def __init__(self) -> None:
139
+ self.collection: dict[str, FeatureValues] = defaultdict(FeatureValues)
140
+
141
+ def __eq__(self, other: Any) -> bool:
142
+ if not isinstance(other, self.__class__):
143
+ return False
144
+ return self.collection == other.collection
145
+
146
+ def add_feature(self, feature_name: str, value_name: str, fv_counts: FeatureValueCounts) -> None:
147
+ self.collection[feature_name].add_value_counts(value_name, fv_counts)
148
+
149
+ @property
150
+ def feature_names(self) -> list[str]:
151
+ return list(self.collection.keys())
152
+
153
+ def feature_values(self) -> Iterator[tuple[str, list[str]]]:
154
+ for feature_name, feature_value in self.collection.items():
155
+ yield feature_name, feature_value.values
156
+
157
+ def feature_values_counts(self) -> Iterator[tuple[str, str, FeatureValueCounts]]:
158
+ for feature_name, feature_values in self.collection.items():
159
+ for value, value_counts in feature_values.values_counts():
160
+ yield feature_name, value, value_counts
161
+
162
+ def _safe_max_flex_val(self) -> int:
163
+ if not self.collection:
164
+ return 0
165
+ # to avoid errors, if max_flex is not set we must set it at least as high as the highest
166
+ return max(v.maximum_selection() for v in self.collection.values())
167
+
168
+ def set_default_max_flex(self) -> None:
169
+ """Note this only sets it if left at the default value"""
170
+ max_flex = self._safe_max_flex_val()
171
+ for feature_values in self.collection.values():
172
+ feature_values.set_default_max_flex(max_flex)
173
+
174
+ def add_remaining(self, feature: str, value_name: str) -> None:
175
+ self.collection[feature].add_remaining(value_name)
176
+
177
+ def add_selected(self, feature: str, value_name: str) -> None:
178
+ self.collection[feature].add_selected(value_name)
179
+
180
+ def remove_remaining(self, feature: str, value_name: str) -> None:
181
+ try:
182
+ self.collection[feature].remove_remaining(value_name)
183
+ except errors.SelectionError as e:
184
+ msg = f"Failed removing from {feature}/{value_name}: {e}"
185
+ raise errors.SelectionError(msg) from None
186
+
187
+ def minimum_selection(self) -> int:
188
+ """
189
+ The minimum selection for this set of features is the largest minimum selection
190
+ of any individual feature.
191
+ """
192
+ if not self.collection:
193
+ return 0
194
+ return max(v.minimum_selection() for v in self.collection.values())
195
+
196
+ def maximum_selection(self) -> int:
197
+ """
198
+ The maximum selection for this set of features is the smallest maximum selection
199
+ of any individual feature.
200
+ """
201
+ if not self.collection:
202
+ return 0
203
+ return min(v.maximum_selection() for v in self.collection.values())
204
+
205
+ def check_min_max(self) -> None:
206
+ """
207
+ If the min is bigger than the max we're in trouble i.e. there's an input error
208
+ """
209
+ if self.minimum_selection() > self.maximum_selection():
210
+ msg = (
211
+ "Inconsistent numbers in min and max in the features input: the sum "
212
+ "of the minimum values of a features is larger than the sum of the "
213
+ "maximum values of a(nother) feature. "
214
+ )
215
+ raise ValueError(msg)
216
+
217
+ def check_desired(self, desired_number: int) -> None:
218
+ """
219
+ Check if the desired number of people is within the min/max of every feature.
220
+ """
221
+ for feature_name, feature_values in self.collection.items():
222
+ if (
223
+ desired_number < feature_values.minimum_selection()
224
+ or desired_number > feature_values.maximum_selection()
225
+ ):
226
+ msg = (
227
+ f"The number of people to select ({desired_number}) is out of the range of "
228
+ f"the numbers of people in the {feature_name} feature. It should be within "
229
+ f"[{feature_values.minimum_selection()}, {feature_values.maximum_selection()}]."
230
+ )
231
+ raise Exception(msg)
232
+
233
+
234
+ def _normalise_col_names(row: dict[str, str]) -> dict[str, str]:
235
+ """
236
+ if the dict has "category" as the key, change that to "feature"
237
+ if the dict has "name" as the key, change that to "value"
238
+ """
239
+ if "category" in row:
240
+ row["feature"] = row.pop("category")
241
+ if "name" in row:
242
+ row["value"] = row.pop("name")
243
+ return row
244
+
245
+
246
+ def _feature_headers_flex(headers: list[str]) -> tuple[bool, list[str]]:
247
+ """
248
+ Determine if the headers match either the required ones, or required plus flex fields.
249
+ Return True if the flex headers are present, False if not.
250
+
251
+ It is fine to have extra headers - they will just be ignored.
252
+
253
+ If an invalid set of headers are present, report details.
254
+ """
255
+ filtered_headers = [h for h in headers if h in ALL_FEATURE_FIELD_NAMES]
256
+ # check that the fieldnames are (at least) what we expect, and only once.
257
+ # BUT (for reverse compatibility) let min_flex and max_flex be optional.
258
+ if sorted(filtered_headers) in (
259
+ sorted(FEATURE_FILE_FIELD_NAMES),
260
+ sorted(FEATURE_FILE_FIELD_NAMES_OLD),
261
+ ):
262
+ return False, filtered_headers
263
+ if sorted(filtered_headers) in (
264
+ sorted(FEATURE_FILE_FIELD_NAMES_FLEX),
265
+ sorted(FEATURE_FILE_FIELD_NAMES_FLEX_OLD),
266
+ ):
267
+ return True, filtered_headers
268
+ # below here we are reporting errors with the headers
269
+ messages: list[str] = []
270
+ required_fields = FEATURE_FILE_FIELD_NAMES if "feature" in filtered_headers else FEATURE_FILE_FIELD_NAMES_OLD
271
+ for field_name in required_fields:
272
+ feature_head_field_name_count = filtered_headers.count(field_name)
273
+ if feature_head_field_name_count == 0 and (field_name != "min_flex" and field_name != "max_flex"):
274
+ messages.append(f"Did not find required column name '{field_name}' in the input")
275
+ elif feature_head_field_name_count > 1:
276
+ messages.append(
277
+ f"Found MORE THAN 1 column named '{field_name}' in the input (found {feature_head_field_name_count})"
278
+ )
279
+ msg = "\n".join(messages) if messages else f"Unexpected error in set of column names: {headers}"
280
+ raise ValueError(msg)
281
+
282
+
283
+ def _clean_row(row: utils.StrippedDict, feature_flex: bool) -> tuple[str, str, FeatureValueCounts]:
284
+ """
285
+ allow for some dirty data - at least strip white space from feature name and value
286
+ but only if they are strings! (sometimes people use ints as feature names or values
287
+ and then strip produces an exception...)
288
+ """
289
+ feature_name = row["feature"]
290
+ # check for blank entries and report a meaningful error
291
+ feature_value = row["value"]
292
+ if feature_value == "" or row["min"] == "" or row["max"] == "":
293
+ msg = f"ERROR reading in feature file: found a blank cell in a row of the feature: {feature_name}."
294
+ raise ValueError(msg)
295
+ # must convert min/max to ints
296
+ value_min = int(row["min"])
297
+ value_max = int(row["max"])
298
+ if feature_flex:
299
+ if row["min_flex"] == "" or row["max_flex"] == "":
300
+ msg = (
301
+ f"ERROR reading in feature file: found a blank min_flex or "
302
+ f"max_flex cell in a feature value: {feature_value}."
303
+ )
304
+ raise ValueError(msg)
305
+ value_min_flex = int(row["min_flex"])
306
+ value_max_flex = int(row["max_flex"])
307
+ # if these values exist they must be at least this...
308
+ if value_min_flex > value_min or value_max_flex < value_max:
309
+ msg = (
310
+ f"Inconsistent numbers in min_flex and max_flex in the features input for {feature_value}: "
311
+ f"the flex values must be equal or outside the max and min values."
312
+ )
313
+ raise ValueError(msg)
314
+ else:
315
+ value_min_flex = 0
316
+ # since we don't know self.number_people_to_select yet! We correct this below
317
+ value_max_flex = MAX_FLEX_UNSET
318
+ fv_counts = FeatureValueCounts(
319
+ min=value_min,
320
+ max=value_max,
321
+ min_flex=value_min_flex,
322
+ max_flex=value_max_flex,
323
+ )
324
+ return feature_name, feature_value, fv_counts
325
+
326
+
327
+ def read_in_features(
328
+ features_head: Iterable[str], features_body: Iterable[dict[str, str]]
329
+ ) -> tuple[FeatureCollection, list[str]]:
330
+ """
331
+ Read in stratified selection features and values
332
+
333
+ Note we do want features_head to ensure we don't have multiple columns with the same name
334
+ """
335
+ features = FeatureCollection()
336
+ msg: list[str] = []
337
+ features_flex, filtered_headers = _feature_headers_flex(list(features_head))
338
+ for row in features_body:
339
+ # check the set of keys in the row are the same as the headers
340
+ assert set(filtered_headers) <= set(row.keys())
341
+ stripped_row = utils.StrippedDict(_normalise_col_names(row))
342
+ if not stripped_row["feature"]:
343
+ continue
344
+ features.add_feature(*_clean_row(stripped_row, features_flex))
345
+
346
+ msg.append(f"Number of features: {len(features.feature_names)}")
347
+ features.check_min_max()
348
+ # check feature_flex to see if we need to set the max here
349
+ # this only changes the max_flex value if these (optional) flex values are NOT set already
350
+ features.set_default_max_flex()
351
+ return features, msg
@@ -0,0 +1,93 @@
1
+ """Selection algorithms for stratified sampling."""
2
+
3
+ from sortition_algorithms import errors
4
+ from sortition_algorithms.features import FeatureCollection
5
+ from sortition_algorithms.people import People
6
+ from sortition_algorithms.people_features import PeopleFeatures
7
+
8
+
9
+ def find_random_sample_legacy(
10
+ people: People,
11
+ features: FeatureCollection,
12
+ number_people_wanted: int,
13
+ check_same_address: bool = False,
14
+ check_same_address_columns: list[str] | None = None,
15
+ ) -> tuple[list[frozenset[str]], list[str]]:
16
+ """
17
+ Legacy stratified random selection algorithm.
18
+
19
+ Implements the original algorithm that uses greedy selection based on priority ratios.
20
+ Always selects from the most urgently needed category first (highest ratio of
21
+ (min-selected)/remaining), then randomly picks within that category.
22
+
23
+ Args:
24
+ people: People collection
25
+ features: Feature definitions with min/max targets
26
+ number_people_wanted: Number of people to select
27
+ check_same_address: Whether to remove household members when selecting someone
28
+ check_same_address_columns: Address columns for household identification
29
+
30
+ Returns:
31
+ Tuple of (selected_committees, output_messages) where:
32
+ - selected_committees: List containing one frozenset of selected person IDs
33
+ - output_messages: List of log messages about the selection process
34
+
35
+ Raises:
36
+ SelectionError: If selection becomes impossible (not enough people, etc.)
37
+ """
38
+ output_lines = ["Using legacy algorithm."]
39
+ people_selected: set[str] = set()
40
+
41
+ # Create PeopleFeatures and initialize
42
+ people_features = PeopleFeatures(people, features, check_same_address, check_same_address_columns or [])
43
+ people_features.update_all_features_remaining()
44
+ people_features.prune_for_feature_max_0()
45
+
46
+ # Main selection loop
47
+ for count in range(number_people_wanted):
48
+ # Find the category with highest priority ratio
49
+ try:
50
+ ratio_result = people_features.find_max_ratio_category()
51
+ except errors.SelectionError as e:
52
+ msg = f"Selection failed on iteration {count + 1}: {e}"
53
+ raise errors.SelectionError(msg) from e
54
+
55
+ # Find the randomly selected person within that category
56
+ target_feature = ratio_result.feature_name
57
+ target_value = ratio_result.feature_value
58
+ random_position = ratio_result.random_person_index
59
+
60
+ selected_person_key = people_features.people.find_person_by_position_in_category(
61
+ target_feature, target_value, random_position
62
+ )
63
+
64
+ # Should never select the same person twice
65
+ assert selected_person_key not in people_selected, f"Person {selected_person_key} was already selected"
66
+
67
+ # Select the person (this also removes household members if configured)
68
+ people_selected.add(selected_person_key)
69
+ selected_person_data = people_features.people.get_person_dict(selected_person_key)
70
+ household_members_removed = people_features.select_person(selected_person_key)
71
+
72
+ # Add output messages about household member removal
73
+ if household_members_removed:
74
+ output_lines.append(
75
+ f"Selected {selected_person_key}, also removed household members: "
76
+ f"{', '.join(household_members_removed)}"
77
+ )
78
+
79
+ # Handle any categories that are now full after this selection
80
+ try:
81
+ category_messages = people_features.handle_category_full_deletions(selected_person_data)
82
+ output_lines.extend(category_messages)
83
+ except errors.SelectionError as e:
84
+ msg = f"Selection failed after selecting {selected_person_key}: {e}"
85
+ raise errors.SelectionError(msg) from e
86
+
87
+ # Check if we're about to run out of people (but not on the last iteration)
88
+ if count < (number_people_wanted - 1) and people_features.people.count == 0:
89
+ msg = "Selection failed: Ran out of people before completing selection"
90
+ raise errors.SelectionError(msg)
91
+
92
+ # Return in legacy format: list containing single frozenset
93
+ return [frozenset(people_selected)], output_lines
@@ -0,0 +1,175 @@
1
+ from collections import defaultdict
2
+ from collections.abc import ItemsView, Iterable, Iterator
3
+ from typing import Any
4
+
5
+ from sortition_algorithms import errors
6
+ from sortition_algorithms.features import FeatureCollection
7
+ from sortition_algorithms.settings import Settings
8
+ from sortition_algorithms.utils import StrippedDict
9
+
10
+
11
+ class People:
12
+ def __init__(self, columns_to_keep: list[str]) -> None:
13
+ self._columns_to_keep = columns_to_keep
14
+ self._full_data: dict[str, dict[str, str]] = {}
15
+
16
+ def __eq__(self, other: Any) -> bool:
17
+ if not isinstance(other, self.__class__):
18
+ return False
19
+ return self._full_data == other._full_data and self._columns_to_keep == self._columns_to_keep
20
+
21
+ @property
22
+ def count(self) -> int:
23
+ return len(self._full_data)
24
+
25
+ def __iter__(self) -> Iterator[str]:
26
+ return iter(self._full_data)
27
+
28
+ def items(self) -> ItemsView[str, dict[str, str]]:
29
+ return self._full_data.items()
30
+
31
+ def add(self, person_key: str, data: StrippedDict, features: FeatureCollection) -> None:
32
+ person_full_data: dict[str, str] = {}
33
+ # get the feature values: these are the most important and we must check them
34
+ for feature_name, feature_values in features.feature_values():
35
+ # check for input errors here - if it's not in the list of feature values...
36
+ # allow for some unclean data - at least strip empty space, but only if a str!
37
+ # (some values will can be numbers)
38
+ p_value = data[feature_name]
39
+ if p_value not in feature_values:
40
+ exc_msg = (
41
+ f"ERROR reading in people (read_in_people): "
42
+ f"Person (id = {person_key}) has value '{p_value}' not in feature {feature_name}"
43
+ )
44
+ raise errors.BadDataError(exc_msg)
45
+ person_full_data[feature_name] = p_value
46
+ # then get the other column values we need
47
+ # this is address, name etc that we need to keep for output file
48
+ # we don't check anything here - it's just for user convenience
49
+ for col in self._columns_to_keep:
50
+ person_full_data[col] = data[col]
51
+
52
+ # add all the data to our people object
53
+ self._full_data[person_key] = person_full_data
54
+
55
+ def remove(self, person_key: str) -> None:
56
+ del self._full_data[person_key]
57
+
58
+ def remove_many(self, person_keys: Iterable[str]) -> None:
59
+ for key in person_keys:
60
+ self.remove(key)
61
+
62
+ def get_person_dict(self, person_key: str) -> dict[str, str]:
63
+ return self._full_data[person_key]
64
+
65
+ def households(self, address_columns: list[str]) -> dict[tuple[str, ...], list[str]]:
66
+ """
67
+ Generates a dict with:
68
+ - keys: a tuple containing the address strings
69
+ - values: a list of person_key for each person at that address
70
+ """
71
+ households = defaultdict(list)
72
+ for person_key, person in self._full_data.items():
73
+ address = tuple(person[col] for col in address_columns)
74
+ households[address].append(person_key)
75
+ return households
76
+
77
+ def matching_address(self, person_key: str, address_columns: list[str]) -> Iterable[str]:
78
+ """
79
+ Returns a list of person keys for all people who have an address matching
80
+ the address of the person passed in.
81
+ """
82
+ person = self._full_data[person_key]
83
+ person_address = tuple(person[col] for col in address_columns)
84
+ for loop_key, loop_person in self._full_data.items():
85
+ if loop_key == person_key:
86
+ continue # skip the person we've been given
87
+ if person_address == tuple(loop_person[col] for col in address_columns):
88
+ yield loop_key
89
+
90
+ def find_person_by_position_in_category(self, feature_name: str, feature_value: str, position: int) -> str:
91
+ """
92
+ Find the nth person (1-indexed) in a specific feature category.
93
+
94
+ Args:
95
+ feature_name: Name of the feature (e.g., "gender")
96
+ feature_value: Value of the feature (e.g., "male")
97
+ position: 1-indexed position within the category
98
+
99
+ Returns:
100
+ Person key of the person at the specified position
101
+
102
+ Raises:
103
+ SelectionError: If no person is found at the specified position
104
+ """
105
+ current_position = 0
106
+
107
+ for person_key, person_dict in self._full_data.items():
108
+ if person_dict[feature_name] == feature_value:
109
+ current_position += 1
110
+ if current_position == position:
111
+ return person_key
112
+
113
+ # Should always find someone if position is valid
114
+ msg = f"Failed to find person at position {position} in {feature_name}/{feature_value}"
115
+ raise errors.SelectionError(msg)
116
+
117
+
118
+ # simple helper function to tidy the code below
119
+ def _check_columns_exist_or_multiple(people_head: list[str], column_list: Iterable[str], error_text: str) -> None:
120
+ for column in column_list:
121
+ column_count = people_head.count(column)
122
+ if column_count == 0:
123
+ msg = f"No '{column}' column {error_text} found in people data!"
124
+ raise errors.BadDataError(msg)
125
+ elif column_count > 1:
126
+ msg = f"MORE THAN 1 '{column}' column {error_text} found in people data!"
127
+ raise errors.BadDataError(msg)
128
+
129
+
130
+ def _check_people_head(people_head: list[str], features: FeatureCollection, settings: Settings) -> None:
131
+ # check that id_column and all the features, columns_to_keep and
132
+ # check_same_address_columns are in the people data fields...
133
+ # check both for existence and duplicate column names
134
+ _check_columns_exist_or_multiple(people_head, [settings.id_column], "(unique id)")
135
+ _check_columns_exist_or_multiple(people_head, features.feature_names, "(a feature)")
136
+ _check_columns_exist_or_multiple(people_head, settings.columns_to_keep, "(to keep)")
137
+ _check_columns_exist_or_multiple(
138
+ people_head,
139
+ settings.check_same_address_columns,
140
+ "(to check same address)",
141
+ )
142
+
143
+
144
+ def _ensure_settings_keep_address_columns(settings: Settings) -> None:
145
+ # let's just merge the check_same_address_columns into columns_to_keep in case they aren't in both
146
+ # TODO: review this - should we do this in settings rather than here?
147
+ for col in settings.check_same_address_columns:
148
+ if col not in settings.columns_to_keep:
149
+ settings.columns_to_keep.append(col)
150
+
151
+
152
+ def read_in_people(
153
+ people_head: list[str],
154
+ people_body: Iterable[dict[str, str]],
155
+ features: FeatureCollection,
156
+ settings: Settings,
157
+ ) -> tuple[People, list[str]]:
158
+ all_msg: list[str] = []
159
+ _check_people_head(people_head, features, settings)
160
+ _ensure_settings_keep_address_columns(settings)
161
+ people = People(settings.columns_to_keep)
162
+ for index, row in enumerate(people_body):
163
+ stripped_row = StrippedDict(row)
164
+ pkey = stripped_row[settings.id_column]
165
+ # skip over any blank lines... but warn the user
166
+ if pkey == "":
167
+ all_msg.append(f"<b>WARNING</b>: blank cell found in ID column in row {index} - skipped that line!")
168
+ continue
169
+ people.add(pkey, stripped_row, features)
170
+ # TODO: should this be done outside this function?
171
+ # so this function just reads in people but doesn't update the features stuff
172
+ # people_features = PeopleFeatures(people, features)
173
+ # people_features.update_all_features_remaining()
174
+ # people_features.prune_for_feature_max_0()
175
+ return people, all_msg