Skip to content
Snippets Groups Projects
Commit 58bbe6a9 authored by Felix Blanke's avatar Felix Blanke
Browse files

Merge AK category slots

parent 216eecff
No related branches found
No related tags found
3 merge requests!4Draft: Add object import from JSON data,!3Merge into fork's `main` branch,!2Merge AK category slots
......@@ -151,9 +151,12 @@ class Availability(models.Model):
if not other.overlaps(self, strict=False):
raise Exception('Only overlapping Availabilities can be merged.')
return Availability(
avail = Availability(
start=min(self.start, other.start), end=max(self.end, other.end)
)
if self.event == other.event:
avail.event = self.event
return avail
def __or__(self, other: 'Availability') -> 'Availability':
"""Performs the merge operation: ``availability1 | availability2``"""
......@@ -168,9 +171,12 @@ class Availability(models.Model):
if not other.overlaps(self, False):
raise Exception('Only overlapping Availabilities can be intersected.')
return Availability(
avail = Availability(
start=max(self.start, other.start), end=min(self.end, other.end)
)
if self.event == other.event:
avail.event = self.event
return avail
def __and__(self, other: 'Availability') -> 'Availability':
"""Performs the intersect operation: ``availability1 &
......
import itertools
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Iterable
......@@ -14,6 +15,35 @@ from simple_history.models import HistoricalRecords
from timezone_field import TimeZoneField
@dataclass
class OptimizerTimeslot:
"""Class describing a timeslot. Used to interface with an optimizer."""
avail: "Availability"
idx: int
constraints: set[str]
def merge(self, other: "OptimizerTimeslot") -> "OptimizerTimeslot":
"""Merge with other OptimizerTimeslot.
Creates a new OptimizerTimeslot object.
Its availability is constructed by merging the availabilities of self and other,
its constraints by taking the union of both constraint sets.
As an index, the index of self is used.
"""
avail = self.avail.merge_with(other.avail)
constraints = self.constraints.union(other.constraints)
# we simply use the index of result[-1]
return OptimizerTimeslot(
avail=avail, idx=self.idx, constraints=constraints
)
def __repr__(self) -> str:
return f"({self.avail.simplified}, {self.idx}, {self.constraints})"
TimeslotBlock = list[OptimizerTimeslot]
class Event(models.Model):
"""
An event supplies the frame for all Aks.
......@@ -164,8 +194,13 @@ class Event(models.Model):
)
def _generate_slots_from_block(
self, start: datetime, end: datetime, slot_duration: timedelta, slot_index: int = 0
) -> Iterable[list[int, "Availability"]]:
self,
start: datetime,
end: datetime,
slot_duration: timedelta,
slot_index: int = 0,
constraints: set[str] | None = None,
) -> Iterable[TimeslotBlock]:
"""Discretize a time range into timeslots.
Uses a uniform discretization into blocks of length `slot_duration`,
......@@ -179,8 +214,7 @@ class Event(models.Model):
:param slot_index: index of the first timeslot. Defaults to 0.
:yield: Block of optimizer timeslots as the discretization result.
:ytype: list of tuples, each consisisting of the timeslot id
and its availability to indicate its start and duration.
:ytype: list of TimeslotBlock
"""
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
......@@ -189,6 +223,9 @@ class Event(models.Model):
current_slot_start = start
previous_slot_start: datetime | None = None
if constraints is None:
constraints = set()
current_block = []
room_availabilities = list({
......@@ -213,7 +250,9 @@ class Event(models.Model):
yield current_block
current_block = []
current_block.append((slot_index, slot))
current_block.append(
OptimizerTimeslot(avail=slot, idx=slot_index, constraints=constraints)
)
previous_slot_start = current_slot_start
slot_index += 1
......@@ -224,44 +263,113 @@ class Event(models.Model):
return slot_index
def uniform_time_slots(self, *, slots_in_an_hour=1.0) -> Iterable[list[int, "Availability"]]:
def uniform_time_slots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]:
"""Uniformly discretize the entire event into a single block of timeslots.
:param slots_in_an_hour: The percentage of an hour covered by a single slot.
Determines the discretization granularity.
:yield: Block of optimizer timeslots as the discretization result.
:ytype: a single list of tuples, each consisisting of the timeslot id
and its availability to indicate its start and duration.
:ytype: a single list of TimeslotBlock
"""
all_category_constraints = AKCategory.create_category_constraints(
AKCategory.objects.filter(event=self).all()
)
yield from self._generate_slots_from_block(
start=self.start,
end=self.end,
slot_duration=timedelta(hours=1.0 / slots_in_an_hour),
constraints=all_category_constraints,
)
def default_time_slots(self, *, slots_in_an_hour=1.0) -> Iterable[list[int, "Availability"]]:
"""Discretize the all default slots into a blocks of timeslots.
def default_time_slots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]:
"""Discretize all default slots into blocks of timeslots.
In the discretization each default slot corresponds to one block.
:param slots_in_an_hour: The percentage of an hour covered by a single slot.
Determines the discretization granularity.
:yield: Block of optimizer timeslots as the discretization result.
:ytype: list of tuples, each consisisting of the timeslot id
and its availability to indicate its start and duration.
:ytype: list of TimeslotBlock
"""
slot_duration = timedelta(hours=1.0 / slots_in_an_hour)
slot_index = 0
for block_slot in DefaultSlot.objects.filter(event=self).order_by("start", "end"):
# NOTE: We do not differentiate between different primary categories
category_constraints = AKCategory.create_category_constraints(
block_slot.primary_categories.all()
)
slot_index = yield from self._generate_slots_from_block(
start=block_slot.start,
end=block_slot.end,
slot_duration=slot_duration,
slot_index=slot_index,
constraints=category_constraints,
)
def merge_blocks(
self, blocks: Iterable[TimeslotBlock]
) -> Iterable[TimeslotBlock]:
"""Merge iterable of blocks together.
The timeslots of all blocks are grouped into maximal blocks.
Timeslots with the same start and end are identified with each other
and merged (cf `OptimizerTimeslot.merge`).
Throws a ValueError if any timeslots are overlapping but do not
share the same start and end, i.e. partial overlap is not allowed.
:param blocks: iterable of blocks to merge.
:return: iterable of merged blocks.
:rtype: iterable over lists of OptimizerTimeslot objects
"""
if not blocks:
return []
# flatten timeslot iterables to single chain
timeslot_chain = itertools.chain.from_iterable(blocks)
# sort timeslots according to start
timeslots = sorted(
timeslot_chain,
key=lambda slot: slot.avail.start
)
if not timeslots:
return []
all_blocks = []
current_block = [timeslots[0]]
timeslots = timeslots[1:]
for slot in timeslots:
if current_block and slot.avail.overlaps(current_block[-1].avail, strict=True):
if (
slot.avail.start == current_block[-1].avail.start
and slot.avail.end == current_block[-1].avail.end
):
# the same timeslot -> merge
current_block[-1] = current_block[-1].merge(slot)
else:
# partial overlap of interiors -> not supported
# TODO: Show comprehensive message in production
raise ValueError(
"Partially overlapping timeslots are not supported!"
f" ({current_block[-1].avail.simplified}, {slot.avail.simplified})"
)
elif not current_block or slot.avail.overlaps(current_block[-1].avail, strict=False):
# only endpoints in intersection -> same block
current_block.append(slot)
else:
# no overlap at all -> new block
all_blocks.append(current_block)
current_block = [slot]
if current_block:
all_blocks.append(current_block)
return all_blocks
def schedule_from_json(self, schedule: str) -> None:
"""Load AK schedule from a json string.
......@@ -275,9 +383,9 @@ class Event(models.Model):
slots_in_an_hour = schedule["input"]["timeslots"]["info"]["duration"]
timeslot_dict = {
slot_idx: slot
for block in self.default_time_slots(slots_in_an_hour=slots_in_an_hour)
for slot_idx, slot in block
timeslot.idx: timeslot
for block in self.merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour))
for timeslot in block
}
for scheduled_slot in schedule["scheduled_aks"]:
......@@ -286,8 +394,8 @@ class Event(models.Model):
scheduled_slot["timeslot_ids"] = list(map(int, scheduled_slot["timeslot_ids"]))
start_timeslot = timeslot_dict[min(scheduled_slot["timeslot_ids"])]
end_timeslot = timeslot_dict[max(scheduled_slot["timeslot_ids"])]
start_timeslot = timeslot_dict[min(scheduled_slot["timeslot_ids"])].avail
end_timeslot = timeslot_dict[max(scheduled_slot["timeslot_ids"])].avail
slot.start = start_timeslot.start
slot.duration = (end_timeslot.end - start_timeslot.start).total_seconds() / 3600.0
......@@ -390,6 +498,20 @@ class AKCategory(models.Model):
def __str__(self):
return self.name
@staticmethod
def create_category_constraints(categories: Iterable["AKCategory"]) -> set[str]:
"""Create a set of constraint strings from an AKCategory iterable.
:param categories: The iterable of categories to derive the constraint strings from.
:return: A set of category constraint strings, i.e. strings of the form
'availability-cat-<cat.name>'.
:rtype: set of strings.
"""
return {
f"availability-cat-{cat.name}"
for cat in categories
}
class AKTrack(models.Model):
""" An AKTrack describes a set of semantically related AKs.
......@@ -821,6 +943,10 @@ class AKSlot(models.Model):
for owner in self.ak.owners.all():
data["time_constraints"].extend(_owner_time_constraints(owner))
if self.ak.category:
category_constraints = AKCategory.create_category_constraints([self.ak.category])
data["time_constraints"].extend(category_constraints)
if self.room is not None and self.fixed:
data["room_constraints"].append(f"availability-room-{self.room.pk}")
......
......@@ -50,6 +50,7 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
context_object_name = "slots"
title = _("AK JSON Export")
def _test_slot_contained(self, slot: Availability, availabilities: List[Availability]) -> bool:
return any(availability.contains(slot) for availability in availabilities)
......@@ -69,7 +70,6 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
and self._test_slot_contained(slot, availabilities)
)
def get_queryset(self):
return super().get_queryset().order_by("ak__track")
......@@ -110,38 +110,38 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
if (values := AKSlot.objects.select_related().filter(ak__pk=ak_id, fixed=True)).exists()
}
for block in self.event.default_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR):
for block in self.event.merge_blocks(self.event.default_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR)):
current_block = []
for slot_index, slot in block:
for timeslot in block:
time_constraints = []
if self.event.reso_deadline is None or slot.end < self.event.reso_deadline:
if self.event.reso_deadline is None or timeslot.avail.end < self.event.reso_deadline:
time_constraints.append("resolution")
time_constraints.extend([
f"availability-ak-{ak_id}"
for ak_id, availabilities in ak_availabilities.items()
if (
self._test_add_constraint(slot, availabilities)
or self._test_fixed_ak(ak_id, slot, ak_fixed)
self._test_add_constraint(timeslot.avail, availabilities)
or self._test_fixed_ak(ak_id, timeslot.avail, ak_fixed)
)
])
time_constraints.extend([
f"availability-person-{person_id}"
for person_id, availabilities in person_availabilities.items()
if self._test_add_constraint(slot, availabilities)
if self._test_add_constraint(timeslot.avail, availabilities)
])
time_constraints.extend([
f"availability-room-{room_id}"
for room_id, availabilities in room_availabilities.items()
if self._test_add_constraint(slot, availabilities)
if self._test_add_constraint(timeslot.avail, availabilities)
])
time_constraints.extend(timeslot.constraints)
current_block.append({
"id": str(slot_index),
"id": str(timeslot.idx),
"info": {
"start": slot.simplified,
"start": timeslot.avail.simplified,
},
"fulfilled_time_constraints": time_constraints,
})
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment