Skip to content

Commit

Permalink
5oappy/feature/calculator v2 (#294)
Browse files Browse the repository at this point in the history
## Describe your changes
big one: removed vehicles and its ties to shifts, that will be handled
separately via a flag in `shift_request` table.

added `shift_position` which acts as a replacement for vehicles and
essentially creates position slots for each shift of which volunteers
can be assigned to, not very resource efficient I'm aware but it seemed
the most object oriented way of doing it.

work flow cant be thought of as:

1) "scheduler-user" creates a `shift_request` entry
`shift_request` entry contains key information about the shift etc but
specifically the `vehicle_id` flag which ...
2) triggers a function that adds the specific assignment and amount of
`roles` required for the shift to the `shift_position` table linked back
to the shift. The amount of positions and their roles types depends on
the `vehicle_id` flag. (note this is not implemented in this pr but will
be created soon
[FIR-112](https://fireapp-emergiq-2024.atlassian.net/browse/FIR-112) )

3) Then, the scheduler (`optimiser`) is called and it obtains all the
info from the relevant tables: `shift_request, shift_position, roles`
and extrapolates data into values that mini zinc can use.

4) mini zinc returns a 2d array of best volunteers for each shift as
well as persisting them to the database table `shift_request_volunteer`.
( [FIR-111](https://fireapp-emergiq-2024.atlassian.net/browse/FIR-111) )

Other misc changes included:

- Adding unavailability parameter to `user` table. 
- Changing the link to roles be through role.code instead of Id as it
makes more canonical sense. BIG RISK as it might mess with mini zinc ,
`user_roles` and how we parse the roles types however it can simply be
reworked. added unique flag to the code to ensure only one of each role
type is ever created.
- Commenting out the deprecated code of the original calculator. Ideally
I should have set this up in a separate file (I still can, id just have
to copy all local changes into a new branch in a separate file) I just
happened to do it in the file as it was easiest to see which stuff
impacted which directly.

more details of the table linking logic at commit
b099dac

note theres is a conflict in this pr in the domain/entity/__init__.py
just of notification stuff will be handled when prompted by GitHub.
## Issue ticket number and link
[FIR-4](https://fireapp-emergiq-2024.atlassian.net/browse/FIR-4)
[FIR-51](https://fireapp-emergiq-2024.atlassian.net/browse/FIR-51)

note: its a tiny bit of a mess as I wasn't signed in and it wouldn't let
me pr but I didn't realise so I went through a whole mess of rebasing
origin/main into my branch before realising
  • Loading branch information
5oappy authored Oct 3, 2024
1 parent cb6368e commit ad82cc5
Show file tree
Hide file tree
Showing 6 changed files with 93 additions and 152 deletions.
4 changes: 3 additions & 1 deletion domain/entity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@
from .chatbot_input import ChatbotInput
from .shift_request import ShiftRequest
from .shift_request_volunteer import ShiftRequestVolunteer
from .fcm_tokens import FCMToken
from .shift_position import ShiftPosition
from .fcm_tokens import FCMToken

4 changes: 2 additions & 2 deletions domain/entity/role.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum
from domain.base import Base


class Role(Base):
__tablename__ = 'role'
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(256), nullable=False)
code = Column(String(256), nullable=False, unique=True) # Must be unique
name = Column(String(256), nullable=False)
deleted = Column(Boolean, nullable=False, default=False)
update_date_time = Column(DateTime, name='last_update_datetime', default=datetime.now(), nullable=False)
Expand Down
21 changes: 21 additions & 0 deletions domain/entity/shift_position.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from datetime import datetime

from sqlalchemy import Column, String, DateTime, ForeignKey, Integer, Enum
from sqlalchemy.orm import relationship

from domain.base import Base


class ShiftPosition(Base):
__tablename__ = 'shift_position'

id = Column(Integer, primary_key=True, autoincrement=True)
shift_id = Column(Integer, ForeignKey('shift_request.id'), nullable=False)
role_code = Column(String(256), ForeignKey('role.code'), nullable=False)

# Many-to-one relationship with Role
role = relationship("Role")

# One-to-one relationship with ShiftRequestVolunteer using backref
volunteer = relationship("ShiftRequestVolunteer", uselist=False, backref="shift_position",
primaryjoin="ShiftPosition.id == ShiftRequestVolunteer.position_id")
8 changes: 6 additions & 2 deletions domain/entity/shift_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from domain.base import Base



class ShiftRequest(Base):
__tablename__ = 'shift_request'

Expand All @@ -19,5 +18,10 @@ class ShiftRequest(Base):
status = Column(Enum(ShiftStatus), name='status', default=ShiftStatus.WAITING, nullable=False)
update_date_time = Column(DateTime, name='last_update_datetime', default=datetime.now(), nullable=False)
insert_date_time = Column(DateTime, name='created_datetime', default=datetime.now(), nullable=False)
Column()
user = relationship("User")

# One-to-many relationship: A shift can have multiple positions
positions = relationship("ShiftPosition", backref="shift_request")


user = relationship("User")
1 change: 1 addition & 0 deletions domain/entity/shift_request_volunteer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ShiftRequestVolunteer(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('user.id'), name='user_id', nullable=False)
request_id = Column(Integer, ForeignKey('shift_request.id'), name='request_id', nullable=False)
position_id = Column(Integer, ForeignKey('shift_position.id'), name="position_id", nullable=False)
status = Column(Enum(ShiftVolunteerStatus), name='status', nullable=False, default=ShiftVolunteerStatus.PENDING)
update_date_time = Column(DateTime, name='last_update_datetime', default=datetime.now(), nullable=False)
insert_date_time = Column(DateTime, name='created_datetime', default=datetime.now(), nullable=False)
Expand Down
207 changes: 60 additions & 147 deletions services/optimiser/calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from typing import List
from sqlalchemy import orm, func, alias

from domain import User, AssetRequestVehicle, AssetType, Role, UserRole, AssetTypeRole
from domain import (User, AssetType, Role, UserRole, AssetTypeRole, ShiftRequest, ShiftPosition,
UnavailabilityTime)


class Calculator:
Expand All @@ -15,33 +16,18 @@ class Calculator:
# Master list of all volunteers, these fetched once so that the order of the records in the list is deterministic.
# This matters as the lists passed to Minizinc are not keyed and are instead used by index.
_users_ = []
_asset_request_vehicles_ = []
_asset_types_ = []
_shifts_ = []
_positions_ = []
_roles_ = []
_asset_type_seats_ = []


# A single database session is used for all transactions in the optimiser. This is initialised by the calling
# function.
_session_ = None

# This is the granularity of the optimiser, it won't consider any times more specific thann this number of minutes
# when scheduling employees.
# It should match the volunteer's shift planner granularity
_time_granularity_ = timedelta(minutes=30)

# The request to optimise.
request_id = None

# Used to map between datetime.datetime().weekday() to the users availability as its agnostic of the time of year.
_week_map_ = {
0: "Monday",
1: "Tuesday",
2: "Wednesday",
3: "Thursday",
4: "Friday",
5: "Saturday",
6: "Sunday"
}

def __init__(self, session: orm.session, request_id: int):
self._session_ = session
Expand All @@ -50,11 +36,6 @@ def __init__(self, session: orm.session, request_id: int):
# Fetch all the request data that will be used in the optimisation functions once.
self.__get_request_data()

def get_number_of_vehicles(self) -> int:
"""
@return: The number of vehicles to be optimised.
"""
return len(self._asset_request_vehicles_)

def get_number_of_roles(self):
"""
Expand All @@ -74,12 +55,6 @@ def get_volunteer_by_index(self, index) -> User:
def get_role_by_index(self, index) -> Role:
return self._roles_[index]

def get_asset_request_by_index(self, index) -> AssetRequestVehicle:
return self._asset_request_vehicles_[index]

def get_asset_requests(self) -> List[AssetRequestVehicle]:
return self._asset_request_vehicles_

def get_roles(self) -> List[Role]:
return self._roles_

Expand All @@ -91,156 +66,94 @@ def __get_request_data(self):
"""
self._users_ = self._session_.query(User) \
.all()
self._asset_request_vehicles_ = self._session_.query(AssetRequestVehicle) \
.filter(AssetRequestVehicle.request_id == self.request_id) \
self._shift_ = self._session_.query(ShiftRequest) \
.all()
self._asset_types_ = self._session_.query(AssetType) \
.filter(AssetType.deleted == False) \
self._positions_ = self._session_.query(ShiftPosition) \
.all()
# return the roles that have not been deleted for the all shifts
self._roles_ = self._session_.query(Role) \
.filter(Role.deleted == False) \
.all()
self._asset_type_seats_ = self._session_.query(AssetTypeRole) \
.join(Role, Role.id == AssetTypeRole.role_id) \
.filter(Role.deleted == False) \
.all()

def calculate_deltas(self, start: datetime, end: datetime) -> List[datetime]:
"""
Given the start time and end time of a shift, generate a list of shift "blocks" which represent a
self._time_granularity_ period that the user would need to be available for
@param start: The start time of the shift.
@param end: The end time of the shift.
@return: A list of dates between the two dates.
"""
deltas = []
curr = start
while curr < end:
deltas.append(curr)
curr += self._time_granularity_
return deltas

@staticmethod
def float_time_to_datetime(float_hours: float, d: datetime) -> datetime:
"""
Given a users available time as a date agnostic decimal hour and a shift blocks date, combine the two into a
datetime that can be used for equality and range comparisons.
@param float_hours: The users decimal hour availability, i.e. 3.5 is 3:30am, 4.0 is 4am,
@param d: The shift blocks date time
@return: The decimal hours time on the shift blocks day as datetime
"""
# Assertion to ensure the front end garbage hasn't continued
assert 0 <= float_hours <= 23.5

# Calculate the actual datetime
hours = int(float_hours)
minutes = int((float_hours * 60) % 60)
return datetime(d.year, d.month, d.day, hours, minutes, 0)

def calculate_compatibility(self) -> List[List[bool]]:
"""
Generates a 2D array of compatibilities between volunteers availabilities and the requirements of the shift.
This is the fairly critical function of the optimiser as its determining in a simple manner if a user is
even available for assignment, regardless of role.
Example 1: The volunteer is available between 2pm to 3pm and the shift is from 2pm to 2:30pm:
Result: True
Example 2: The volunteer is available from 1pm to 2pm and the shift is from 1pm to 3pm:
Result: False
@return:
Generates a 2D array of compatibilities between volunteers' unavailability and the requirements of the shift.
"""
compatibilities = []

# Iterate through each shift in the request
for asset_request_vehicle in self._asset_request_vehicles_:
for shift in self._shifts_:
# Each shift gets its own row in the result set.
shift_compatibility = []

# Shift blocks are the _time_granularity_ sections the volunteer would need to be available for.
# Its calculated by finding all the 30 minute slots between the start time and end time (inclusive)
shift_blocks = self.calculate_deltas(asset_request_vehicle.from_date_time,
asset_request_vehicle.to_date_time)
# Iterate through the users, this makes each element of the array
# Define the shift start and end times
shift_start = shift.startTime
shift_end = shift.endTime

# Iterate through the users
for user in self._users_:
# We start by assuming the user is available, then prove this wrong.
# Start by assuming the user is available
user_available = True

# Iterate through each block to see if the user is available on this shift
shift_block_availability = []
for shift_block in shift_blocks:
available_in_shift = False
for day_availability in user.availabilities[self._week_map_[shift_block.weekday()]]:
# Generate a new datetime object that is the start time and end time of their availability, but
# using the date of the shift block. This lets us calculate availability regardless of the day
# of the year
start_time = self.float_time_to_datetime(day_availability[0], shift_block)
end_time = self.float_time_to_datetime(day_availability[1], shift_block)
if end_time >= shift_block >= start_time:
available_in_shift = True
shift_block_availability.append(available_in_shift)

# If every element in the shift block availability is true, then the user can do this shift.
if False in shift_block_availability:
user_available = False
# Query unavailability times for the current user
unavailability_records = self._session_.query(UnavailabilityTime).filter(
UnavailabilityTime.userId == user.id
).all()

# Check if any unavailability overlaps with the entire shift period
for record in unavailability_records:
if record.start < shift_end and record.end > shift_start:
user_available = False
break # User is unavailable, no need to check further

# Append the user's availability for this shift
shift_compatibility.append(user_available)

# Append the shift compatibilities to the overall result
compatibilities.append(shift_compatibility)
# Return the 2D array
return compatibilities

def calculate_clashes(self) -> List[List[bool]]:
"""
Generate a 2d array of vehicle requests that overlap. This is to ensure that a single user isn't assigned to
multiple vehicles simultaneously. Its expected that each shift is incompatible with itself too.
@return: A 2D array of clashes.
"""
clashes = []
# Iterate through each shift in the request
for this_vehicle in self._asset_request_vehicles_:
this_vehicle_clashes = []
this_shift_blocks = self.calculate_deltas(this_vehicle.from_date_time,
this_vehicle.to_date_time)
for other_vehicle in self._asset_request_vehicles_:
has_clash = False
for this_shift_block in this_shift_blocks:
if other_vehicle.from_date_time <= this_shift_block <= other_vehicle.to_date_time \
and other_vehicle.id != this_vehicle.id:
has_clash = True
this_vehicle_clashes.append(has_clash)
clashes.append(this_vehicle_clashes)
return clashes
# Return the 2D array of compatibilities
return compatibilities

def calculate_skill_requirement(self):
"""
Return a 2D array showing the number of people required for each skill in a asset shift. Might look something
like:
Driver Pilot Ninja
----------------------
Vehicle 1 [[1, 0, 0]
Vehicle 2 [F, 1, 1]]
@return:
Returns a 2D array showing the number of people required for each skill in an asset shift. Example:
Driver Pilot Ninja
----------------------
Shift 1 [[1, 0, 0]
Shift 2 [0, 1, 1]]
Shift 3 [0, 2, 2]]
@return: List of lists containing the required number of people for each role in each shift.
"""
rtn = []
for vehicle in self._asset_request_vehicles_:
this_vehicle = []
# Iterate through each shift
for shift in self._shifts_:
this_position = []
# Iterate through each role
for role in self._roles_:
this_vehicle.append(self.get_role_count(vehicle.asset_type.id, role.id))
rtn.append(this_vehicle)
# Use the get_role_count function to query the number of people required for this role in the current
# shift
role_count = self.get_role_count(shift.id, role.code)

# Append the role count to the current shift's list
this_position.append(role_count)

# Append the list of role counts for this shift to the return list
rtn.append(this_position)

return rtn

def get_role_count(self, asset_type_id, role_id):
def get_role_count(self, shift_request_id, role_code):
"""
Determines the number of each role required for each asset type or 0 if not required.
@param asset_type_id: The asset type to search for.
@param role_id: The role to search for.
@return: The volunteers required or 0 if not required.
given a shift id and a role code, return the number of people required for that specific role
this is done by counting the entries of shift positions that match the role
"""
query = self._session_.query(AssetTypeRole) \
.join(Role, Role.id == AssetTypeRole.role_id) \
.join(AssetType, AssetType.id == AssetTypeRole.asset_type_id) \
query = self._session_.query(func.count(ShiftPosition.id)) \
.join(Role, Role.code == ShiftPosition.role_code) \
.filter(Role.deleted == False) \
.filter(Role.id == role_id) \
.filter(AssetType.id == asset_type_id)
.filter(Role.code == role_code) \
.filter(ShiftPosition.shift_id == shift_request_id) \

result = self._session_.query(func.count('*')).select_from(alias(query)).scalar()
if result is None:
result = 0
Expand Down

0 comments on commit ad82cc5

Please sign in to comment.