流動的な時間にわたってリソースの可用性をモデル化するのに役立つデータ型を探しています。
私はさまざまな方向からこの問題に取り組んできましたが、データ型がわからないため、時間の経過とともに整数のような単純なものをモデル化できないという根本的な問題に常に戻ります。
予定を時系列イベントに変換できます(たとえば、予定の到着は可用性-1を意味し、予定は平均+1を意味します)が、そのデータを操作する方法がまだわからないので、可用性がゼロより大きい期間を特定できます。
誰かが集中力の欠如を理由に賛成票を投じましたが、ここでの私の目標はかなり特異的であるので、問題をグラフィカルに説明しようと思います。アクティブなジョブの数が特定の容量を下回る期間を推測しようとしています。
既知の並列容量の範囲(9〜6の3など)と、開始/終了が可変のジョブのリストを、利用可能な時間の時間範囲のリストに変換します。
時間分解能が1分よりも細かい場合を除き、各ジョブの期間にわたって割り当てられた一連のjobIdを使用して、その日の分のマップを使用することをお勧めします
例えば:
# convert time to minute of the day (assumes24H time, but you can make this your own way)
def toMinute(time):
return sum(p*t for p,t in Zip(map(int,time.split(":")),(60,1)))
def toTime(minute):
return f"{minute//60}:{minute%60:02d}"
# booking a job adds it to all minutes covered by its duration
def book(timeMap,jobId,start,duration):
startMin = toMinute(start)
for m in range(startMin,startMin+duration):
timeMap[m].add(jobId)
# unbooking a job removes it from all minutes where it was present
def unbook(timeMap,jobId):
for s in timeMap:
s.discard(jobId)
# return time ranges for minutes meeting a given condition
def minuteSpans(timeMap,condition,start="09:00",end="18:00"):
start,end = toMinute(start),toMinute(end)
timeRange = timeMap[start:end]
match = [condition(s) for s in timeRange]
breaks = [True] + [a!=b for a,b in Zip(match,match[1:])]
starts = [i for (i,a),b in Zip(enumerate(match),breaks) if b]
return [(start+s,start+e) for s,e in Zip(starts,starts[1:]+[len(match)]) if match[s]]
def timeSpans(timeMap,condition,start="09:00",end="18:00"):
return [(toTime(s),toTime(e)) for s,e in minuteSpans(timeMap,condition,start,end)]
# availability is ranges of minutes where the number of jobs is less than your capacity
def available(timeMap,start="09:00",end="18:00",maxJobs=5):
return timeSpans(timeMap,lambda s:len(s)<maxJobs,start,end)
使用例:
timeMap = [set() for _ in range(1440)]
book(timeMap,"job1","9:45",25)
book(timeMap,"job2","9:30",45)
book(timeMap,"job3","9:00",90)
print(available(timeMap,maxJobs=3))
[('9:00', '9:45'), ('10:10', '18:00')]
print(timeSpans(timeMap,lambda s:"job3" in s))
[('9:00', '10:30')]
いくつかの調整を行うと、一部の期間(ランチタイムなど)をスキップする不連続なジョブになる可能性さえあります。一部の期間に偽のジョブを配置することにより、それらをブロックすることもできます。
ジョブキューを個別に管理する必要がある場合は、個別のタイムマップ(キューごとに1つ)を用意し、全体像を把握する必要があるときにそれらを1つに結合できます。
print(available(timeMap1,maxJobs=1))
print(available(timeMap2,maxJobs=1))
print(available(timeMap3,maxJobs=1))
globalMap = list(set.union(*qs) for qs in Zip(timeMap1,timeMap2,timeMap3))
print(available(globalMap),maxJobs=3)
これらすべてを(個々の関数ではなく)TimeMapクラスに入れてください。これを操作するための非常に優れたツールセットが必要です。
ジョブを実行できるレーンを表す専用クラスを使用できます。これらのオブジェクトは、ジョブを追跡し、それに応じてそれらの可用性を追跡できます。
import bisect
from datetime import time
from functools import total_ordering
import math
@total_ordering
class TimeSlot:
def __init__(self, start, stop, lane):
self.start = start
self.stop = stop
self.lane = lane
def __contains__(self, other):
return self.start <= other.start and self.stop >= other.stop
def __lt__(self, other):
return (self.start, -self.stop.second) < (other.start, -other.stop.second)
def __eq__(self, other):
return (self.start, -self.stop.second) == (other.start, -other.stop.second)
def __str__(self):
return f'({self.lane}) {[self.start, self.stop]}'
__repr__ = __str__
class Lane:
@total_ordering
class TimeHorizon:
def __repr__(self):
return '...'
def __lt__(self, other):
return False
def __eq__(self, other):
return False
@property
def second(self):
return math.inf
@property
def timestamp(self):
return math.inf
time_horizon = TimeHorizon()
del TimeHorizon
def __init__(self, start, nr):
self.nr = nr
self.availability = [TimeSlot(start, self.time_horizon, self)]
def add_job(self, job):
if not isinstance(job, TimeSlot):
job = TimeSlot(*job, self)
# We want to bisect_right but only on the start time,
# so we need to do it manually if they are equal.
index = bisect.bisect_left(self.availability, job)
if index < len(self.availability):
index += (job.start == self.availability[index].start)
index -= 1 # select the corresponding free slot
slot = self.availability[index]
if slot.start > job.start or slot.stop is not self.time_horizon and job.stop > slot.stop:
raise ValueError('Requested time slot not available')
if job == slot:
del self.availability[index]
Elif job.start == slot.start:
slot.start = job.stop
Elif job.stop == slot.stop:
slot.stop = job.start
else:
slot_end = slot.stop
slot.stop = job.start
self.availability.insert(index+1, TimeSlot(job.stop, slot_end, self))
Lane
オブジェクトは次のように使用できます。
lane = Lane(start=time(9), nr=1)
print(lane.availability)
lane.add_job([time(11), time(14)])
print(lane.availability)
出力:
[(1) [datetime.time(9, 0), ...]]
[(1) [datetime.time(9, 0), datetime.time(11, 0)],
(1) [datetime.time(14, 0), ...]]
ジョブを追加すると、可用性も更新されます。
これで、これらのレーンオブジェクトの複数を一緒に使用して、完全なスケジュールを表すことができます。必要に応じてジョブを追加でき、可用性は自動的に更新されます。
class Schedule:
def __init__(self, n_lanes, start):
self.lanes = [Lane(start, nr=i) for i in range(n_lanes)]
def add_job(self, job):
for lane in self.lanes:
try:
lane.add_job(job)
except ValueError:
pass
else:
break
from pprint import pprint
# Example jobs from OP.
jobs = [(time(10), time(15)),
(time(9), time(11)),
(time(12, 30), time(16)),
(time(10), time(18))]
schedule = Schedule(3, start=time(9))
for job in jobs:
schedule.add_job(job)
for lane in schedule.lanes:
pprint(lane.availability)
出力:
[(0) [datetime.time(9, 0), datetime.time(10, 0)],
(0) [datetime.time(15, 0), ...]]
[(1) [datetime.time(11, 0), datetime.time(12, 30)],
(1) [datetime.time(16, 0), ...]]
[(2) [datetime.time(9, 0), datetime.time(10, 0)],
(2) [datetime.time(18, 0), ...]]
新しいジョブを登録するときに最適なスロットを選択するために、すべてのレーンのタイムスロットを追跡する専用のツリーのような構造を作成できます。ツリーのノードは単一のタイムスロットを表し、その子はすべてそのスロットに含まれるタイムスロットです。その後、新しいジョブを登録するときに、ツリーを検索して最適なスロットを見つけることができます。ツリーとレーンは同じタイムスロットを共有するため、スロットを削除するか、新しいスロットを挿入する場合にのみ、スロットを手動で調整する必要があります。ここに関連するコードがあります、それは少し長いです(ただのドラフト):
import itertools as it
class OneStepBuffered:
"""Can back up elements that are consumed by `it.takewhile`.
From: https://stackoverflow.com/a/30615837/3767239
"""
_sentinel = object()
def __init__(self, it):
self._it = iter(it)
self._last = self._sentinel
self._next = self._sentinel
def __iter__(self):
return self
def __next__(self):
sentinel = self._sentinel
if self._next is not sentinel:
next_val, self._next = self._next, sentinel
return next_val
try:
self._last = next(self._it)
return self._last
except StopIteration:
self._last = self._next = sentinel
raise
def step_back(self):
if self._last is self._sentinel:
raise ValueError("Can't back up a step")
self._next, self._last = self._last, self._sentinel
class SlotTree:
def __init__(self, slot, subslots, parent=None):
self.parent = parent
self.slot = slot
self.subslots = []
slots = OneStepBuffered(subslots)
for slot in slots:
subslots = it.takewhile(lambda x: x.stop <= slot.stop, slots)
self.subslots.append(SlotTree(slot, subslots, self))
try:
slots.step_back()
except ValueError:
break
def __str__(self):
sub_repr = ['\n| '.join(str(slot).split('\n'))
for slot in self.subslots]
sub_repr = [f'| {x}' for x in sub_repr]
sub_repr = '\n'.join(sub_repr)
sep = '\n' if sub_repr else ''
return f'{self.slot}{sep}{sub_repr}'
def find_minimal_containing_slot(self, slot):
try:
return min(self.find_containing_slots(slot),
key=lambda x: x.slot.stop.second - x.slot.start.second)
except ValueError:
raise ValueError('Requested time slot not available') from None
def find_containing_slots(self, slot):
for candidate in self.subslots:
if slot in candidate.slot:
yield from candidate.find_containing_slots(slot)
yield candidate
@classmethod
def from_slots(cls, slots):
# Ascending in start time, descending in stop time (secondary).
return cls(cls.__name__, sorted(slots))
class Schedule:
def __init__(self, n_lanes, start):
self.lanes = [Lane(start, i+1) for i in range(n_lanes)]
self.slots = SlotTree.from_slots(
s for lane in self.lanes for s in lane.availability)
def add_job(self, job):
if not isinstance(job, TimeSlot):
job = TimeSlot(*job, lane=None)
# Minimal containing slot is one possible strategy,
# others can be implemented as well.
slot = self.slots.find_minimal_containing_slot(job)
lane = slot.slot.lane
if job == slot.slot:
slot.parent.subslots.remove(slot)
Elif job.start > slot.slot.start and job.stop < slot.slot.stop:
slot.parent.subslots.insert(
slot.parent.subslots.index(slot) + 1,
SlotTree(TimeSlot(job.stop, slot.slot.stop, lane), [], slot.parent))
lane.add_job(job)
これで、Schedule
クラスを使用して、ジョブをレーンに自動的に割り当て、可用性を更新できます。
if __name__ == '__main__':
jobs = [(time(10), time(15)), # example from OP
(time(9), time(11)),
(time(12, 30), time(16)),
(time(10), time(18))]
schedule = Schedule(3, start=time(9))
print(schedule.slots, end='\n\n')
for job in jobs:
print(f'Adding {TimeSlot(*job, "new slot")}')
schedule.add_job(job)
print(schedule.slots, end='\n\n')
出力:
SlotTree
| (1) [datetime.time(9, 0), ...]
| (2) [datetime.time(9, 0), ...]
| (3) [datetime.time(9, 0), ...]
Adding (new slot) [datetime.time(10, 0), datetime.time(15, 0)]
SlotTree
| (1) [datetime.time(9, 0), datetime.time(10, 0)]
| (1) [datetime.time(15, 0), ...]
| (2) [datetime.time(9, 0), ...]
| (3) [datetime.time(9, 0), ...]
Adding (new slot) [datetime.time(9, 0), datetime.time(11, 0)]
SlotTree
| (1) [datetime.time(9, 0), datetime.time(10, 0)]
| (1) [datetime.time(15, 0), ...]
| (2) [datetime.time(11, 0), ...]
| (3) [datetime.time(9, 0), ...]
Adding (new slot) [datetime.time(12, 30), datetime.time(16, 0)]
SlotTree
| (1) [datetime.time(9, 0), datetime.time(10, 0)]
| (1) [datetime.time(15, 0), ...]
| (2) [datetime.time(11, 0), datetime.time(12, 30)]
| (2) [datetime.time(16, 0), ...]
| (3) [datetime.time(9, 0), ...]
Adding (new slot) [datetime.time(10, 0), datetime.time(18, 0)]
SlotTree
| (1) [datetime.time(9, 0), datetime.time(10, 0)]
| (1) [datetime.time(15, 0), ...]
| (2) [datetime.time(11, 0), datetime.time(12, 30)]
| (2) [datetime.time(16, 0), ...]
| (3) [datetime.time(9, 0), datetime.time(10, 0)]
| (3) [datetime.time(18, 0), ...]
番号(i)
はレーン番号を示し、[]
はそのレーンで使用可能なタイムスロットを示します。 ...
は「オープンエンド」(期間)を示します。ご覧のとおり、タイムスロットが調整されてもツリー自体は再構築されません。これは改善の可能性があります。新しいジョブごとに理想的には、対応する最適なタイムスロットがツリーからポップされ、ジョブがスロットにどのように収まるかに応じて、調整されたバージョンと、場合によっては新しいスロットがツリーに戻されます(または、ジョブはスロットに正確に適合します)。
上記の例では、1日とtime
オブジェクトのみを考慮していますが、datetime
オブジェクトで使用するためにコードを拡張することも簡単です。