An OptaPlanner model is Object-Orientated Programming (OOP) friendly
Both Gurobi and OptaPlanner require you to define your model, with optimization variables,
so the mathematical optimization software knows which decisions it needs to make.
Gurobi supports 3 types of optimization variables: booleans, integers and floating point numbers.
You must transform your domain model into those types.
For example:
// Input
Model model = ...
Variable[][] assignments = new Variable[shifts.size()][employees.size()];
for (int s = 0; s < shifts.size(); s++) {
for (int e = 0; e < employees.size(); e++) {
assignments[s][e] = model.addVar(BINARY);
}
}
... // Add constraints to enforce no shift is assigned to multiple employees
// Solve
model.solve();
// Output
for (int s = 0; s < shifts.size(); s++) {
for (int e = 0; e < employees.size(); e++) {
if (assignments[s][e].get() > 0.99) {
print(shifts[s] + " is assigned to " + employees[e]);
}
}
}
OptaPlanner supports any type of optimization variables,
including your custom classes (Employee, Vehicle, …) or standard classes (Boolean, Integer, BigDecimal, LocalDate, …).
You can reuse your existing domain model, to avoid costly data transformations.
For example:
@PlanningEntity
class Shift { // User defined class
... // Shift id, date, start time, required skills, ...
@PlanningVariable
Employee employee;
}
@PlanningSolution
class TimeTable { // User defined class
List<Employee> employees;
List<Shift> shifts;
}
// Input
Timetable timetable = new Timetable(shifts, employees);
// Solve
timetable = Solver.solve(timetable);
// Output
for (Shift shift : timetable.shifts) {
print(shift + " is assigned to " + shift.employee);
}
Neither of these 2 classes (Shift and Timetable) exist in OptaPlanner itself: you define and shape them.
Your code doesn’t deal with booleans and numbers, but uses Employee, Shift and DayOfRequest instances.
Your code reads naturally.
OptaPlanner even supports polymorphism.
OptaPlanner constraints are code, not equations
Gurobi constraints are implemented as mathematical equations.
For example, to assign at most one shift per day,
you add an equation s1 + s2 + s3 <= 1 for all shifts on day 1,
an equation s4 + s5 <= 1 for all shifts on day 2, and so forth:
for (int e = 0; e < employees.size(); e++) {
for (int d = 0; d < dates.size(); d++) {
Expression expr = ...
for (int s = 0; s < shifts.size(); s++) {
// If the shift is on the date
if (shifts[s].date == dates[d])) {
expr.addTerm(1.0, assignments[s][e]);
}
}
model.addConstraint(expr, LESS_EQUAL, 1.0);
}
}
OptaPlanner constraints are implemented as programming code.
If you use ConstraintStreams, a Function Programming (FP) approach,
OptaPlanner automatically applies incremental score calculation with deltas
for maximum scalability and performance.
For example, to assign at most one shift per day,
select every pair of Shift instances
that have the same date and the same employee
to penalize those pairs as a hard constraint:
// For every shift ...
constraintFactory.forEach(Shift.class)
// ... combined with any other shift ...
.join(Shift.class,
// ... on the same date ...
equal(shift -> shift.date),
// ... assigned to the same employee ...
equal(shift -> shift.employee))
// ... penalize one broken hard constraint per pair.
.penalize("One shift per day", HardSoftScore.ONE_HARD);
That equal() method accepts any code as a parameter to return any type (not just booleans and numbers).
For example, because date is an instance of LocalDate (an advanced Date and Time API),
use LocalDate.isDayOfWeek() to select 2 shifts on the same day of week:
// ... on the same day of week ...
equal(shift -> shift.date.getDayOfWeek())
Date and times arithmetic is notoriously difficult,
because of Daylight Saving Time (DST), timezones, leap years and other semantics that only a few programmers on this planet actually understand.
OptaPlanner empowers you to directly use their APIs (such as LocalDate) in your constraints.
Besides the equal() joiner, OptaPlanner supplies lessThan(), greaterThan(), lessThanOrEqual(), greaterThanOrEqual(),
overlapping(), etc. You can also plug in custom joiners.
OptaPlanner automatically applies indexing (hashtable techniques) on joiners for performance.
For example, select two overlapping shifts with the overlapping() joiner
(even if they start or end at different times):
// ... that overlap ...
overlapping(shift -> shift.startDateTime, shift -> shift.endDateTime)
Besides the join() construct, OptaPlanner supports filter(), groupBy(), ifExists(), ifNotExists(), map(), etc.
This rich API empowers you to implement any constraint.
For example, allow employees that can work double shifts to work double shifts
by filtering out all employees that work double shifts with a filter():
// For every shift ...
constraintFactory.forEach(Shift.class)
// ... assigned to an employee that does not work double shifts ...
.filter(shift -> !shift.employee.worksDoubleShifts)
// ... combined with any other shift ...
.join(Shift.class,
equal(shift -> shift.date),
// ... assigned to that same employee that does not work double shifts ...
equal(shift -> shift.employee))
.penalize("One shift per day", HardSoftScore.ONE_HARD);
The groupBy() construct supports count(), sum(), average(), min(), max(), toList(), toSet(), toMap(), etc.
You can also plug in custom collectors.
For example, don’t assign more that 10 shifts to any employee by counting their shifts with count():
constraintFactory.forEach(Shift.class)
// Group shifts by employee and count the number of shifts per employee ...
.groupBy(shift -> shift.employee, count())
// ... if more than 10 shifts for one employee ...
.filter((employee, shiftCount) -> shiftCount > 10)
// ... penalize as a hard constraint ...
.penalize("Too many shifts", HardSoftScore.ONE_HARD,
// ... multiplied by the number of excessive shifts.
(employee, shiftCount) -> shiftCount - 10);
OptaPlanner allow weighting constraints dynamically.
It has no linear limitations.
For example, avoid overtime and distribute it fairly by penalizing the number of excessive hours squared:
constraintFactory.forEach(Shift.class)
// Group shifts by employee and sum the shift duration per employee ...
.groupBy(shift -> shift.employee, sum(shift -> shift.getDurationInHours()))
// ... if an employee is working more hours than his/her contract ...
.filter((employee, hoursTotal) -> hoursTotal > employee.contract.maxHours)
// ... penalize as a soft constraint of weight 1000 ...
.penalize("Too many shifts", HardSoftScore.ofSoft(1000),
// ... multiplied by the number of excessive hours squared.
(employee, hoursTotal) -> {
int excessiveHours = hoursTotal - employee.contract.maxHours;
return excessiveHours * excessiveHours;
});
This penalizes outliers more.
It automatically load balances overtime in fair manner across the employees,
whenever possible. Learn more.
OptaPlanner also support positive constraints: use reward() instead of penalize().
Gurobi sometimes recommends the M is a very large number trick to implement challenging constraints.
OptaPlanner never needs that hack.
OptaPlanner has flexible scoring
Gurobi supports 2 score levels: hard constraints as constraints
and soft constraints as an objective function that returns a floating point number.
If one soft constraint takes total priority over another soft constraint,
for example service quality constraints over productivity constraints,
Gurobi multiplies the first soft constraint by a big weight and sums that with the second.
This can lead to overflow or underflow.
OptaPlanner supports any number of score levels:
-
2 levels (default): hard and soft constraints with HardSoftScore
-
3 levels: hard, medium and soft constraints with HardMediumSoftScore
-
n levels with BendableScore
This allows users to prioritize operational constraints (such as assign all shifts)
over financial constraints (such as reduce cost), without multiplication to with a big number.
The OptaPlanner constraint weights can use:
-
32-bit integer (int) arithmetic (default) with HardSoftScore, etc.
-
64-bit integer (long) arithmetic with HardSoftLongScore, etc.
-
Decimal number (BigDecimal) arithmetic with HardSoftBigDecimalScore, etc.
OptaPlanner actually no longer supports floating point (double) arithmetic
because of the numerical instability issues involved for incremental score calculation.