Skip to main content

u_schedule/models/
constraint.rs

1//! Scheduling constraints and transition matrices.
2//!
3//! Defines the constraints that a valid schedule must satisfy:
4//! precedence, capacity, time windows, no-overlap, and
5//! sequence-dependent setup times.
6//!
7//! # Reference
8//! Brucker (2007), "Scheduling Algorithms", Ch. 2
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// A scheduling constraint.
14///
15/// Constraints define the rules that a valid schedule must satisfy.
16/// The scheduler's job is to find an assignment that satisfies all
17/// hard constraints while optimizing objectives.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub enum Constraint {
20    /// Activity `after` cannot start until `before` finishes + `min_delay_ms`.
21    ///
22    /// # Reference
23    /// Pinedo (2016), "Scheduling", precedence constraints (Ch. 2.1)
24    Precedence {
25        before: String,
26        after: String,
27        min_delay_ms: i64,
28    },
29
30    /// At most `max_capacity` activities may use `resource_id` simultaneously.
31    Capacity {
32        resource_id: String,
33        max_capacity: i32,
34    },
35
36    /// Activity must be scheduled within [start_ms, end_ms).
37    TimeWindow {
38        activity_id: String,
39        start_ms: i64,
40        end_ms: i64,
41    },
42
43    /// Listed activities cannot overlap on the given resource.
44    /// This is a mutual exclusion constraint (disjunctive resource).
45    NoOverlap {
46        resource_id: String,
47        activity_ids: Vec<String>,
48    },
49
50    /// Sequence-dependent setup time between activity categories.
51    /// When transitioning from `from_category` to `to_category`,
52    /// `cost_ms` additional time is incurred.
53    TransitionCost {
54        from_category: String,
55        to_category: String,
56        cost_ms: i64,
57    },
58
59    /// Listed activities must start at the same time.
60    Synchronize { activity_ids: Vec<String> },
61}
62
63impl Constraint {
64    /// Creates a zero-delay precedence constraint.
65    pub fn precedence(before: impl Into<String>, after: impl Into<String>) -> Self {
66        Self::Precedence {
67            before: before.into(),
68            after: after.into(),
69            min_delay_ms: 0,
70        }
71    }
72
73    /// Creates a precedence constraint with a minimum delay.
74    pub fn precedence_with_delay(
75        before: impl Into<String>,
76        after: impl Into<String>,
77        delay_ms: i64,
78    ) -> Self {
79        Self::Precedence {
80            before: before.into(),
81            after: after.into(),
82            min_delay_ms: delay_ms,
83        }
84    }
85
86    /// Creates a capacity constraint.
87    pub fn capacity(resource_id: impl Into<String>, max: i32) -> Self {
88        Self::Capacity {
89            resource_id: resource_id.into(),
90            max_capacity: max,
91        }
92    }
93
94    /// Creates a time window constraint.
95    pub fn time_window(activity_id: impl Into<String>, start_ms: i64, end_ms: i64) -> Self {
96        Self::TimeWindow {
97            activity_id: activity_id.into(),
98            start_ms,
99            end_ms,
100        }
101    }
102
103    /// Creates a no-overlap (disjunctive) constraint.
104    pub fn no_overlap(resource_id: impl Into<String>, activity_ids: Vec<String>) -> Self {
105        Self::NoOverlap {
106            resource_id: resource_id.into(),
107            activity_ids,
108        }
109    }
110
111    /// Creates a synchronization constraint.
112    pub fn synchronize(activity_ids: Vec<String>) -> Self {
113        Self::Synchronize { activity_ids }
114    }
115}
116
117/// Sequence-dependent setup time matrix.
118///
119/// Maps (from_category, to_category) → setup time in ms.
120/// Used when the setup time on a resource depends on what was
121/// processed previously (e.g., machine changeover, color change).
122///
123/// # Reference
124/// Allahverdi et al. (2008), "A survey of scheduling problems with
125/// setup times or costs"
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct TransitionMatrix {
128    /// Matrix identifier.
129    pub name: String,
130    /// Resource this matrix applies to.
131    pub resource_id: String,
132    /// Transition times: (from_category, to_category) → milliseconds.
133    transitions: HashMap<(String, String), i64>,
134    /// Default setup time when no explicit transition is defined.
135    pub default_ms: i64,
136}
137
138impl TransitionMatrix {
139    /// Creates a new transition matrix for a resource.
140    pub fn new(name: impl Into<String>, resource_id: impl Into<String>) -> Self {
141        Self {
142            name: name.into(),
143            resource_id: resource_id.into(),
144            transitions: HashMap::new(),
145            default_ms: 0,
146        }
147    }
148
149    /// Sets the default transition time.
150    pub fn with_default(mut self, default_ms: i64) -> Self {
151        self.default_ms = default_ms;
152        self
153    }
154
155    /// Defines a transition time between two categories.
156    pub fn set_transition(&mut self, from: impl Into<String>, to: impl Into<String>, time_ms: i64) {
157        self.transitions.insert((from.into(), to.into()), time_ms);
158    }
159
160    /// Gets the transition time between two categories.
161    ///
162    /// Returns the explicit time if defined, otherwise the default.
163    /// Same-category transitions return 0 unless explicitly set.
164    pub fn get_transition(&self, from: &str, to: &str) -> i64 {
165        if from == to {
166            return *self
167                .transitions
168                .get(&(from.to_string(), to.to_string()))
169                .unwrap_or(&0);
170        }
171        *self
172            .transitions
173            .get(&(from.to_string(), to.to_string()))
174            .unwrap_or(&self.default_ms)
175    }
176
177    /// Number of explicitly defined transitions.
178    pub fn transition_count(&self) -> usize {
179        self.transitions.len()
180    }
181}
182
183/// A collection of transition matrices indexed by resource ID.
184///
185/// Provides unified lookup for sequence-dependent setup times
186/// across multiple resources.
187#[derive(Debug, Clone, Default, Serialize, Deserialize)]
188pub struct TransitionMatrixCollection {
189    matrices: HashMap<String, TransitionMatrix>,
190}
191
192impl TransitionMatrixCollection {
193    /// Creates an empty collection.
194    pub fn new() -> Self {
195        Self::default()
196    }
197
198    /// Adds a transition matrix for a resource.
199    pub fn add(&mut self, matrix: TransitionMatrix) {
200        self.matrices.insert(matrix.resource_id.clone(), matrix);
201    }
202
203    /// Builder: adds a matrix and returns self.
204    pub fn with_matrix(mut self, matrix: TransitionMatrix) -> Self {
205        self.add(matrix);
206        self
207    }
208
209    /// Gets the transition time for a resource between two categories.
210    ///
211    /// Returns 0 if no matrix exists for the resource.
212    pub fn get_transition_time(&self, resource_id: &str, from: &str, to: &str) -> i64 {
213        self.matrices
214            .get(resource_id)
215            .map(|m| m.get_transition(from, to))
216            .unwrap_or(0)
217    }
218
219    /// Number of matrices in the collection.
220    pub fn len(&self) -> usize {
221        self.matrices.len()
222    }
223
224    /// Whether the collection is empty.
225    pub fn is_empty(&self) -> bool {
226        self.matrices.is_empty()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_precedence_constraint() {
236        let c = Constraint::precedence("O1", "O2");
237        match c {
238            Constraint::Precedence {
239                before,
240                after,
241                min_delay_ms,
242            } => {
243                assert_eq!(before, "O1");
244                assert_eq!(after, "O2");
245                assert_eq!(min_delay_ms, 0);
246            }
247            _ => panic!("wrong variant"),
248        }
249    }
250
251    #[test]
252    fn test_precedence_with_delay() {
253        let c = Constraint::precedence_with_delay("O1", "O2", 5000);
254        match c {
255            Constraint::Precedence { min_delay_ms, .. } => {
256                assert_eq!(min_delay_ms, 5000);
257            }
258            _ => panic!("wrong variant"),
259        }
260    }
261
262    #[test]
263    fn test_capacity_constraint() {
264        let c = Constraint::capacity("M1", 2);
265        match c {
266            Constraint::Capacity {
267                resource_id,
268                max_capacity,
269            } => {
270                assert_eq!(resource_id, "M1");
271                assert_eq!(max_capacity, 2);
272            }
273            _ => panic!("wrong variant"),
274        }
275    }
276
277    #[test]
278    fn test_transition_matrix() {
279        let mut tm = TransitionMatrix::new("changeover", "M1").with_default(500);
280
281        tm.set_transition("TypeA", "TypeB", 1000);
282        tm.set_transition("TypeB", "TypeA", 800);
283        tm.set_transition("TypeA", "TypeA", 100); // Same-type changeover
284
285        assert_eq!(tm.get_transition("TypeA", "TypeB"), 1000);
286        assert_eq!(tm.get_transition("TypeB", "TypeA"), 800);
287        assert_eq!(tm.get_transition("TypeA", "TypeA"), 100); // Explicitly set
288        assert_eq!(tm.get_transition("TypeB", "TypeB"), 0); // Same-type default
289        assert_eq!(tm.get_transition("TypeC", "TypeD"), 500); // Falls to default
290        assert_eq!(tm.transition_count(), 3);
291    }
292
293    #[test]
294    fn test_transition_matrix_same_category_default() {
295        let tm = TransitionMatrix::new("tm", "M1").with_default(200);
296        // Same category → 0 (not default) unless explicitly set
297        assert_eq!(tm.get_transition("X", "X"), 0);
298        // Different category → default
299        assert_eq!(tm.get_transition("X", "Y"), 200);
300    }
301
302    #[test]
303    fn test_no_overlap_constraint() {
304        let c = Constraint::no_overlap("M1", vec!["O1".into(), "O2".into(), "O3".into()]);
305        match c {
306            Constraint::NoOverlap {
307                resource_id,
308                activity_ids,
309            } => {
310                assert_eq!(resource_id, "M1");
311                assert_eq!(activity_ids.len(), 3);
312            }
313            _ => panic!("wrong variant"),
314        }
315    }
316
317    #[test]
318    fn test_synchronize_constraint() {
319        let c = Constraint::synchronize(vec!["O1".into(), "O2".into()]);
320        match c {
321            Constraint::Synchronize { activity_ids } => {
322                assert_eq!(activity_ids.len(), 2);
323            }
324            _ => panic!("wrong variant"),
325        }
326    }
327}