Skip to content

Commit ee449c5

Browse files
authored
Merge pull request #48 from popmonkey/fix-47
respect start/end boundaries for availability
2 parents 4fbec83 + 179a068 commit ee449c5

4 files changed

Lines changed: 164 additions & 10 deletions

File tree

src/jres_standard_solver.cpp

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,12 @@ void JresStandardSolver::add_participant_model(
5151
// Pre-parse stint times
5252
std::vector<std::chrono::system_clock::time_point> startTimes;
5353
startTimes.reserve(m_input.stints.size());
54+
std::vector<std::chrono::system_clock::time_point> endTimes;
55+
endTimes.reserve(m_input.stints.size());
5456

5557
for (const auto& stint : m_input.stints) {
5658
startTimes.push_back(jres::internal::TimeHelpers::stringToTimePoint(stint.startTime));
59+
endTimes.push_back(jres::internal::TimeHelpers::stringToTimePoint(stint.endTime));
5760
}
5861

5962
// Determine Block Structure
@@ -94,18 +97,28 @@ void JresStandardSolver::add_participant_model(
9497
for (int s_idx : block) {
9598
workVars[{p.name, s_idx}] = workVarIdx;
9699

97-
std::string availabilityKey = jres::internal::TimeHelpers::timePointToKey(startTimes[s_idx]);
98-
auto member_availability_it = m_input.availability.find(p.name);
99-
if (member_availability_it != m_input.availability.end()) {
100-
auto time_availability_it = member_availability_it->second.find(availabilityKey);
101-
if (time_availability_it != member_availability_it->second.end()) {
102-
if (time_availability_it->second == jres::internal::Availability::Unavailable) {
103-
total_cost += kPenaltyUnavailable;
104-
any_unavailable = true;
105-
} else if (time_availability_it->second == jres::internal::Availability::Preferred) {
106-
total_cost += kRewardPreferred;
100+
auto s_time = startTimes[s_idx];
101+
auto e_time = endTimes[s_idx];
102+
103+
// Start checking from the hour bucket where the stint starts
104+
std::string startKey = jres::internal::TimeHelpers::timePointToKey(s_time);
105+
auto t_cursor = jres::internal::TimeHelpers::stringToTimePoint(startKey);
106+
107+
while (t_cursor < e_time) {
108+
std::string availabilityKey = jres::internal::TimeHelpers::timePointToKey(t_cursor);
109+
auto member_availability_it = m_input.availability.find(p.name);
110+
if (member_availability_it != m_input.availability.end()) {
111+
auto time_availability_it = member_availability_it->second.find(availabilityKey);
112+
if (time_availability_it != member_availability_it->second.end()) {
113+
if (time_availability_it->second == jres::internal::Availability::Unavailable) {
114+
total_cost += kPenaltyUnavailable;
115+
any_unavailable = true;
116+
} else if (time_availability_it->second == jres::internal::Availability::Preferred) {
117+
total_cost += kRewardPreferred;
118+
}
107119
}
108120
}
121+
t_cursor += std::chrono::hours(1);
109122
}
110123
}
111124

test/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ add_executable(solver_tests
3939
test_max_busy.cpp
4040
test_max_busy_mixed_roles.cpp
4141
test_max_busy_defaults.cpp
42+
test_availability_overlap.cpp
43+
test_availability_boundaries.cpp
4244
)
4345

4446
add_dependencies(solver_tests jres_solver_lib)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @file test/test_availability_boundaries.cpp
3+
* @brief Tests for precise availability boundary conditions.
4+
*/
5+
6+
#include "gtest/gtest.h"
7+
#include "jres_solver/jres_solver.hpp"
8+
#include "nlohmann/json.hpp"
9+
#include <vector>
10+
#include <string>
11+
12+
using json = nlohmann::json;
13+
14+
TEST(AvailabilityBoundaryTest, StintEndsExactlyAtUnavailableStart) {
15+
// Stint: 13:00 - 14:00. Unavailable at 14:00. Should be VALID.
16+
json j;
17+
j["success"] = true;
18+
j["consecutiveStints"] = 1;
19+
j["minimumRestHours"] = 0;
20+
j["teamMembers"] = {{{"name", "D"}, {"isDriver", true}, {"isSpotter", false}}};
21+
j["stints"] = {{{"id", 1}, {"startTime", "2026-01-17T13:00:00.000Z"}, {"endTime", "2026-01-17T14:00:00.000Z"}}};
22+
j["availability"] = {{"D", {{"2026-01-17T13:00:00.000Z", "Available"}, {"2026-01-17T14:00:00.000Z", "Unavailable"}}}};
23+
j["firstStintDriver"] = nullptr;
24+
25+
JresSolverInput* input = jres_input_from_json(j.dump().c_str());
26+
JresSolverOptions options = {};
27+
options.spotterMode = JRES_SPOTTER_MODE_NONE;
28+
options.allowNoSpotter = true;
29+
30+
JresSolverOutput* output = solve_race_schedule(input, &options);
31+
bool hasViolation = false;
32+
for (int i = 0; i < output->diagnosis_len; ++i) {
33+
if (std::string(output->diagnosis[i]).find("Violation: Unavailable Driver") != std::string::npos) hasViolation = true;
34+
}
35+
36+
EXPECT_FALSE(hasViolation);
37+
free_jres_solver_input(input);
38+
free_jres_solver_output(output);
39+
}
40+
41+
TEST(AvailabilityBoundaryTest, StintOverlapsUnavailableByOneMinute) {
42+
// Stint: 13:00 - 14:01. Unavailable at 14:00. Should be INVALID.
43+
json j;
44+
j["success"] = true;
45+
j["consecutiveStints"] = 1;
46+
j["minimumRestHours"] = 0;
47+
j["teamMembers"] = {{{"name", "D"}, {"isDriver", true}, {"isSpotter", false}}};
48+
j["stints"] = {{{"id", 1}, {"startTime", "2026-01-17T13:00:00.000Z"}, {"endTime", "2026-01-17T14:01:00.000Z"}}};
49+
j["availability"] = {{"D", {{"2026-01-17T13:00:00.000Z", "Available"}, {"2026-01-17T14:00:00.000Z", "Unavailable"}}}};
50+
j["firstStintDriver"] = nullptr;
51+
52+
JresSolverInput* input = jres_input_from_json(j.dump().c_str());
53+
JresSolverOptions options = {};
54+
options.spotterMode = JRES_SPOTTER_MODE_NONE;
55+
options.allowNoSpotter = true;
56+
57+
JresSolverOutput* output = solve_race_schedule(input, &options);
58+
bool hasViolation = false;
59+
for (int i = 0; i < output->diagnosis_len; ++i) {
60+
if (std::string(output->diagnosis[i]).find("Violation: Unavailable Driver") != std::string::npos) hasViolation = true;
61+
}
62+
63+
EXPECT_TRUE(hasViolation);
64+
free_jres_solver_input(input);
65+
free_jres_solver_output(output);
66+
}

test/test_availability_overlap.cpp

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @file test/test_availability_overlap.cpp
3+
* @brief Tests for availability overlap detection.
4+
*/
5+
6+
#include "gtest/gtest.h"
7+
#include "jres_solver/jres_solver.hpp"
8+
#include "nlohmann/json.hpp"
9+
#include <vector>
10+
#include <string>
11+
12+
using json = nlohmann::json;
13+
14+
TEST(AvailabilityOverlapTest, StintOverlapsUnavailableSlot) {
15+
// Scenario:
16+
// Stint starts at 13:55 and ends at 14:55 (1 hour duration).
17+
//
18+
// Driver "Overlapper":
19+
// - 13:00: Preferred (So solver wants to pick them if looking at start time only)
20+
// - 14:00: Unavailable
21+
//
22+
// Driver "SafeDriver":
23+
// - 13:00: Available
24+
// - 14:00: Available
25+
26+
json j;
27+
j["success"] = true;
28+
j["consecutiveStints"] = 1;
29+
j["minimumRestHours"] = 0;
30+
31+
j["teamMembers"] = {
32+
{{"name", "Overlapper"}, {"isDriver", true}, {"isSpotter", false}},
33+
{{"name", "SafeDriver"}, {"isDriver", true}, {"isSpotter", false}}
34+
};
35+
36+
j["stints"] = {
37+
{
38+
{"id", 1},
39+
{"startTime", "2026-01-17T13:55:00.000Z"},
40+
{"endTime", "2026-01-17T14:55:00.000Z"}
41+
}
42+
};
43+
44+
j["availability"] = {
45+
{"Overlapper", {
46+
{"2026-01-17T13:00:00.000Z", "Preferred"},
47+
{"2026-01-17T14:00:00.000Z", "Unavailable"}
48+
}},
49+
{"SafeDriver", {
50+
{"2026-01-17T13:00:00.000Z", "Available"},
51+
{"2026-01-17T14:00:00.000Z", "Available"}
52+
}}
53+
};
54+
55+
j["firstStintDriver"] = nullptr;
56+
57+
JresSolverInput* input = jres_input_from_json(j.dump().c_str());
58+
JresSolverOptions options = {};
59+
options.timeLimit = 5;
60+
options.spotterMode = JRES_SPOTTER_MODE_NONE;
61+
options.allowNoSpotter = true;
62+
options.optimalityGap = 0.0;
63+
64+
JresSolverOutput* output = solve_race_schedule(input, &options);
65+
ASSERT_NE(output, nullptr);
66+
ASSERT_EQ(output->schedule_len, 1);
67+
68+
std::string assignedDriver = output->schedule[0].driver;
69+
EXPECT_EQ(assignedDriver, "SafeDriver") << "Solver assigned a driver who is unavailable during the latter part of the stint.";
70+
71+
free_jres_solver_input(input);
72+
free_jres_solver_output(output);
73+
}

0 commit comments

Comments
 (0)