時間規劃在Optaplanner上的實現

  在與諸位交流中,使用較多的生產計劃和路線規劃場景中,大家最爲關注的焦點是關於時間的處理問題。確實,時間這一維度具有一定的特殊性。因爲時間是一維的,體現爲通過圖形表示時,它僅可以通過一條有向直線來表達它的時刻和方向。相對而言,空間則可以存在多維,例如二維座標,三維空間等,甚至在生產計劃的規劃場景中,各種資源可以表示爲多個維度。因此,時間的一維特性,決定了在規劃過程中,需要處理它的方法也具有一定的特殊性和侷限性。本文將討論通過Optaplanner實現規劃過程中,對於時間方面的處理方式。
在衆多規劃優化場景中,可以歸納爲兩種情況的規劃,分別是單一維的空間維度規劃,和同時存在空間與時間兩個維度進行規劃。
其中第一種情況,僅對一個維度進行規劃的場景,我們可以把這一維歸納爲,僅對空間維度的規劃。例如八王后(N Qeen)問題,其規劃的目標是爲每個王后找個適當的位置,位置就是一個最爲直觀的空間概念,因此它是一個很明確直觀的空間規劃問題。而另外一些從直接字面意義上可能跟空間並沒有直接的關係,但其實也可以將它視作僅有一個空間維度的規劃。這類規劃的一個特點規劃目標與目標之間沒有時序關係,即時間維度是不考慮的,例如。有一些存在時間概念的問題,其實也可以轉化爲唯一空間維度的規劃,從而將問題簡化。例如排班過程中,將每個人員安排到指定的班次,雖然班次是一個時間上概念的概念,但實際對這個問題進行排班設計的時候,我們可以將時間轉化爲類似空間的形式處理。更直觀的說法,將班次分佈在時間軸上,按時間軸來看,各個班次就是時間軸上不同位置的區間,從而令問題簡化。因此,這類規則更嚴格地說,可以理解爲無論是空間還是時間上的規劃,都可以轉化、展開爲單一唯度的規劃問題,通過使用空間規劃的方法進行規劃建模求解;即使是時間規劃(例如排班)也不例外。
  另外一種規劃,則需要同時考慮空間與時間兩個維度協同規劃。如生產計劃、帶時間窗口的車輛路線規劃等問題,就是其中的典型。以生產計劃爲例,在空間維度,需要將一個任務分配到合理的機臺,即是空間上的規劃。然而,生產計劃問題的另一個需求是,確定了機臺後,還要確定到底這個任務應該在什麼時候開始,什麼時候結束;哪個任務需要在哪個任何完成後才能開始等等。這些時序邏輯相關的引出的問題,均屬於時間規劃問題。時間維度可以與空間維度一起,確定一個活動的時空座標。此座標是一個邏輯上抽象的概念。以生產計劃爲例,兩個維度均通過平面圖形來表示時,可以把計劃中的每個任務,分配在指定機臺的指定時間區間上,通過下圖可以看到,這個示意圖的水平軸(X軸)表示時間,從這個方向可以看出一個任務哪個時刻開始,持續多久,哪個時刻結束。以及與該任務同處於一個空間(機臺,或產線,或車間)上的前後任務的接續關係。垂直軸(Y軸)表示空間,表示它被分配到哪個機臺上執行。如下圖:
  針對不同的時間規劃要求,Optaplanner提供了3常用的規劃模式,分別是時間槽模式- Time Slot Pattern,時間粒模式 - Time Grain Pattern, 和時間鏈模式 - Chained Through Time Pattern.下面分別對這三種模式的特徵,適用場景和使用方法進行詳細介紹。因爲翻譯準確度原因(對自己的英文水平缺乏自信:P), 下文介紹中均直接使用Time Slot, Time Grain 和 Chained Through Time.以避免本文件的翻譯不當造成誤解。
 

時間槽模式 - Time slot

Time Slot在應用時有一些適用條件,滿足以下所有條件,才適用:
  1. 規劃實體中的規劃變量是一個時間區間;
  2. 一個規劃變量的取值最多僅可分配一個時間區間;
  3. 規劃變量對應的時間區間是等長的。
 
  對於規劃值範圍各個時間段,將其轉換爲空間上的概念更爲直觀。將時間用一個水平軸表示,在軸上劃分大小固定的區間,這些區間則可以作爲規劃過程中的取值範圍;在設計時,把這些區間定義成ValueRange。適用於Time slot模式情況,有制定中小學課程表、考試安排等問題。因爲大學或公開課程的計劃安排,除了排定時間外,可能還需要確定具體的地點,也就是空間維度的規劃。此類問題通常需要將時間和空間分開來考慮,但其中的時間緯可以通過Time slot模式轉化爲與空間規劃一樣的問題,從而令問題簡化。引用Optaplanner開發手冊的一張圖可以清楚地看到,每一個規劃實體只需要一個時間區間,且區間長短是相同的,(如下圖)。
 

    從圖中可以看出,每門課所需的時間都是固定一小時。具體到這個模式的應用,因爲其原理、結構和實現起來都相當簡單,本文不通過示例詳細講解了。可參考示例包中的Course timetabling中的設計和代碼。

 時間粒模式 - Time Grain

  在相當多運籌優化場景中,需要規劃的時間長短是不固定的,不同的任務其所需的時間有長短之分。這種需求下,若使用Time slot模式就無法實現時間上的精確規劃。那些就要使用更靈活,時間粒度更小的Time Grain模式。從Time Grain模式的名稱中的Grain可以推測到,此模式是將時間細分成一個一個顆粒並應用於規劃。例如可以設定爲每1分鐘,5分鐘,30分鐘,1小時等固定的長度,爲一個Grain的長度。
Time Grain模式適用條件:
  1. 規劃變量是時間區間;
  2. 業務上對應於規劃變量的時間區間可以不等長,但必須是Grain的倍數。
   例如通過Outlook的日曆功能創建會議時,默認情況下每個會議的時間,是0.5小時的倍數,也就是一個會議至少是0.5小時,或者是1小時,或1.5小時如此類推。當然如果你不使用Outlook的默認時間精度,也可以將時間精度定到分鐘,那麼也就表示,會議的時間是1分鐘的倍數。只不過針對人的日常活動在時間上的精度,以分鐘作爲精確度其意義不太大。就如9:01分開會跟9:00開會,對於人類的活動能力來說,正常情況下不存在任何區別。因爲你從辦公室去到會議室,都可能需要花費1分鐘了;所以outlook裏默認的是半小時。那麼這個最小的時候單位 - 半小時,在Time Grain模式中,就被稱爲一個Time Grain,以下簡稱Grain。可以先從開發手冊的圖中看到Time Grain模式所表達的意義,如下圖。
 

   從上圖可以看到,每個會議所需的時間長度是不相等的,但是其長度必然是一個Time Grain的倍數,從圖中上方的時間刻度可以比劃出一個TimeGrain應該是15分鐘。例如Sales meeting佔用了4個Time Grain,即時長1小時。Time Grain模式的使用會相對Time Slot更靈活,適用範圍會更廣。通過設置可知,其實適用於Time Slot模型的情形,是完全可以通過TimeGrain模式實現的,只是實現起來會更復雜一些。那麼Time Grain模式的設計要點在哪裏呢?要了解其設計原理,就得先掌握Time Grain的結構及其對時間的提供方法。

  Time Grain中的重點在於一個Grain的設計,與Time Slot中的slot一樣,Time Grain中的Grain表示的也是一個時間區間,只不過它所表達的意義不僅在於一個Time Grain的時間區間內,每個Grain的序號也是關鍵因素,當一個Grain被分配到一個規劃變量時,Grain的序號決定了它與時間軸的映射位置。在生產計劃中,若一個Grain被分配到一個任務時,表示任務起止於這個Grain的開始時刻。 即該任務的開始時間是哪個Grain內對應的時間區間內,那麼這個Grain的開始時間,就是這個任務的開始時間;通過這個任務的長度,推算出它需要佔用多少個Grain, 進而推算出它的結束時間會在哪個Grain內,那麼這個Grain的結束時間,即是這個任務的結束時間。
還是以上圖爲例,其中的Sales meeting,它的起始是在grain0內,grain0的起始時間是8:00,那麼這個會議的起始時間就是8:00。這個會議的長度是1小時,所以它佔用了4個Grain,因此,第4個Grain的結束時間就是會議的結束時間,也就是圖中Grain3的結束時間 - 9:00,是這個會議的結束時間。進一步分析也知,若這個會議時長是1:10, 那麼它的結束時間將會落於gran4內(第5個grain), 那麼它的結束時間就是grain4的結束時間 - 9:15. 因此,總結起來,我們在實現這個模式的時候有以下要點在設計時需要注意:
  1. 設計好每個Grain的粒度,也就是時間長度。並不是粒度越細越好,例如以1秒鐘作爲一個粒度,是不是就可以將任務的時間精度控制在1級呢?理論上是可以的,但日常使用中不太可行。因爲這樣的設計會產生過量的Grain,Grain就是Value Range,當可選值的數量過多時,整個規劃問題的規模就會增大,其時間複雜度就會指數級上升,從而令優化效果降低。
  2. 定義好每個Grain與絕對時間的映射關係。這個模式中的Time Grain其時間上是相對的。如何理解呢?就是說,這個模式在運行的時候,會把初始化出來的Grain對象列表,以Index(Grain的序號)爲序形成一個連接的時間粒的序列。列表中每一個具體的Grain對應的絕對時間是什麼時候呢?是以第一個Grain作爲參照推算出來的。例如上圖中的第一個Grain - grain0它的起始時間是8:00, 那麼第6個grain - grain5的起始時間就是9:30,這個時間是通過grain0加上6個grain的時長推算出來的,也就是8:00加上1.5小時,因此得到的是9:30。因此,當你設定Time Grain與絕對時間的對應關係時,就需要從業務上考慮,grain0的起始是什麼時刻;它決定了後續所有任務的時間。
  爲了防止同一空間上,存兩個任務時間重疊的問題,可以根據其分配的Grain進行判斷。如示例Meeting scheduling中關於時間重疊的判斷,可以參考MeetingAssignment類中的calculateOverlap方法,見以下代碼。
 
public int calculateOverlap(MeetingAssignment other) {
  if (startingTimeGrain == null || other.getStartingTimeGrain() == null) {
    return 0;
  }
  
int start = startingTimeGrain.getGrainIndex();   int end = start + meeting.getDurationInGrains();   int otherStart = other.startingTimeGrain.getGrainIndex();   int otherEnd = otherStart + other.meeting.getDurationInGrains();   if (end < otherStart) {     return 0;   } else if (otherEnd < start) {     return 0;   }   return Math.min(end, otherEnd) - Math.max(start, otherStart); }
 
  上述代碼是判斷兩個會議的TIme Grain, 若存在重疊,則返回重疊量,供引擎的評分機制來判斷各個solution的優劣。
 

時間鏈模式 - Chained Through Time

  前面提出的兩種時間模式,其實有較多的相似之處,都是將時間段劃分爲單個個體,再將這些個體作爲規劃變量的取值範圍,從而實現與空間規劃一致的規劃模式。但更復雜的場景下,將時間轉化爲“空間”的做法,未必能行得通。例如帶時間窗口的路徑規劃,多工序多資源生產計劃等問題,其時間維度是難以通過Time Slot或Time Grain模式實現的。我增嘗試將Time Grain模式應用於多工序多資源條件下的生產計劃規劃;其原理上是可行的,但仍然會到到一些相當難解決的問題。其中之一就是Time Grain的粒度大小問題,若需要實現精確到分鐘的計劃,當編排一個時間跨度較大的計劃時,就會引起問題規模過大的問題,從而論引擎效率驟降。另外就是實現相鄰任務的重疊和先後次序判斷時,會遇到一些難以解決的,問題需要花費較多的精力去處理。因此,Optaplanner引入了第三種時間規劃模式 - 時間鏈模式(同樣是翻譯問題,下稱Chained Through Time模式)。
Chained Through Time模式顧名思義就是應用了鏈狀結構的特性,來實現時間的規劃。它的設計思想是,規劃變量並不是普通的時間或空間上的值, 而是另外一個規劃實體;從而形成一個由各個首尾相接的規劃實體鏈,即Value Range的範圍就是規劃實集合本身。通過規劃實體間的鏈狀關係,來推算各個實體的起止時間。事實上,Optaplanner中將規劃實體環環相扣形成鏈的特性,其主要目的並非爲了實現時間規劃,而是爲了解類似TSP,VRP等問題而提供的。這些問題需要規劃的,是各個節點之間形成的連通關係;在約定規則下,求解最佳連通方案。根據不同的場景要求,所求的目標有“最短路徑”,“最小重複節點”,“最在連接效率”等。在時間規劃的功能方面,其實現方式與上兩種模式類似。以生產計劃的例子來說,通過Chained Through Time模式獲得各任務的連接關係與次序後,就可以根據鏈中首個任務的開始時間,結合各任務的持續時間,推算出各個任務精確的起止時間了,甚至可以精確到秒。所以此模式用於時間規劃,只是它的一個“副業”,引擎使用Chained Through Time模式時,並不是直接對時間進行規劃優化,而是在優化規劃實體之間的連接關係;時間作爲這個規劃實體中的一個影子變量(Shadow variable)進行計算,最終通過評分機制對這個影子變量進行約束限制,從而得到時間優化的方案。與Time Slot和Time Grain相比,Chained Through Time最大的特性是通過次序來推導時間,而另外兩種模式則是需要通過時間來反映任務之間的先後關係。
  雖然Chained Through Time模式的作用相當巨大且廣泛,但該模式的設計與實現難度又是三個模式中最高的,實現起來相對複雜。下面來進一步對其進行深入討論。
 

Chained Through Time模式的意義

  Chained Through Time模式通過對正在進行規劃的所有規劃實體建立鏈狀關係,來實現時間推導,其推導結果示意圖如下。從圖中可以看到,分配給Ann有兩個任務(FR taxes和SP taxes),其中第一個任務FR taxes的開始時刻是固定爲本次計劃的最早時間,而第二個任務SP taxes的開始時刻,則是根據第一個任務推導出來的 - 等於第一個任務的開始時刻加上其持續時間。因此,需要在約束的限制下,引擎過過各種約束分數的判斷,生成一個相對最合理的實體連接方案,再在這個方案的基礎上來推導時間,或將時間納入作爲約束條件,實現對連接方案的影響,從而實現了時間維度的規劃優化。
 

 

 Chained Through Time的內存模型

  規劃實體形成的鏈是由引擎自動生成的,每生成的一個方案都是由各規劃實體之間的相對位置變化而成的。在創建的這些規劃實體構成的鏈中,它會遵循以下原則:
  1. 一條鏈由一個Anchor(錨),和零或,或1個,或多個Entity(實體,其實就是規劃實體)構成;
  2. 一條鏈必須有且僅有一個Anchor(錨);
  3. 一條鏈中的Entity或Anchor之間是一對一的關係,不可出現合流或分流結構;
  4. 一條鏈中的Entity或Anchor不可出現循環。
如下圖
 

Chained Through Time模式的設計實現

  通過上面的鏈結構,我們瞭解到,一條鏈中將會存在兩種對象,一種是Anchor, 一種是Entity.對麼它們分別代表現實場景中的什麼業務實體呢?其實Entity是其常容易理解,如果是生產計劃案例中,它代表的是每個任務;在車輛路線規劃案例中,它代表的是每個車輛需要途徑的派件/攬件客戶。而Anchor則表未任務所在的機臺,及各個投/攬方案中的每一車輛。因此,這兩種不同的對象,在內容中會形成依賴關係,即一個Entity的前一步可以是另外一個Entiy, 也可以是一個Anchor。以生產計劃的業務場景來描述,則表示一個任務的前一個任務,可以是另外一個任務(Entity),也可以是一個機臺(Anchor,當這個任務是這個機臺的首個任務時)。因此,在我們設計它的時候需要把這兩種不同的業務實體抽象爲同一類纔有辦法實現它們之間的依賴,事實上這種抽象關係,在面向對象的原則,在業務意義上來說,是不成立的,僅僅是爲了滿足它們形成同一鏈的要求才作出的計劃。如下是一個任務與機臺的類設計圖。可以看到,我從Taskg與Machine抽象了一個父類Step(這是我想到的最合適類名了),那麼每一個任務的前一個Step有可能是另外一個任務,也有可能是一個機臺。
 

時間推算方法

  Chained Through Time模式與其兩種時間規劃模式不同,本質上它並不對時間進行規劃,只對實體之間的關係進行規劃優化。因此,在引擎每一個原子操作中需要通過對VariableListener接口的實現,來對時間進行推算,並在完成推算後,由引擎通過評分機制進行約束評分。一個Move有可能對應多個原子操作,一個Move的操作種類,可以參見開發 手冊中關於Move Selector一章,在以後對引擎行爲進行深入分析的文章中,我將會寫一篇關於Move Seletor的文件,來揭示引擎的運行原理。在需要進行時間推算時,可以通過實現接口的afterVariableChanged方法,對當前所處理的規劃實體的時間進行更新。因爲Chained Through Timea模式下,所有已初始化的規劃實體都處在一條鏈上;因此,當一個規劃實體的時間被更新後,跟隨着它的後一個規劃實體的時間也需要被更新,如此類推,直到鏈上最後一個實體,或出現一個時間正好不需要更新的規劃實體,即該規劃實體前面的所有實體的時間出現更新後,其時間不用變化,那麼鏈上從它往後的規劃實體的時候也無需更新。

  以下是VariableListener接口的afterVariableChanged及其處理方法。

// 實現VariableListener的類
public class StartTimeUpdatingVariableListener implements VariableListener<Task> {

    // 實現afterVariableChanged方法
    @Override
    public void afterVariableChanged(ScoreDirector scoreDirector, Task task) {
        updateStartTime(scoreDirector, task);
    }

    @Override
    public void beforeEntityAdded(ScoreDirector scoreDirector, Task task) {
        // Do nothing
    }

    @Override
    public void afterEntityAdded(ScoreDirector scoreDirector, Task task) {
        updateStartTime(scoreDirector, task);
    }
    .
    .
    .
}    

 

//當一個任務的時候被更新時,順着鏈將它後面所有任務的時候都更新
protected void updateStartTime(ScoreDirector scoreDirector, Task sourceTask) {
     Step previous = sourceTask.getPreviousStep();
     Task shadowTask = sourceTask;
     Integer previousEndTime = (previous == null ? null : previous.getEndTime());
     Integer startTime = calculateStartTime(shadowTask, previousEndTime);
     while (shadowTask != null && !Objects.equals(shadowTask.getStartTime(), startTime)) {
          scoreDirector.beforeVariableChanged(shadowTask, "startTime");
          shadowTask.setStartTime(startTime);
          scoreDirector.afterVariableChanged(shadowTask, "startTime");
          previousEndTime = shadowTask.getEndTime();
          shadowTask = shadowTask.getNextTask();
          startTime = calculateStartTime(shadowTask, previousEndTime); 
     }
}

 

規劃實體的設計

  上一步我們介紹瞭如何通過鏈在引擎的運行過程中進行時間推算,那麼如何設定才能讓引擎可以執行VariableListener中的方法呢,這就需要在規劃實體的設計過程中,反映出Chained Through Time的特性了。我們以上面的類圖爲例,理解下面其設計要求,在此示例中,把Task作爲規劃實體(Planning Entity), 那麼在Task類中需要定義一個Planning Variable(genuine planning variable), 它的類型是Step,它表示當前Task的上一個步驟(可能是另一個Task,也可能是一Machine). 此外,在 @PlanningVariable註解中,添加graphType = PlanningVariableGraphType.CHAINED說明。如下代碼:

// Planning variables: changes during planning, between score calculations.
    @PlanningVariable(valueRangeProviderRefs = {"machineRange", "taskRange"},
            graphType = PlanningVariableGraphType.CHAINED)
    private Step previousStep;

  以上代碼說明,規劃實體(Task)的genuine planning variable名爲previousStep, 它的Value Range有兩個來源,分別是機臺列表(machineRange)和任務列表(taskRange),並且添加了屬性grapType=planningVariableGraphType.CHAINED, 表明將應用Chained Through Time模式運行。

  有了genuine planning variable, 還需要Shadow variable, 所謂的Shadow variable,在Chained Through Time模式下有兩種作用,分別是:

  1. 用於建立兩個對象(Entity或Anchor)之間的又向依賴關係;即示例中的Machine與Task, 相信的兩個Task。

  2. 用於指定當genuine planning variable的值在規劃運算過程產生變化時,需要更改哪個變量;即上面提到的開始時間。

,對於第一個作用,其代碼體現如下,在規劃實體(Task)中,以@AnchorShadowVariable註解,並在該註解的sourceVariableName中指定該Shadow Variable在鏈上的前一個對象指向的是哪個變量。

    // Shadow variables
    // Task nextTask inherited from superclass
    @AnchorShadowVariable(sourceVariableName = "previousStep")
    private Machine machine;

  上述代碼說明成員machine是一個Anchor Shadow Variable, 在鏈上,它連接的前一個實體是實體類的一個成員 - previousStep.

  Chained Through Time中的鏈需要形成雙向關係(bi-directional),下圖是路線規劃示例中。一個客戶與上一個停靠點之間的又向關係。

   在規劃實體(Task)中我們已經定義了前一個Step,並以@AnchorShadowVariable註解標識。而雙向關係中的另一方,則需要在相鄰節點中的前一個節點定義。通過鏈的內存模型,我們可以知道,在生產計劃示例中,一個實體的前一個節點的類型可能是另一個Task, 也要能是一個Machine, 因此,前一個節點指向後一個節點的規劃變量,只能在Task與Machine的共同父類中定義,也就是需要在Step中實現。因此,在Step類中需要定義另一個Shadow Variable, 因爲相對於Task中的Anchor Shadow variable, 它是反現的,因此,它需要通過@InverseRelationShadowVariable註解,說明它在鏈上起到反向連接作用,即它是指向後一個節點的。代碼如下:

@PlanningEntity
public abstract class Step{

    // Shadow variables
    @InverseRelationShadowVariable(sourceVariableName = "previousStep")
    protected Task nextTask;
    .
    .
    .
}

  可以從代碼中看到,Step類也是一個規劃實體.其中的一個成員nextTask, 它的類型是Task,它表示在鏈中指向後面的Entity. 大家可以想一下,爲什麼它可以是一個Task, 而無需是一個Step。

  通過上述設計,已經實現了Chained Through Time的基本模式,可能大家還會問,上面我們實現了VariableListener, 引擎是如何觸發它的呢。這就需要用到另外一種Shadow Variable了,這種Shadow Varible是用於實現在運算過程中執行額外處理的,因此稱爲Custom Shadow Variable.

// 自定義Shadow Variable, 它表示當 genuine被引擎改變時,需要處理哪個變量。 
@CustomShadowVariable(variableListenerClass = StartTimeUpdatingVariableListener.class,
            sources = {@PlanningVariableReference(variableName = "previousStep")})
    private Integer startTime; // 因爲時間在規劃過程中以相對值進行運算,因此以整數表示。

  上面的代碼通過@CustomShadowVariable註解,說明了Task的成員startTime是一個自定義的Shadow Variable. 同時在註解中添加了variableListenerClass屬性,其值指定爲剛纔我們定義的,實現了VariableListener接口的類 - StartTimeUpdatingVariableListener,同時,能冠軍sources屬性指定,當前Custom Shadow Variable是跟隨着genuine variable - previousStep的變化而變化的。

  至此,關於Chained Through Time中的關鍵要點已全部設計實現,具體的使用可以參照示例包中有用到此模式的代碼。

 

總結

  關於時間的規劃,在實際的系統開發時,並不只本文描述的那麼簡單,關於最爲複雜的Chained Through Time模式,大家可以通過本文了解其概念、結構和要點,再結合示例包中的代碼進來理解,才能掌握其要領。且現實項目中也有許許多多的個性規則和要求,需要通過大家的技巧來實現;但萬變不離其宗,所有處理特殊情況的技巧,都需要甚至Optaplanner這些既有特性。因此,大家可以先通過示例包中的代碼將這些特性掌握,再進行更復雜情況下的設計開如。未來若時間允許,我將分享我在項目中遇到的一些特殊,甚至是苛刻的規則要求,及其處理辦法。

 

如需瞭解更多關於Optaplanner的應用,請發電郵致:[email protected]
或到討論組發表你的意見:https://groups.google.com/forum/#!forum/optaplanner-cn
若有需要可添加本人微信(13631823503)或QQ(12977379)實時溝通,但因本人日常工作繁忙,通過微信,QQ等工具可能無法深入溝通,較複雜的問題,建議以郵件或討論組方式提出。(討論組屬於google郵件列表,國內網絡可能較難訪問,需自行解決)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章