schejule

0.1.0-SNAPSHOT


a job shop scheduling library for Clojure

dependencies

org.clojure/clojure
1.8.0
org.clojure/core.logic
0.8.11



(this space intentionally left almost blank)
 
(ns schejule.analyser
  (:require [schejule.generator :as gen]))

Returns true if s contains a subset of coll's elements in the same order as they appear in coll. Returns false otherwise.

(defn subseq?
  [s coll]
  (loop [v1 s
         v2 coll]
    (cond
      (nil? v1)                 true
      (nil? v2)                 false
      (= (first v1) (first v2)) (recur (next v1) (next v2))
      :else                     (recur v1 (next v2)))))

Get the workspan of a solution. The workspan is the finish time of the last task to be completed.

(defn get-workspan
  [solution]
  (if (empty? solution)
    nil
    (->> solution :machines (map second) (apply max))))

Simulates schedule execution, checking whether :endby-constrained jobs will be done on time.

(defn simulate-schedule*
  [tasks constraints]
  (map (fn [tasks]
         (reduce (fn [solution task]
                   (let [{:keys [machine duration job id endby]} task
                         end-time (+ duration (max
                                               (or (get-in solution [:machines machine]) 0)
                                               (or (get-in solution [:jobs job]) 0)))]
                      ;; Check if `:endby` constraint has been violated.
                     (let [new-sol (-> solution
                                       (assoc-in [:machines machine] end-time)
                                       (assoc-in [:jobs job] end-time)
                                       (update :tasks #(conj % (assoc task :end-time end-time))))]
                       (if (and endby (< endby end-time))
                         (reduced (assoc new-sol :failure id))
                         new-sol))))
                 {}
                 tasks))
       (gen/gen-schedule* tasks constraints)))

'Rewinds' a bad schedule to identify which subsequence of tasks contributed to the failure

(defn distil-rule
  [bad-schedule]
  (loop [current       (first (bad-schedule :tasks))
         [head & tail] (next (bad-schedule :tasks))
         bottleneck    [(:id current)]
         start         (- (current :end-time) (current :duration))]
    (cond
      (nil? head)
      bottleneck
      (and (= start (head :end-time))
           (or (= (current :job) (head :job))
               (= (current :machine) (head :machine))))
      (recur head tail (cons (:id head) bottleneck) (- start (head :duration)))
      :else
      (recur current tail bottleneck start))))

Trims a sequence of tasks to focus on first and last tasks in each job

(defn disjunctive-pairs
  [job-lookup bad-schedule]
  (partition 2 (rest (mapcat (fn [tasks] [(first tasks) (last tasks)])
                             (partition-by job-lookup bad-schedule)))))

Yeah

(defn accrete-rules
  [job-lookup rules failure]
  (let [new-rule (disjunctive-pairs job-lookup (distil-rule failure))]
    (if (some #(subseq? (apply concat %) (apply concat new-rule)) rules)
      rules
      (conj (remove #(subseq? (apply concat new-rule) (apply concat %)) rules)
            new-rule))))
 
(ns schejule.generator
  (:refer-clojure :exclude [==])
  (:require [clojure.core.logic :refer [== all defne everyg lvar run* succeed] :as l]
            [clojure.core.logic.fd :as fd]))

Helper functions

Binds vars in a job to occur in the correct order. lengths must be a seq of integers defining no. of tasks in nth job.

(defn bind-intra-job-ordero
  [vars lengths]
  (if (seq lengths)
    (let [l (first lengths)]
      (all
       (everyg (fn [[x y]] (fd/< x y)) (partition 2 1 (take l vars)))
       (bind-intra-job-ordero (drop l vars) (next lengths))))
    succeed))

Like select-keys but returns a plain vector of items from coll.

(defn select-nths
  [from ks]
  (reduce (fn [result s] (conj result (nth from s)))
          []
          ks))
(defne not-ordered-pairso [l]
  ([[[a b] . _]] (fd/> a b))
  ([[[a b] . tail]] (fd/< a b) (not-ordered-pairso tail)))

Workhorse

Find a feasible ordering for tasks. Returns a lazy sequence.

TODO: experiment with passing run* an atom with a goal inside, realising part of the results, updating the atom, then realising the rest of the results.

(defn gen-sequence*
  [tasks constraints]
  (let [n       (count tasks)
        dom     (dec n)
        lengths (->> tasks (partition-by :job) (map count))
        vars    (repeatedly n lvar)
        rules   (map (fn [c]
                       (map (partial select-nths vars) c))
                     constraints)]
    (run* [q]
          (== q vars)
          (fd/distinct vars)
          (everyg #(fd/in % (fd/interval dom)) vars)
          (bind-intra-job-ordero vars lengths)
          (everyg #(not-ordered-pairso %) rules))))

Wraps gen-sequence*. Associates a sequence of ords with the matching tasks. Tasks MUST be sorted by :job, then by :id. Returns a lazy sequence.

(defn gen-schedule*
  [tasks constraints]
  (let [feasible* (gen-sequence* tasks constraints)]
    (map (fn [tasks ords]
           (sort-by :ord (map (fn [t ord] (assoc t :ord ord))
                              tasks
                              ords)))
         (repeat tasks)
         feasible*)))
 
(ns schejule
  (:require [schejule.analyser :as z]))

Helper functions

Sort by :job. Within a job, sort by :id

(defn sort-tasks
  [tasks]
  (sort (fn [a b] (if-not (= (a :job) (b :job))
                    (compare (a :job) (b :job))
                    (compare (a :id) (b :id))))
        tasks))

Makes sure tasks are sorted by :job, then by :id and have :id values from 0 to n-1.

(defn clean-tasks
  [tasks]
  (let [sorted (sort-tasks tasks)]
    (loop [clean         []
           [head & tail] sorted
           id            0]
      (if (empty? head)
        clean
        (recur (conj clean (assoc head :id id)) tail (inc id))))))
(defn by-job [tasks]
  (into {} (map (fn [task] [(:id task) (:job task)])
                tasks)))
(defn by-machine [tasks]
  (into {} (map (fn [task] [(:id task) (:machine task)])
                tasks)))
(defn solution-search
  [tasks sols failures]
  (let [job-lookup (by-job tasks)]
    (loop [anti-patterns []
           [s & others]  (z/simulate-schedule* tasks [])]
      (cond
        (Thread/interrupted) nil
        (:failure s)
        (let [anti-patterns' (z/accrete-rules job-lookup anti-patterns s)]
          (prn (str "Failure: " (s :failure)))
          (prn "Anti-Patterns")
          (prn anti-patterns')
          ;; (prn "Constraints")
          ;; (prn constraints)
          (send failures (fn [old] (update old (s :failure) #(inc (or % 0)))))
          (recur anti-patterns' (z/simulate-schedule* tasks anti-patterns')))
        :else
        (let [current (z/get-workspan s)]
          (prn (str "Success: " current))
          (send sols (fn [b] (if (or (nil? b)
                                     (< current (:workspan b)))
                               (assoc s :workspan current)
                               b)))
          (recur anti-patterns others))))))

Entrypoint

Searches for a solution and returns the best found in timeout milliseconds. "Best" means the minimum workspan, given the :endby constraints encoded into the tasks.

(defn adequate-schedule
  [tasks millis]
  (let [ts       (clean-tasks tasks)
        failures (agent nil)
        sols     (agent nil)
        f        (future (solution-search ts sols failures))]
    (doall (map prn ts))
    (Thread/sleep millis)
    (future-cancel f)
    (assoc @sols :failures @failures)))