From 179a068997164851a53f0949e86f460ae9d515da Mon Sep 17 00:00:00 2001 From: popmonkey Date: Tue, 13 Jan 2026 11:09:21 -0800 Subject: [PATCH] respect start/end boundaries for availability this fixes issue #47 --- src/jres_standard_solver.cpp | 33 ++++++++---- test/CMakeLists.txt | 2 + test/test_availability_boundaries.cpp | 66 ++++++++++++++++++++++++ test/test_availability_overlap.cpp | 73 +++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 test/test_availability_boundaries.cpp create mode 100644 test/test_availability_overlap.cpp diff --git a/src/jres_standard_solver.cpp b/src/jres_standard_solver.cpp index b3ab224..1f98c94 100644 --- a/src/jres_standard_solver.cpp +++ b/src/jres_standard_solver.cpp @@ -51,9 +51,12 @@ void JresStandardSolver::add_participant_model( // Pre-parse stint times std::vector startTimes; startTimes.reserve(m_input.stints.size()); + std::vector 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 @@ -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); } } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8bd67b3..abdd119 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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) diff --git a/test/test_availability_boundaries.cpp b/test/test_availability_boundaries.cpp new file mode 100644 index 0000000..9508d14 --- /dev/null +++ b/test/test_availability_boundaries.cpp @@ -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 +#include + +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); +} diff --git a/test/test_availability_overlap.cpp b/test/test_availability_overlap.cpp new file mode 100644 index 0000000..f4f2e9f --- /dev/null +++ b/test/test_availability_overlap.cpp @@ -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 +#include + +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); +}