Skip to content

Commit 83d5c57

Browse files
authored
Merge pull request #37 from popmonkey/incentives
incentives and optimizations * added incentives for consecutive stints * added tests for optimizing
2 parents 91860b4 + 7461d92 commit 83d5c57

3 files changed

Lines changed: 234 additions & 0 deletions

File tree

src/jres_standard_solver.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,30 @@ jres::internal::SolverOutput JresStandardSolver::solve()
185185
m_highs->changeColCost(under_avg_var, 1.0);
186186
}
187187

188+
// --- Incentivize Consecutive Stints ---
189+
for (const auto &p : m_driverPool) {
190+
if (p.maxStints <= 1) continue; // No incentive if they can't do consecutive stints
191+
192+
for (size_t s = 0; s < m_input.stints.size() - 1; ++s) {
193+
if (m_driverWorkVars.count({p.name, s}) && m_driverWorkVars.count({p.name, s + 1})) {
194+
int var_s = m_driverWorkVars.at({p.name, s});
195+
int var_next = m_driverWorkVars.at({p.name, s + 1});
196+
197+
int consecutive_var = m_highs->getNumCol();
198+
m_highs->addVar(0.0, 1.0);
199+
m_highs->changeColIntegrality(consecutive_var, HighsVarType::kInteger);
200+
m_highs->changeColCost(consecutive_var, -1.5); // Reward for consecutive stints
201+
202+
// z <= x_s
203+
m_highs->addRow(-kHighsInf, 0.0, 2, std::vector<int>{consecutive_var, var_s}.data(), std::vector<double>{1.0, -1.0}.data());
204+
// z <= x_{s+1}
205+
m_highs->addRow(-kHighsInf, 0.0, 2, std::vector<int>{consecutive_var, var_next}.data(), std::vector<double>{1.0, -1.0}.data());
206+
// z >= x_s + x_{s+1} - 1 => z - x_s - x_{s+1} >= -1
207+
m_highs->addRow(-1.0, kHighsInf, 3, std::vector<int>{consecutive_var, var_s, var_next}.data(), std::vector<double>{1.0, -1.0, -1.0}.data());
208+
}
209+
}
210+
}
211+
188212

189213
// --- Add Spotter Model (Integrated Mode) ---
190214
if (m_options.spotterMode == JRES_SPOTTER_MODE_INTEGRATED)

test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ add_executable(solver_tests
2929
test_errors.cpp
3030
test_diagnosis.cpp
3131
test_balancing.cpp
32+
test_optimization.cpp
3233
)
3334

3435
add_dependencies(solver_tests jres_solver_lib)

test/test_optimization.cpp

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
#include "gtest/gtest.h"
2+
#include "jres_solver/jres_solver.hpp"
3+
#include "nlohmann/json.hpp"
4+
#include <vector>
5+
#include <string>
6+
7+
using json = nlohmann::json;
8+
9+
TEST(OptimizationTest, IncentivizeConsecutiveStints) {
10+
// Scenario: 4 Drivers, 8 Stints.
11+
// Each driver can do max 2 stints.
12+
// Optimal with incentive (Max Consecutive): Driver A (1,2), Driver B (3,4), Driver C (5,6), Driver D (7,8)
13+
// This results in 4 consecutive pairs: (1-2), (3-4), (5-6), (7-8).
14+
// Suboptimal (Alternating): A, B, A, B... results in 0 consecutive pairs.
15+
16+
json j;
17+
j["success"] = true;
18+
19+
json drivers = json::array();
20+
std::vector<std::string> names = {"Driver A", "Driver B", "Driver C", "Driver D"};
21+
for (const auto& name : names) {
22+
drivers.push_back({
23+
{"name", name},
24+
{"isDriver", true},
25+
{"isSpotter", false},
26+
{"maxStints", 2},
27+
{"minimumRestHours", 0}
28+
});
29+
}
30+
j["teamMembers"] = drivers;
31+
32+
json stints = json::array();
33+
for (int i = 0; i < 8; ++i) {
34+
stints.push_back({
35+
{"id", i + 1},
36+
{"startTime", "2026-01-17T0" + std::to_string(i) + ":00:00.000Z"},
37+
{"endTime", "2026-01-17T0" + std::to_string(i+1) + ":00:00.000Z"}
38+
});
39+
}
40+
j["stints"] = stints;
41+
j["availability"] = json::object(); // All available
42+
j["firstStintDriver"] = nullptr;
43+
44+
std::string json_str = j.dump();
45+
46+
JresSolverOptions options;
47+
options.timeLimit = 10;
48+
options.spotterMode = JRES_SPOTTER_MODE_NONE; // Focus on driver optimization
49+
options.allowNoSpotter = true;
50+
options.optimalityGap = 0.0;
51+
52+
JresSolverInput* input = jres_input_from_json(json_str.c_str());
53+
ASSERT_NE(input, nullptr);
54+
55+
JresSolverOutput* output = solve_race_schedule(input, &options);
56+
ASSERT_NE(output, nullptr);
57+
ASSERT_EQ(output->schedule_len, 8);
58+
59+
int consecutive_count = 0;
60+
for (int i = 0; i < output->schedule_len - 1; ++i) {
61+
std::string current = output->schedule[i].driver;
62+
std::string next = output->schedule[i+1].driver;
63+
if (current == next) {
64+
consecutive_count++;
65+
}
66+
}
67+
68+
// We expect the solver to maximize consecutive stints.
69+
// In a perfect 2-stint blocks schedule: A A B B C C D D
70+
// Pairs: (A,A), (A,B), (B,B), (B,C), (C,C), (C,D), (D,D)
71+
// Consecutive: 4.
72+
// We accept at least 3 to allow for some minor variation, but definitely > 0.
73+
EXPECT_GE(consecutive_count, 3) << "Solver should prioritize consecutive stints. Found " << consecutive_count << " consecutive pairs.";
74+
75+
free_jres_solver_input(input);
76+
free_jres_solver_output(output);
77+
}
78+
79+
TEST(OptimizationTest, PreferredOverAvailable) {
80+
// Scenario: 2 Drivers, 2 Stints. maxStints=1 (Disable consecutive bonus).
81+
// Driver A: Stint 1 (Available), Stint 2 (Preferred)
82+
// Driver B: Stint 1 (Preferred), Stint 2 (Available)
83+
//
84+
// Naive/Round-Robin/Alphabetical Order might try: A then B.
85+
// - S1 (A, Avail) + S2 (B, Avail) -> Cost 0.
86+
//
87+
// Optimal Preference Order: B then A.
88+
// - S1 (B, Pref) + S2 (A, Pref) -> Cost -2.
89+
//
90+
// This forces the solver to pick B first, proving it's looking at the "Preferred" weight
91+
// and not just assigning in list order.
92+
93+
json j;
94+
j["success"] = true;
95+
j["teamMembers"] = {
96+
{{"name", "Driver A"}, {"isDriver", true}, {"isSpotter", false}, {"maxStints", 1}, {"minimumRestHours", 0}},
97+
{{"name", "Driver B"}, {"isDriver", true}, {"isSpotter", false}, {"maxStints", 1}, {"minimumRestHours", 0}}
98+
};
99+
j["stints"] = {
100+
{{"id", 1}, {"startTime", "2026-01-17T00:00:00.000Z"}, {"endTime", "2026-01-17T01:00:00.000Z"}},
101+
{{"id", 2}, {"startTime", "2026-01-17T01:00:00.000Z"}, {"endTime", "2026-01-17T02:00:00.000Z"}}
102+
};
103+
j["availability"] = {
104+
{"Driver A", {{"2026-01-17T00:00:00.000Z", "Available"}, {"2026-01-17T01:00:00.000Z", "Preferred"}}},
105+
{"Driver B", {{"2026-01-17T00:00:00.000Z", "Preferred"}, {"2026-01-17T01:00:00.000Z", "Available"}}}
106+
};
107+
j["firstStintDriver"] = nullptr;
108+
109+
JresSolverOptions options;
110+
options.timeLimit = 5;
111+
options.spotterMode = JRES_SPOTTER_MODE_NONE;
112+
options.allowNoSpotter = true;
113+
options.optimalityGap = 0.0;
114+
115+
JresSolverInput* input = jres_input_from_json(j.dump().c_str());
116+
ASSERT_NE(input, nullptr);
117+
118+
JresSolverOutput* output = solve_race_schedule(input, &options);
119+
ASSERT_NE(output, nullptr);
120+
ASSERT_EQ(output->schedule_len, 2);
121+
122+
// Expect B then A
123+
EXPECT_STREQ(output->schedule[0].driver, "Driver B") << "Stint 1 should be Driver B (Preferred)";
124+
EXPECT_STREQ(output->schedule[1].driver, "Driver A") << "Stint 2 should be Driver A (Preferred)";
125+
126+
free_jres_solver_input(input);
127+
free_jres_solver_output(output);
128+
}
129+
130+
TEST(OptimizationTest, ConsecutiveOverPreferred) {
131+
// Scenario: 2 Drivers, 4 Stints.
132+
// Availability Pattern (Alternating Preference):
133+
// Stint 1: A=Pref, B=Avail
134+
// Stint 2: A=Avail, B=Pref
135+
// Stint 3: A=Pref, B=Avail
136+
// Stint 4: A=Avail, B=Pref
137+
//
138+
// Option 1 (Alternating/Split): A, B, A, B
139+
// - Everyone gets their Preferred slots.
140+
// - Total Preferred = 4. Cost = -4.0.
141+
// - Consecutive Pairs = 0. Cost = 0.0.
142+
// - Balance: Perfect (2 each). Cost = 0.
143+
// - Total Cost = -4.0.
144+
//
145+
// Option 2 (Consecutive Blocks): A, A, B, B
146+
// - A takes S1(Pref), S2(Avail). B takes S3(Avail), S4(Pref).
147+
// - Total Preferred = 2. Cost = -2.0.
148+
// - Consecutive Pairs = 2 (A-A, B-B). Cost = 2 * -1.5 = -3.0.
149+
// - Balance: Perfect (2 each). Cost = 0.
150+
// - Total Cost = -5.0.
151+
//
152+
// Since -5.0 < -4.0, the solver MUST choose Option 2 (Consecutive Blocks).
153+
// This proves that the Consecutive Bonus (-1.5) outweighs the loss of a Preferred slot (1.0 difference).
154+
155+
json j;
156+
j["success"] = true;
157+
j["teamMembers"] = {
158+
{{"name", "Driver A"}, {"isDriver", true}, {"isSpotter", false}, {"maxStints", 2}, {"minimumRestHours", 0}},
159+
{{"name", "Driver B"}, {"isDriver", true}, {"isSpotter", false}, {"maxStints", 2}, {"minimumRestHours", 0}}
160+
};
161+
j["stints"] = {
162+
{{"id", 1}, {"startTime", "2026-01-17T00:00:00.000Z"}, {"endTime", "2026-01-17T01:00:00.000Z"}},
163+
{{"id", 2}, {"startTime", "2026-01-17T01:00:00.000Z"}, {"endTime", "2026-01-17T02:00:00.000Z"}},
164+
{{"id", 3}, {"startTime", "2026-01-17T02:00:00.000Z"}, {"endTime", "2026-01-17T03:00:00.000Z"}},
165+
{{"id", 4}, {"startTime", "2026-01-17T03:00:00.000Z"}, {"endTime", "2026-01-17T04:00:00.000Z"}}
166+
};
167+
j["availability"] = {
168+
{"Driver A", {
169+
{"2026-01-17T00:00:00.000Z", "Preferred"},
170+
{"2026-01-17T01:00:00.000Z", "Available"},
171+
{"2026-01-17T02:00:00.000Z", "Preferred"},
172+
{"2026-01-17T03:00:00.000Z", "Available"}
173+
}},
174+
{"Driver B", {
175+
{"2026-01-17T00:00:00.000Z", "Available"},
176+
{"2026-01-17T01:00:00.000Z", "Preferred"},
177+
{"2026-01-17T02:00:00.000Z", "Available"},
178+
{"2026-01-17T03:00:00.000Z", "Preferred"}
179+
}}
180+
};
181+
j["firstStintDriver"] = nullptr;
182+
183+
JresSolverOptions options;
184+
options.timeLimit = 5;
185+
options.spotterMode = JRES_SPOTTER_MODE_NONE;
186+
options.allowNoSpotter = true;
187+
options.optimalityGap = 0.0;
188+
189+
JresSolverInput* input = jres_input_from_json(j.dump().c_str());
190+
ASSERT_NE(input, nullptr);
191+
192+
JresSolverOutput* output = solve_race_schedule(input, &options);
193+
ASSERT_NE(output, nullptr);
194+
ASSERT_EQ(output->schedule_len, 4);
195+
196+
// Check for consecutive blocks
197+
std::string d1 = output->schedule[0].driver;
198+
std::string d2 = output->schedule[1].driver;
199+
std::string d3 = output->schedule[2].driver;
200+
std::string d4 = output->schedule[3].driver;
201+
202+
// We expect pairs like AA BB or BB AA
203+
EXPECT_EQ(d1, d2) << "Stints 1 and 2 should be consecutive";
204+
EXPECT_EQ(d3, d4) << "Stints 3 and 4 should be consecutive";
205+
EXPECT_NE(d2, d3) << "Drivers should switch between blocks";
206+
207+
free_jres_solver_input(input);
208+
free_jres_solver_output(output);
209+
}

0 commit comments

Comments
 (0)