web-dev-qa-db-ja.com

Pythonで時間をかけて何かをどのようにモデル化しますか?

流動的な時間にわたってリソースの可用性をモデル化するのに役立つデータ型を探しています。

  • 9時から6時まで営業しており、5つの並列ジョブを処理できます。私の想像上のプログラミングランドでは、その範囲でオブジェクトを初期化しました。値は3です。
  • 開始時刻と終了時刻が記載された予約があります。
  • 私はそれらのそれぞれを一日のうちにパンチする必要があります
  • これにより、可用性が上下する種類のグラフが残りますが、最終的には、残りの可用性がある時間範囲をすばやく見つけることができます。

私はさまざまな方向からこの問題に取り組んできましたが、データ型がわからないため、時間の経過とともに整数のような単純なものをモデル化できないという根本的な問題に常に戻ります。

予定を時系列イベントに変換できます(たとえば、予定の到着は可用性-1を意味し、予定は平均+1を意味します)が、そのデータを操作する方法がまだわからないので、可用性がゼロより大きい期間を特定できます。


誰かが集中力の欠如を理由に賛成票を投じましたが、ここでの私の目標はかなり特異的であるので、問題をグラフィカルに説明しようと思います。アクティブなジョブの数が特定の容量を下回る期間を推測しようとしています。

enter image description here

既知の並列容量の範囲(9〜6の3など)と、開始/終了が可変のジョブのリストを、利用可能な時間の時間範囲のリストに変換します。

7
Oli

時間分解能が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クラスに入れてください。これを操作するための非常に優れたツールセットが必要です。

0
Alain T.

ジョブを実行できるレーンを表す専用クラスを使用できます。これらのオブジェクトは、ジョブを追跡し、それに応じてそれらの可用性を追跡できます。

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オブジェクトで使用するためにコードを拡張することも簡単です。

0
a_guest