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

Merge branch 'feature/merge-ak-category-slots' into 'feature-json-export'

Merge AK category slots

See merge request !2
parents 216eecff 58bbe6a9
No related branches found
No related tags found
4 merge requests!17Add view to clear schedule,!4Draft: Add object import from JSON data,!3Merge into fork's `main` branch,!2Merge AK category slots
Pipeline #236656 passed
This commit is part of merge request !3. Comments created here will be created in the context of that merge request.
...@@ -151,9 +151,12 @@ class Availability(models.Model): ...@@ -151,9 +151,12 @@ class Availability(models.Model):
if not other.overlaps(self, strict=False): if not other.overlaps(self, strict=False):
raise Exception('Only overlapping Availabilities can be merged.') raise Exception('Only overlapping Availabilities can be merged.')
return Availability( avail = Availability(
start=min(self.start, other.start), end=max(self.end, other.end) 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': def __or__(self, other: 'Availability') -> 'Availability':
"""Performs the merge operation: ``availability1 | availability2``""" """Performs the merge operation: ``availability1 | availability2``"""
...@@ -168,9 +171,12 @@ class Availability(models.Model): ...@@ -168,9 +171,12 @@ class Availability(models.Model):
if not other.overlaps(self, False): if not other.overlaps(self, False):
raise Exception('Only overlapping Availabilities can be intersected.') raise Exception('Only overlapping Availabilities can be intersected.')
return Availability( avail = Availability(
start=max(self.start, other.start), end=min(self.end, other.end) 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': def __and__(self, other: 'Availability') -> 'Availability':
"""Performs the intersect operation: ``availability1 & """Performs the intersect operation: ``availability1 &
......
import itertools import itertools
import json import json
from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Iterable from typing import Iterable
...@@ -14,6 +15,35 @@ from simple_history.models import HistoricalRecords ...@@ -14,6 +15,35 @@ from simple_history.models import HistoricalRecords
from timezone_field import TimeZoneField 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): class Event(models.Model):
""" """
An event supplies the frame for all Aks. An event supplies the frame for all Aks.
...@@ -164,8 +194,13 @@ class Event(models.Model): ...@@ -164,8 +194,13 @@ class Event(models.Model):
) )
def _generate_slots_from_block( def _generate_slots_from_block(
self, start: datetime, end: datetime, slot_duration: timedelta, slot_index: int = 0 self,
) -> Iterable[list[int, "Availability"]]: start: datetime,
end: datetime,
slot_duration: timedelta,
slot_index: int = 0,
constraints: set[str] | None = None,
) -> Iterable[TimeslotBlock]:
"""Discretize a time range into timeslots. """Discretize a time range into timeslots.
Uses a uniform discretization into blocks of length `slot_duration`, Uses a uniform discretization into blocks of length `slot_duration`,
...@@ -179,8 +214,7 @@ class Event(models.Model): ...@@ -179,8 +214,7 @@ class Event(models.Model):
:param slot_index: index of the first timeslot. Defaults to 0. :param slot_index: index of the first timeslot. Defaults to 0.
:yield: Block of optimizer timeslots as the discretization result. :yield: Block of optimizer timeslots as the discretization result.
:ytype: list of tuples, each consisisting of the timeslot id :ytype: list of TimeslotBlock
and its availability to indicate its start and duration.
""" """
# local import to prevent cyclic import # local import to prevent cyclic import
# pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel
...@@ -189,6 +223,9 @@ class Event(models.Model): ...@@ -189,6 +223,9 @@ class Event(models.Model):
current_slot_start = start current_slot_start = start
previous_slot_start: datetime | None = None previous_slot_start: datetime | None = None
if constraints is None:
constraints = set()
current_block = [] current_block = []
room_availabilities = list({ room_availabilities = list({
...@@ -213,7 +250,9 @@ class Event(models.Model): ...@@ -213,7 +250,9 @@ class Event(models.Model):
yield current_block yield current_block
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 previous_slot_start = current_slot_start
slot_index += 1 slot_index += 1
...@@ -224,44 +263,113 @@ class Event(models.Model): ...@@ -224,44 +263,113 @@ class Event(models.Model):
return slot_index 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. """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. :param slots_in_an_hour: The percentage of an hour covered by a single slot.
Determines the discretization granularity. Determines the discretization granularity.
:yield: Block of optimizer timeslots as the discretization result. :yield: Block of optimizer timeslots as the discretization result.
:ytype: a single list of tuples, each consisisting of the timeslot id :ytype: a single list of TimeslotBlock
and its availability to indicate its start and duration.
""" """
all_category_constraints = AKCategory.create_category_constraints(
AKCategory.objects.filter(event=self).all()
)
yield from self._generate_slots_from_block( yield from self._generate_slots_from_block(
start=self.start, start=self.start,
end=self.end, end=self.end,
slot_duration=timedelta(hours=1.0 / slots_in_an_hour), 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"]]: def default_time_slots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]:
"""Discretize the all default slots into a blocks of timeslots. """Discretize all default slots into blocks of timeslots.
In the discretization each default slot corresponds to one block. 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. :param slots_in_an_hour: The percentage of an hour covered by a single slot.
Determines the discretization granularity. Determines the discretization granularity.
:yield: Block of optimizer timeslots as the discretization result. :yield: Block of optimizer timeslots as the discretization result.
:ytype: list of tuples, each consisisting of the timeslot id :ytype: list of TimeslotBlock
and its availability to indicate its start and duration.
""" """
slot_duration = timedelta(hours=1.0 / slots_in_an_hour) slot_duration = timedelta(hours=1.0 / slots_in_an_hour)
slot_index = 0 slot_index = 0
for block_slot in DefaultSlot.objects.filter(event=self).order_by("start", "end"): 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( slot_index = yield from self._generate_slots_from_block(
start=block_slot.start, start=block_slot.start,
end=block_slot.end, end=block_slot.end,
slot_duration=slot_duration, slot_duration=slot_duration,
slot_index=slot_index, 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: def schedule_from_json(self, schedule: str) -> None:
"""Load AK schedule from a json string. """Load AK schedule from a json string.
...@@ -275,9 +383,9 @@ class Event(models.Model): ...@@ -275,9 +383,9 @@ class Event(models.Model):
slots_in_an_hour = schedule["input"]["timeslots"]["info"]["duration"] slots_in_an_hour = schedule["input"]["timeslots"]["info"]["duration"]
timeslot_dict = { timeslot_dict = {
slot_idx: slot timeslot.idx: timeslot
for block in self.default_time_slots(slots_in_an_hour=slots_in_an_hour) for block in self.merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour))
for slot_idx, slot in block for timeslot in block
} }
for scheduled_slot in schedule["scheduled_aks"]: for scheduled_slot in schedule["scheduled_aks"]:
...@@ -286,8 +394,8 @@ class Event(models.Model): ...@@ -286,8 +394,8 @@ class Event(models.Model):
scheduled_slot["timeslot_ids"] = list(map(int, scheduled_slot["timeslot_ids"])) scheduled_slot["timeslot_ids"] = list(map(int, scheduled_slot["timeslot_ids"]))
start_timeslot = timeslot_dict[min(scheduled_slot["timeslot_ids"])] start_timeslot = timeslot_dict[min(scheduled_slot["timeslot_ids"])].avail
end_timeslot = timeslot_dict[max(scheduled_slot["timeslot_ids"])] end_timeslot = timeslot_dict[max(scheduled_slot["timeslot_ids"])].avail
slot.start = start_timeslot.start slot.start = start_timeslot.start
slot.duration = (end_timeslot.end - start_timeslot.start).total_seconds() / 3600.0 slot.duration = (end_timeslot.end - start_timeslot.start).total_seconds() / 3600.0
...@@ -390,6 +498,20 @@ class AKCategory(models.Model): ...@@ -390,6 +498,20 @@ class AKCategory(models.Model):
def __str__(self): def __str__(self):
return self.name 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): class AKTrack(models.Model):
""" An AKTrack describes a set of semantically related AKs. """ An AKTrack describes a set of semantically related AKs.
...@@ -821,6 +943,10 @@ class AKSlot(models.Model): ...@@ -821,6 +943,10 @@ class AKSlot(models.Model):
for owner in self.ak.owners.all(): for owner in self.ak.owners.all():
data["time_constraints"].extend(_owner_time_constraints(owner)) 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: if self.room is not None and self.fixed:
data["room_constraints"].append(f"availability-room-{self.room.pk}") data["room_constraints"].append(f"availability-room-{self.room.pk}")
......
...@@ -50,6 +50,7 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): ...@@ -50,6 +50,7 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
context_object_name = "slots" context_object_name = "slots"
title = _("AK JSON Export") title = _("AK JSON Export")
def _test_slot_contained(self, slot: Availability, availabilities: List[Availability]) -> bool: def _test_slot_contained(self, slot: Availability, availabilities: List[Availability]) -> bool:
return any(availability.contains(slot) for availability in availabilities) return any(availability.contains(slot) for availability in availabilities)
...@@ -69,7 +70,6 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): ...@@ -69,7 +70,6 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
and self._test_slot_contained(slot, availabilities) and self._test_slot_contained(slot, availabilities)
) )
def get_queryset(self): def get_queryset(self):
return super().get_queryset().order_by("ak__track") return super().get_queryset().order_by("ak__track")
...@@ -110,38 +110,38 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): ...@@ -110,38 +110,38 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
if (values := AKSlot.objects.select_related().filter(ak__pk=ak_id, fixed=True)).exists() 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 = [] current_block = []
for slot_index, slot in block: for timeslot in block:
time_constraints = [] time_constraints = []
if self.event.reso_deadline is None or timeslot.avail.end < self.event.reso_deadline:
if self.event.reso_deadline is None or slot.end < self.event.reso_deadline:
time_constraints.append("resolution") time_constraints.append("resolution")
time_constraints.extend([ time_constraints.extend([
f"availability-ak-{ak_id}" f"availability-ak-{ak_id}"
for ak_id, availabilities in ak_availabilities.items() for ak_id, availabilities in ak_availabilities.items()
if ( if (
self._test_add_constraint(slot, availabilities) self._test_add_constraint(timeslot.avail, availabilities)
or self._test_fixed_ak(ak_id, slot, ak_fixed) or self._test_fixed_ak(ak_id, timeslot.avail, ak_fixed)
) )
]) ])
time_constraints.extend([ time_constraints.extend([
f"availability-person-{person_id}" f"availability-person-{person_id}"
for person_id, availabilities in person_availabilities.items() 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([ time_constraints.extend([
f"availability-room-{room_id}" f"availability-room-{room_id}"
for room_id, availabilities in room_availabilities.items() 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({ current_block.append({
"id": str(slot_index), "id": str(timeslot.idx),
"info": { "info": {
"start": slot.simplified, "start": timeslot.avail.simplified,
}, },
"fulfilled_time_constraints": time_constraints, "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