19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457 | class StudentDormDistributor:
""" """
def __init__(
self,
student_dorms_file: str = None,
debug_mode: bool = False,
):
"""
Tool to distribute unallocated people into student dorms based on age proportions.
Parameters
----------
student_dorms_file
Path to student dorms CSV with age proportion data
debug_mode
Enable detailed tracking and CSV export for development/testing
"""
self.student_dorms_file = student_dorms_file or default_student_dorms_filename
self.debug_mode = debug_mode
self.debug_data = [] # Store debug information
@classmethod
def from_file(
cls,
student_dorms_filename: str = default_student_dorms_filename,
debug_mode: bool = False,
):
"""Create StudentDormDistributor from student dorms file.
Args:
student_dorms_filename (str, optional): Path to student dorms CSV file (Default value = default_student_dorms_filename)
debug_mode (bool, optional): Enable detailed tracking and CSV export for development/testing (Default value = False)
"""
return cls(student_dorms_file=student_dorms_filename, debug_mode=debug_mode)
def distribute_unallocated_people(self, student_dorms, unallocated_people):
"""Distribute unallocated people to student dorms based on each dorm's stored age proportions.
Only uses people from the same area as each student dorm.
Args:
student_dorms: StudentDorms object containing available student dorms
unallocated_people: Dict with structure: {area_id: {age_group: {sex: [Person, ...]}}}
Returns:
dict: Dictionary containing distribution summary and remaining unallocated people
{
'distribution_summary': {...},
'unallocated_people': {...}
}
"""
logger.info(f"Distributing people to student dorms...")
if not student_dorms or not unallocated_people:
return {
'distribution_summary': {},
'unallocated_people': unallocated_people
}
total_distributed = 0
distribution_summary = {'by_area': {}, 'by_age_range': {'16_24': 0, '25_34': 0, '35_49': 0, '50_64': 0, '65_99': 0}}
# Group student dorms by area for efficient processing
student_dorms_by_area = {}
for student_dorm in student_dorms.members:
if not hasattr(student_dorm, 'age_proportions'):
logger.debug(f"Student dorm {getattr(student_dorm, 'id', 'unknown')} has no age proportions - skipping")
continue
# Get area identifier from student dorm
area_id = getattr(student_dorm.area, 'name', str(getattr(student_dorm.area, 'id', 'unknown')))
if area_id not in student_dorms_by_area:
student_dorms_by_area[area_id] = []
student_dorms_by_area[area_id].append(student_dorm)
# Process each area that has both student dorms and unallocated people
for area_id, area_student_dorms in student_dorms_by_area.items():
if area_id not in unallocated_people:
logger.debug(f"No unallocated people in area {area_id} with student dorms")
continue
area_people = unallocated_people[area_id]
area_distributed = 0
# Initialize area statistics
area_stats = self._initialize_area_stats(area_id, area_people, area_student_dorms)
# Distribute people to student dorms in this area
for student_dorm in area_student_dorms:
# Use target allocations from CSV data instead of estimated capacity
target_allocations = getattr(student_dorm, 'target_allocations', {})
target_total = target_allocations.get('n_total', 0)
current_size = student_dorm.size
available_spots = max(0, target_total - current_size)
if available_spots <= 0:
continue # Dorm has reached target capacity
# Use actual target numbers for each age group instead of proportions
# Note: Even though CSV has 16-24 category, we only allow 18+ in dorms
needed_by_age_range = {
'prop_16_24': max(0, target_allocations.get('n_16_24', 0) - self._count_age_group_in_dorm(student_dorm, 18, 24)),
'prop_25_34': max(0, target_allocations.get('n_25_34', 0) - self._count_age_group_in_dorm(student_dorm, 25, 34)),
'prop_35_49': max(0, target_allocations.get('n_35_49', 0) - self._count_age_group_in_dorm(student_dorm, 35, 49)),
'prop_50_64': max(0, target_allocations.get('n_50_64', 0) - self._count_age_group_in_dorm(student_dorm, 50, 64)),
'prop_65_99': max(0, target_allocations.get('n_65_99', 0) - self._count_age_group_in_dorm(student_dorm, 65, 99))
}
# Distribute people from this area to this student dorm
distributed_to_this_dorm = self._distribute_to_student_dorm_from_area(
student_dorm, needed_by_age_range, area_people, area_id, target_total
)
area_distributed += distributed_to_this_dorm
total_distributed += distributed_to_this_dorm
# Update student dorm stats
area_stats['student_dorms'].append({
'id': getattr(student_dorm, 'id', 'unknown'),
'target_capacity': target_total,
'initial_residents': current_size,
'distributed': distributed_to_this_dorm,
'final_residents': student_dorm.size,
'occupancy_rate': student_dorm.size / target_total if target_total > 0 else 0
})
# Finalize area statistics
self._finalize_area_stats(area_stats, area_people)
if area_distributed > 0:
distribution_summary['by_area'][area_id] = area_distributed
# Display area report if debug mode is enabled
if self.debug_mode:
self._display_area_report(area_stats)
logger.info(f"Total people distributed to student dorms: {total_distributed}")
# Display comprehensive summary if debug mode is enabled
if self.debug_mode:
self._display_final_summary(distribution_summary, total_distributed, student_dorms_by_area)
return {
'distribution_summary': distribution_summary,
'unallocated_people': unallocated_people
}
def _distribute_to_student_dorm_from_area(self, student_dorm, needed_by_age_range, area_people, area_id, target_capacity):
"""Distribute people from a specific area to a student dorm based on its age range needs.
Args:
student_dorm: The student dorm object to populate
needed_by_age_range: Dict of {age_prop_key: count} needed for this student dorm
area_people: Available people from this specific area: {age_group: {sex: [Person, ...]}}
area_id: ID of the area being processed
target_capacity: Target capacity for this student dorm from CSV data
Returns:
int: Number of people distributed to this student dorm
"""
distributed_count = 0
# Map age proportion keys to our age groups and age ranges
# Note: Even though CSV category is 16-24, we only allow 18+ in dorms
age_range_mapping = {
'prop_16_24': (['kids', 'young_adults'], 18, 24), # Only 18-24 (no under-18s in dorms)
'prop_25_34': ('adults', 25, 34), # Updated to match new boundary: adults 25-64
'prop_35_49': ('adults', 35, 49),
'prop_50_64': ('adults', 50, 64),
'prop_65_99': ('old_adults', 65, 99) # old_adults >= 65
}
# Try to fill the student dorm according to its age proportions
for age_prop_key, needed_count in needed_by_age_range.items():
if needed_count <= 0 or age_prop_key not in age_range_mapping:
continue
age_groups, min_age, max_age = age_range_mapping[age_prop_key]
# Handle case where age_groups can be a single string or a list
if isinstance(age_groups, str):
age_groups = [age_groups]
# Collect available people from all relevant age groups in this area
available_people = []
for age_group in age_groups:
if age_group in area_people:
for sex in ['m', 'f']:
if sex in area_people[age_group]:
# Filter by specific age range within the age group
matching_people = [p for p in area_people[age_group][sex]
if min_age <= p.age <= max_age]
available_people.extend(matching_people)
if not available_people:
continue
# Randomly select people to distribute
shuffle(available_people)
people_to_distribute = available_people[:min(needed_count, len(available_people))]
# Add people to student dorm and remove from unallocated lists
for person in people_to_distribute:
if student_dorm.size < target_capacity:
student_dorm.add(person, subgroup_type=0) # Add as resident
distributed_count += 1
# Remove person from this area's unallocated people
self._remove_person_from_area(person, area_people)
if self.debug_mode:
self.debug_data.append({
'person_id': person.id,
'age': person.age,
'sex': person.sex,
'age_range': f"{min_age}-{max_age}",
'student_dorm_id': getattr(student_dorm, 'id', 'unknown'),
'area_id': area_id
})
else:
break # Student dorm is now full
return distributed_count
def _remove_person_from_area(self, person, area_people):
"""Remove a specific person from a single area's unallocated people structure.
Args:
person:
area_people:
"""
# Determine person's age group
age_group = self._get_person_age_group(person)
# Find and remove the person from the appropriate list in this area
if age_group in area_people:
for sex in ['m', 'f']:
if sex in area_people[age_group] and person in area_people[age_group][sex]:
area_people[age_group][sex].remove(person)
return
def _get_person_age_group(self, person):
"""Determine which age group a person belongs to.
Args:
person:
"""
age = person.age
if age < 18: # Changed to < 18 to ensure 18-year-olds can be in dorms
return 'kids'
elif age <= 25:
return 'young_adults'
elif age <= 64:
return 'adults'
else:
return 'old_adults'
def _count_age_group_in_dorm(self, student_dorm, min_age, max_age):
"""Count how many people in a specific age range are already in the student dorm.
Args:
student_dorm:
min_age:
max_age:
"""
count = 0
for subgroup in student_dorm.subgroups:
if subgroup is not None:
for person in subgroup:
if min_age <= person.age <= max_age:
count += 1
return count
def _initialize_area_stats(self, area_id, area_people, area_student_dorms):
"""Initialize statistics tracking for an area.
Args:
area_id:
area_people:
area_student_dorms:
"""
# Count initial people by age group and sex
initial_people = {}
total_initial = 0
for age_group in ["kids", "young_adults", "adults", "old_adults"]:
initial_people[age_group] = {}
for sex in ["m", "f"]:
count = len(area_people.get(age_group, {}).get(sex, []))
initial_people[age_group][sex] = count
total_initial += count
return {
'area_id': area_id,
'initial_people': initial_people,
'total_initial_people': total_initial,
'student_dorms': [],
'total_distributed': 0,
'remaining_people': {},
'distribution_rate': 0.0
}
def _finalize_area_stats(self, area_stats, area_people):
"""Calculate final statistics for an area.
Args:
area_stats:
area_people:
"""
# Count remaining people
remaining_people = {}
total_remaining = 0
for age_group in ["kids", "young_adults", "adults", "old_adults"]:
remaining_people[age_group] = {}
for sex in ["m", "f"]:
count = len(area_people.get(age_group, {}).get(sex, []))
remaining_people[age_group][sex] = count
total_remaining += count
area_stats['remaining_people'] = remaining_people
area_stats['total_distributed'] = area_stats['total_initial_people'] - total_remaining
area_stats['distribution_rate'] = (area_stats['total_distributed'] / area_stats['total_initial_people']) if area_stats['total_initial_people'] > 0 else 0.0
def _display_area_report(self, stats):
"""Display comprehensive student dorm distribution report for an area.
Args:
stats:
"""
print(f"\n{'='*100}")
print(f"🏠 STUDENT DORM DISTRIBUTION REPORT - AREA: {stats['area_id']}")
print(f"{'='*100}")
# Section 1: Student Dorm Summary
print(f"\n🏢 STUDENT DORMS SUMMARY:")
print(f"{'Dorm ID':<25} {'Target Cap.':<12} {'Initial':<10} {'Added':<8} {'Final':<8} {'Occupancy':<12}")
print("-" * 87)
total_capacity = 0
total_initial = 0
total_added = 0
total_final = 0
for sd in stats['student_dorms']:
total_capacity += sd['target_capacity']
total_initial += sd['initial_residents']
total_added += sd['distributed']
total_final += sd['final_residents']
print(f"{sd['id']:<25} {sd['target_capacity']:<12} {sd['initial_residents']:<10} {sd['distributed']:<8} {sd['final_residents']:<8} {sd['occupancy_rate']:<11.1%}")
print("-" * 87)
print(f"{'TOTAL':<25} {total_capacity:<12} {total_initial:<10} {total_added:<8} {total_final:<8} {total_final/total_capacity if total_capacity > 0 else 0:<11.1%}")
# Section 2: People Distribution by Age Group
print(f"\n👥 PEOPLE DISTRIBUTION BY AGE GROUP:")
print(f"{'Age Group':<15} {'Initial M':<10} {'Initial F':<10} {'Remain M':<10} {'Remain F':<10} {'Distributed':<12} {'Rate':<8}")
print("-" * 85)
for age_group in ["kids", "young_adults", "adults", "old_adults"]:
initial_m = stats['initial_people'][age_group]['m']
initial_f = stats['initial_people'][age_group]['f']
remain_m = stats['remaining_people'][age_group]['m']
remain_f = stats['remaining_people'][age_group]['f']
total_initial_ag = initial_m + initial_f
total_remaining_ag = remain_m + remain_f
distributed_ag = total_initial_ag - total_remaining_ag
rate_ag = distributed_ag / total_initial_ag if total_initial_ag > 0 else 0
print(f"{age_group.replace('_', ' ').title():<15} {initial_m:<10} {initial_f:<10} {remain_m:<10} {remain_f:<10} {distributed_ag:<12} {rate_ag:<7.1%}")
print("-" * 85)
total_remain = sum(stats['remaining_people'][ag]['m'] + stats['remaining_people'][ag]['f'] for ag in stats['remaining_people'])
print(f"{'TOTAL':<15} {'-':<10} {'-':<10} {'-':<10} {'-':<10} {stats['total_distributed']:<12} {stats['distribution_rate']:<7.1%}")
# Section 3: Summary Statistics
print(f"\n📊 AREA SUMMARY:")
print(f"Total Initial People: {stats['total_initial_people']:>6}")
print(f"Total Distributed: {stats['total_distributed']:>6}")
print(f"Total Remaining: {total_remain:>6}")
print(f"Distribution Rate: {stats['distribution_rate']:>5.1%}")
print(f"Total Dorm Capacity: {total_capacity:>6}")
print(f"Dorm Occupancy Rate: {total_final/total_capacity if total_capacity > 0 else 0:>5.1%}")
print(f"\n{'='*100}\n")
def _display_final_summary(self, distribution_summary, total_distributed, student_dorms_by_area):
"""Display final summary across all areas.
Args:
distribution_summary:
total_distributed:
student_dorms_by_area:
"""
print(f"\n{'='*100}")
print(f"🎯 FINAL STUDENT DORM DISTRIBUTION SUMMARY")
print(f"{'='*100}")
# Summary by area
print(f"\n📍 DISTRIBUTION BY AREA:")
print(f"{'Area ID':<20} {'Student Dorms':<15} {'People Distributed':<18}")
print("-" * 53)
total_student_dorms = 0
for area_id, distributed_count in distribution_summary['by_area'].items():
num_student_dorms = len(student_dorms_by_area.get(area_id, []))
total_student_dorms += num_student_dorms
print(f"{area_id:<20} {num_student_dorms:<15} {distributed_count:<18}")
print("-" * 53)
print(f"{'TOTAL':<20} {total_student_dorms:<15} {total_distributed:<18}")
# Age distribution from debug data
if self.debug_data:
print(f"\n📊 DISTRIBUTION BY AGE RANGE:")
age_range_counts = {}
for entry in self.debug_data:
age_range = entry['age_range']
age_range_counts[age_range] = age_range_counts.get(age_range, 0) + 1
print(f"{'Age Range':<15} {'Count':<10} {'Percentage':<12}")
print("-" * 37)
for age_range, count in sorted(age_range_counts.items()):
percentage = count / total_distributed if total_distributed > 0 else 0
print(f"{age_range:<15} {count:<10} {percentage:<11.1%}")
print(f"\n🎉 Successfully distributed {total_distributed} people to student dorms across {len(distribution_summary['by_area'])} areas!")
print(f"{'='*100}\n")
|