Skip to content

Commit 3d68486

Browse files
add tests and gitignore
1 parent 9173422 commit 3d68486

File tree

6 files changed

+7015
-0
lines changed

6 files changed

+7015
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__/

sample_data/output.tsv

Lines changed: 6820 additions & 0 deletions
Large diffs are not rendered by default.

src/__init__.py

Whitespace-only changes.

src/school_center.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import math
2+
import csv
3+
import random
4+
import argparse
5+
from typing import Dict, List, Tuple
6+
7+
# Constants
8+
PREF_DISTANCE_THRESHOLD = 2 # Preferred threshold distance in kilometers
9+
ABS_DISTANCE_THRESHOLD = 7 # Absolute threshold distance in kilometers
10+
MIN_STUDENT_IN_CENTER = 10 # Minimum number of students from a school to be assigned to a center
11+
STRETCH_CAPACITY_FACTOR = 0.02 # How much can center capacity be stretched if need arises
12+
PREF_CUTOFF = -4 # Do not allocate students with pref score less than cutoff
13+
14+
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
15+
""" Calculate the great circle distance between two points on the earth in kilometers. """
16+
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
17+
dlon = lon2 - lon1
18+
dlat = lat2 - lat1
19+
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
20+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
21+
radius_earth = 6371
22+
return radius_earth * c
23+
24+
def read_tsv(file_path: str) -> List[Dict[str, str]]:
25+
""" Read a TSV file and return a list of dictionaries for each row. """
26+
try:
27+
with open(file_path, 'r', newline='', encoding='utf-8') as file:
28+
reader = csv.DictReader(file, delimiter='\t')
29+
return [row for row in reader]
30+
except FileNotFoundError:
31+
print(f"Error: The file {file_path} does not exist.")
32+
return []
33+
except Exception as e:
34+
print(f"An error occurred: {e}")
35+
return []
36+
37+
def read_prefs(file_path: str) -> Dict[str, Dict[str, int]]:
38+
""" Read preference scores from a TSV file into a nested dictionary. """
39+
prefs = {}
40+
data = read_tsv(file_path)
41+
for row in data:
42+
scode, cscode, pref = row['scode'], row['cscode'], int(row['pref'])
43+
if scode in prefs:
44+
prefs[scode][cscode] = prefs[scode].get(cscode, 0) + pref
45+
else:
46+
prefs[scode] = {cscode: pref}
47+
return prefs
48+
49+
def filter_and_sort_centers(school: Dict[str, str], centers: List[Dict[str, str]], distance_threshold: float, prefs: Dict[str, Dict[str, int]]) -> List[Dict[str, any]]:
50+
""" Filter and sort centers based on distance and preferences. """
51+
school_lat, school_long = float(school['lat']), float(school['long'])
52+
valid_centers = []
53+
for center in centers:
54+
if center['cscode'] == school['scode']:
55+
continue
56+
center_lat, center_long = float(center['lat']), float(center['long'])
57+
distance = haversine_distance(school_lat, school_long, center_lat, center_long)
58+
pref_score = prefs.get(school['scode'], {}).get(center['cscode'], 0)
59+
if distance <= distance_threshold and pref_score > PREF_CUTOFF:
60+
valid_centers.append({**center, 'distance_km': distance, 'pref_score': pref_score})
61+
62+
return sorted(valid_centers, key=lambda c: (c['distance_km'], -c['pref_score']))
63+
64+
def allocate_centers(schools: List[Dict[str, str]], centers: List[Dict[str, str]], prefs: Dict[str, Dict[str, int]]) -> Tuple[Dict[str, Dict[str, int]], int]:
65+
""" Allocate centers to schools based on preferences and capacities. """
66+
allocations = {}
67+
remaining_students = 0
68+
centers_capacity = {c['cscode']: int(c['capacity']) for c in centers}
69+
70+
for school in schools:
71+
needed = int(school['count'])
72+
centers_for_school = filter_and_sort_centers(school, centers, PREF_DISTANCE_THRESHOLD, prefs)
73+
for center in centers_for_school:
74+
if needed <= 0:
75+
break
76+
allot = min(needed, centers_capacity[center['cscode']], MIN_STUDENT_IN_CENTER)
77+
if centers_capacity[center['cscode']] >= allot:
78+
if school['scode'] not in allocations:
79+
allocations[school['scode']] = {}
80+
allocations[school['scode']][center['cscode']] = allocations[school['scode']].get(center['cscode'], 0) + allot
81+
centers_capacity[center['cscode']] -= allot
82+
needed -= allot
83+
84+
if needed > 0: # If students are still unallocated, attempt with a relaxed distance threshold
85+
expanded_centers = filter_and_sort_centers(school, centers, ABS_DISTANCE_THRESHOLD, prefs)
86+
for center in expanded_centers:
87+
if needed <= 0:
88+
break
89+
stretched_capacity = math.floor(int(center['capacity']) * STRETCH_CAPACITY_FACTOR + centers_capacity[center['cscode']])
90+
allot = min(needed, max(stretched_capacity, MIN_STUDENT_IN_CENTER))
91+
if stretched_capacity >= allot:
92+
if school['scode'] not in allocations:
93+
allocations[school['scode']] = {}
94+
allocations[school['scode']][center['cscode']] = allocations[school['scode']].get(center['cscode'], 0) + allot
95+
centers_capacity[center['cscode']] -= allot
96+
needed -= allot
97+
98+
remaining_students += needed
99+
100+
return allocations, remaining_students
101+
102+
def main():
103+
parser = argparse.ArgumentParser(description='Assigns exam centers to students based on preferences.')
104+
parser.add_argument('schools_tsv', help="Tab separated file containing school details")
105+
parser.add_argument('centers_tsv', help="Tab separated file containing center details")
106+
parser.add_argument('prefs_tsv', help="Tab separated file containing preference scores")
107+
parser.add_argument('-o', '--output', default='school-center.tsv', help='Output file')
108+
args = parser.parse_args()
109+
110+
schools = read_tsv(args.schools_tsv)
111+
centers = read_tsv(args.centers_tsv)
112+
prefs = read_prefs(args.prefs_tsv)
113+
114+
allocations, remaining_students = allocate_centers(schools, centers, prefs)
115+
116+
# Output results
117+
with open(args.output, 'w', newline='', encoding='utf-8') as file:
118+
writer = csv.writer(file, delimiter='\t')
119+
writer.writerow(["scode", "school", "cscode", "center", "center_address", "allocation", "distance_km"])
120+
for scode, school_allocations in allocations.items():
121+
for cscode, count in school_allocations.items():
122+
school = next((s for s in schools if s['scode'] == scode), None)
123+
center = next((c for c in centers if c['cscode'] == cscode), None)
124+
if school and center:
125+
print(school)
126+
writer.writerow([scode, school['name-address'], cscode, center['name'], center['address'], count, center.get('distance_km', '')])
127+
128+
print(f"Total students not assigned: {remaining_students}")
129+
130+
if __name__ == '__main__':
131+
main()

tests/__init__.py

Whitespace-only changes.

tests/test_school_center.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
from school_center import read_tsv, read_prefs, haversine_distance, filter_and_sort_centers, allocate_centers, main
4+
5+
class TestSchoolCenterScript(unittest.TestCase):
6+
7+
def test_haversine_distance(self):
8+
# Test the distance calculation for known coordinates
9+
distance = haversine_distance(36.12, -86.67, 33.94, -118.40)
10+
self.assertAlmostEqual(distance, 2887, places=0) # Approximate distance in km
11+
12+
@patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="scode\tcscode\tpref\treason\n27001\t28001\t-5\tsame management\n")
13+
def test_read_prefs(self, mock_file):
14+
result = read_prefs('dummy_path')
15+
expected = {'27001': {'28001': -5}}
16+
self.assertEqual(result, expected)
17+
18+
@patch('school_center.read_tsv')
19+
def test_filter_and_sort_centers(self, mock_read_tsv):
20+
mock_read_tsv.return_value = [
21+
{'cscode': '28001', 'capacity': '120', 'name': 'Center A', 'address': 'Location X', 'lat': '27.0', 'long': '85.0'},
22+
{'cscode': '28002', 'capacity': '200', 'name': 'Center B', 'address': 'Location Y', 'lat': '27.5', 'long': '85.5'}
23+
]
24+
school = {'scode': '27001', 'lat': '27.0', 'long': '85.0'}
25+
centers = mock_read_tsv()
26+
prefs = {'27001': {'28001': 0, '28002': 3}}
27+
sorted_centers = filter_and_sort_centers(school, centers, 10, prefs)
28+
self.assertEqual(len(sorted_centers), 2)
29+
self.assertEqual(sorted_centers[0]['cscode'], '28002') # Should be sorted by preference score
30+
31+
@patch('school_center.read_tsv')
32+
@patch('school_center.filter_and_sort_centers')
33+
def test_allocate_centers(self, mock_filter_and_sort, mock_read_tsv):
34+
mock_read_tsv.return_value = [
35+
{'cscode': '28001', 'capacity': '120', 'lat': '27.0', 'long': '85.0'},
36+
{'cscode': '28002', 'capacity': '200', 'lat': '27.5', 'long': '85.5'}
37+
]
38+
mock_filter_and_sort.return_value = [
39+
{'cscode': '28001', 'capacity': '120', 'name': 'Center A', 'address': 'Location X', 'lat': '27.0', 'long': '85.0', 'distance_km': 0, 'pref_score': 0},
40+
{'cscode': '28002', 'capacity': '200', 'name': 'Center B', 'address': 'Location Y', 'lat': '27.5', 'long': '85.5', 'distance_km': 10, 'pref_score': 3}
41+
]
42+
schools = [{'scode': '27001', 'count': '150', 'lat': '27.0', 'long': '85.0'}]
43+
centers = mock_read_tsv()
44+
prefs = {'27001': {'28001': 0, '28002': 3}}
45+
allocations, remaining_students = allocate_centers(schools, centers, prefs)
46+
self.assertEqual(remaining_students, 0)
47+
self.assertIn('27001', allocations)
48+
self.assertTrue(allocations['27001']['28002'] > 0)
49+
50+
@patch('argparse.ArgumentParser.parse_args')
51+
@patch('builtins.open', new_callable=unittest.mock.mock_open)
52+
@patch('school_center.allocate_centers', return_value=({}, 0))
53+
@patch('school_center.read_prefs', return_value={})
54+
@patch('school_center.read_tsv', return_value=[])
55+
def test_main(self, mock_read_tsv, mock_read_prefs, mock_allocate, mock_open, mock_args):
56+
mock_args.return_value = MagicMock(schools_tsv='schools.tsv', centers_tsv='centers.tsv', prefs_tsv='prefs.tsv', output='output.tsv')
57+
main()
58+
mock_read_tsv.assert_called()
59+
mock_read_prefs.assert_called()
60+
mock_allocate.assert_called()
61+
62+
if __name__ == '__main__':
63+
unittest.main()

0 commit comments

Comments
 (0)