Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions src/jres_standard_solver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ void JresStandardSolver::add_participant_model(
// Pre-parse stint times
std::vector<std::chrono::system_clock::time_point> startTimes;
startTimes.reserve(m_input.stints.size());
std::vector<std::chrono::system_clock::time_point> endTimes;
endTimes.reserve(m_input.stints.size());

for (const auto& stint : m_input.stints) {
startTimes.push_back(jres::internal::TimeHelpers::stringToTimePoint(stint.startTime));
endTimes.push_back(jres::internal::TimeHelpers::stringToTimePoint(stint.endTime));
}

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

std::string availabilityKey = jres::internal::TimeHelpers::timePointToKey(startTimes[s_idx]);
auto member_availability_it = m_input.availability.find(p.name);
if (member_availability_it != m_input.availability.end()) {
auto time_availability_it = member_availability_it->second.find(availabilityKey);
if (time_availability_it != member_availability_it->second.end()) {
if (time_availability_it->second == jres::internal::Availability::Unavailable) {
total_cost += kPenaltyUnavailable;
any_unavailable = true;
} else if (time_availability_it->second == jres::internal::Availability::Preferred) {
total_cost += kRewardPreferred;
auto s_time = startTimes[s_idx];
auto e_time = endTimes[s_idx];

// Start checking from the hour bucket where the stint starts
std::string startKey = jres::internal::TimeHelpers::timePointToKey(s_time);
auto t_cursor = jres::internal::TimeHelpers::stringToTimePoint(startKey);

while (t_cursor < e_time) {
std::string availabilityKey = jres::internal::TimeHelpers::timePointToKey(t_cursor);
auto member_availability_it = m_input.availability.find(p.name);
if (member_availability_it != m_input.availability.end()) {
auto time_availability_it = member_availability_it->second.find(availabilityKey);
if (time_availability_it != member_availability_it->second.end()) {
if (time_availability_it->second == jres::internal::Availability::Unavailable) {
total_cost += kPenaltyUnavailable;
any_unavailable = true;
} else if (time_availability_it->second == jres::internal::Availability::Preferred) {
total_cost += kRewardPreferred;
}
}
}
t_cursor += std::chrono::hours(1);
}
}

Expand Down
2 changes: 2 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ add_executable(solver_tests
test_max_busy.cpp
test_max_busy_mixed_roles.cpp
test_max_busy_defaults.cpp
test_availability_overlap.cpp
test_availability_boundaries.cpp
)

add_dependencies(solver_tests jres_solver_lib)
Expand Down
66 changes: 66 additions & 0 deletions test/test_availability_boundaries.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* @file test/test_availability_boundaries.cpp
* @brief Tests for precise availability boundary conditions.
*/

#include "gtest/gtest.h"
#include "jres_solver/jres_solver.hpp"
#include "nlohmann/json.hpp"
#include <vector>
#include <string>

using json = nlohmann::json;

TEST(AvailabilityBoundaryTest, StintEndsExactlyAtUnavailableStart) {
// Stint: 13:00 - 14:00. Unavailable at 14:00. Should be VALID.
json j;
j["success"] = true;
j["consecutiveStints"] = 1;
j["minimumRestHours"] = 0;
j["teamMembers"] = {{{"name", "D"}, {"isDriver", true}, {"isSpotter", false}}};
j["stints"] = {{{"id", 1}, {"startTime", "2026-01-17T13:00:00.000Z"}, {"endTime", "2026-01-17T14:00:00.000Z"}}};
j["availability"] = {{"D", {{"2026-01-17T13:00:00.000Z", "Available"}, {"2026-01-17T14:00:00.000Z", "Unavailable"}}}};
j["firstStintDriver"] = nullptr;

JresSolverInput* input = jres_input_from_json(j.dump().c_str());
JresSolverOptions options = {};
options.spotterMode = JRES_SPOTTER_MODE_NONE;
options.allowNoSpotter = true;

JresSolverOutput* output = solve_race_schedule(input, &options);
bool hasViolation = false;
for (int i = 0; i < output->diagnosis_len; ++i) {
if (std::string(output->diagnosis[i]).find("Violation: Unavailable Driver") != std::string::npos) hasViolation = true;
}

EXPECT_FALSE(hasViolation);
free_jres_solver_input(input);
free_jres_solver_output(output);
}

TEST(AvailabilityBoundaryTest, StintOverlapsUnavailableByOneMinute) {
// Stint: 13:00 - 14:01. Unavailable at 14:00. Should be INVALID.
json j;
j["success"] = true;
j["consecutiveStints"] = 1;
j["minimumRestHours"] = 0;
j["teamMembers"] = {{{"name", "D"}, {"isDriver", true}, {"isSpotter", false}}};
j["stints"] = {{{"id", 1}, {"startTime", "2026-01-17T13:00:00.000Z"}, {"endTime", "2026-01-17T14:01:00.000Z"}}};
j["availability"] = {{"D", {{"2026-01-17T13:00:00.000Z", "Available"}, {"2026-01-17T14:00:00.000Z", "Unavailable"}}}};
j["firstStintDriver"] = nullptr;

JresSolverInput* input = jres_input_from_json(j.dump().c_str());
JresSolverOptions options = {};
options.spotterMode = JRES_SPOTTER_MODE_NONE;
options.allowNoSpotter = true;

JresSolverOutput* output = solve_race_schedule(input, &options);
bool hasViolation = false;
for (int i = 0; i < output->diagnosis_len; ++i) {
if (std::string(output->diagnosis[i]).find("Violation: Unavailable Driver") != std::string::npos) hasViolation = true;
}

EXPECT_TRUE(hasViolation);
free_jres_solver_input(input);
free_jres_solver_output(output);
}
73 changes: 73 additions & 0 deletions test/test_availability_overlap.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @file test/test_availability_overlap.cpp
* @brief Tests for availability overlap detection.
*/

#include "gtest/gtest.h"
#include "jres_solver/jres_solver.hpp"
#include "nlohmann/json.hpp"
#include <vector>
#include <string>

using json = nlohmann::json;

TEST(AvailabilityOverlapTest, StintOverlapsUnavailableSlot) {
// Scenario:
// Stint starts at 13:55 and ends at 14:55 (1 hour duration).
//
// Driver "Overlapper":
// - 13:00: Preferred (So solver wants to pick them if looking at start time only)
// - 14:00: Unavailable
//
// Driver "SafeDriver":
// - 13:00: Available
// - 14:00: Available

json j;
j["success"] = true;
j["consecutiveStints"] = 1;
j["minimumRestHours"] = 0;

j["teamMembers"] = {
{{"name", "Overlapper"}, {"isDriver", true}, {"isSpotter", false}},
{{"name", "SafeDriver"}, {"isDriver", true}, {"isSpotter", false}}
};

j["stints"] = {
{
{"id", 1},
{"startTime", "2026-01-17T13:55:00.000Z"},
{"endTime", "2026-01-17T14:55:00.000Z"}
}
};

j["availability"] = {
{"Overlapper", {
{"2026-01-17T13:00:00.000Z", "Preferred"},
{"2026-01-17T14:00:00.000Z", "Unavailable"}
}},
{"SafeDriver", {
{"2026-01-17T13:00:00.000Z", "Available"},
{"2026-01-17T14:00:00.000Z", "Available"}
}}
};

j["firstStintDriver"] = nullptr;

JresSolverInput* input = jres_input_from_json(j.dump().c_str());
JresSolverOptions options = {};
options.timeLimit = 5;
options.spotterMode = JRES_SPOTTER_MODE_NONE;
options.allowNoSpotter = true;
options.optimalityGap = 0.0;

JresSolverOutput* output = solve_race_schedule(input, &options);
ASSERT_NE(output, nullptr);
ASSERT_EQ(output->schedule_len, 1);

std::string assignedDriver = output->schedule[0].driver;
EXPECT_EQ(assignedDriver, "SafeDriver") << "Solver assigned a driver who is unavailable during the latter part of the stint.";

free_jres_solver_input(input);
free_jres_solver_output(output);
}