diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index 3a7921c..18af30d 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -136,6 +136,7 @@ jobs: - name: Run Integration Tests run: | chmod +x test/integration/run_test.sh + chmod +x test/integration/test_formatter_stdout.sh ./test/integration/run_test.sh # --------------------------------------------------------- diff --git a/CMakeLists.txt b/CMakeLists.txt index c0ad08d..3b46b0a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -159,7 +159,6 @@ add_library(jres_solver_lib src/jres_solver.cpp src/jres_json_converter.cpp src/jres_internal_types.cpp - src/jres_solver_base.cpp src/jres_standard_solver.cpp src/analysis/capacity_analyzer.cpp src/analysis/solver_diagnostics.cpp diff --git a/GEMINI.md b/GEMINI.md index 8bbfa29..f3745d2 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -81,7 +81,7 @@ ctest There is also a script to test the formatter's stdout functionality: ```bash -./test_formatter_stdout.sh +./test/integration/test_formatter_stdout.sh ``` ### Running the CLI Tools diff --git a/cmd/solver/cli.cpp b/cmd/solver/cli.cpp index 8ddae16..907f3eb 100644 --- a/cmd/solver/cli.cpp +++ b/cmd/solver/cli.cpp @@ -212,4 +212,4 @@ int main(int argc, char **argv) } return returnCode; -} \ No newline at end of file +} diff --git a/include/jres_solver/jres_solver.hpp b/include/jres_solver/jres_solver.hpp index 467a70b..a4fe428 100644 --- a/include/jres_solver/jres_solver.hpp +++ b/include/jres_solver/jres_solver.hpp @@ -237,4 +237,4 @@ JRES_SOLVER_API void free_json_string(char* json_string); } // extern "C" #endif -#endif // JRES_SOLVER_HPP \ No newline at end of file +#endif // JRES_SOLVER_HPP diff --git a/src/analysis/capacity_analyzer.cpp b/src/analysis/capacity_analyzer.cpp index 0e5584e..13133f0 100644 --- a/src/analysis/capacity_analyzer.cpp +++ b/src/analysis/capacity_analyzer.cpp @@ -14,29 +14,26 @@ CapacityAnalysis CapacityAnalyzer::calculate_max_potential_capacity( const std::vector& participants, const SolverInput& input) { - // Parse stint times once - std::vector startTimes; - std::vector endTimes; + std::vector startTimes; + std::vector endTimes; startTimes.reserve(input.stints.size()); endTimes.reserve(input.stints.size()); - std::chrono::system_clock::time_point raceStart; - std::chrono::system_clock::time_point raceEnd; + std::time_t raceStart; + std::time_t raceEnd; bool raceTimesInit = false; for (const auto& stint : input.stints) { - auto s = TimeHelpers::stringToTimePoint(stint.startTime); - auto e = TimeHelpers::stringToTimePoint(stint.endTime); - startTimes.push_back(s); - endTimes.push_back(e); + startTimes.push_back(stint.startTime); + endTimes.push_back(stint.endTime); if(!raceTimesInit) { - raceStart = s; - raceEnd = e; + raceStart = stint.startTime; + raceEnd = stint.endTime; raceTimesInit = true; } else { - if(s < raceStart) raceStart = s; - if(e > raceEnd) raceEnd = e; + if(stint.startTime < raceStart) raceStart = stint.startTime; + if(stint.endTime > raceEnd) raceEnd = stint.endTime; } } @@ -47,10 +44,11 @@ CapacityAnalysis CapacityAnalyzer::calculate_max_potential_capacity( for (const auto& p : participants) { // Build Availability std::vector is_available(input.stints.size(), true); - auto member_availability_it = input.availability.find(p.name); + auto member_availability_it = input.availability.find(p.nameId); if (member_availability_it != input.availability.end()) { for (size_t s = 0; s < input.stints.size(); ++s) { - std::string key = TimeHelpers::timePointToKey(startTimes[s]); + // Check if this stint time is unavailable + std::time_t key = TimeHelpers::roundToHour(startTimes[s]); auto time_it = member_availability_it->second.find(key); if (time_it != member_availability_it->second.end() && time_it->second == Availability::Unavailable) { @@ -68,24 +66,24 @@ CapacityAnalysis CapacityAnalyzer::calculate_max_potential_capacity( planned_drive[s] = true; base_capacity++; - auto duration_ms = std::chrono::duration_cast(endTimes[s] - startTimes[s]).count(); - driver_total_hours += static_cast(duration_ms) / 3600000.0; + double h = std::difftime(endTimes[s], startTimes[s]) / 3600.0; + driver_total_hours += h; } } // Adjust for Global Minimum Rest (One Instance) int final_capacity = base_capacity; if (input.minimumRestHours > 0) { - auto minRestDuration = std::chrono::hours(input.minimumRestHours); + long long minRestSeconds = (long long)input.minimumRestHours * 3600; int min_loss = base_capacity; bool found_valid_window = false; - std::vector candidateStarts; + std::vector candidateStarts; candidateStarts.push_back(raceStart); for(const auto& t : endTimes) candidateStarts.push_back(t); for(const auto& tStart : candidateStarts) { - auto tEnd = tStart + minRestDuration; + auto tEnd = tStart + minRestSeconds; if (tEnd > raceEnd) continue; found_valid_window = true; @@ -109,7 +107,8 @@ CapacityAnalysis CapacityAnalyzer::calculate_max_potential_capacity( analysis.totalCapacity += final_capacity; - ss << "\n- " << p.name << ": " << final_capacity + std::string name = input.strings.get_string(p.nameId); + ss << "\n- " << name << ": " << final_capacity << " stints (approx " << std::fixed << std::setprecision(1) << driver_total_hours << "h, MinRest=" << input.minimumRestHours << "h)"; } diff --git a/src/analysis/solver_diagnostics.cpp b/src/analysis/solver_diagnostics.cpp index e79184c..9837ff3 100644 --- a/src/analysis/solver_diagnostics.cpp +++ b/src/analysis/solver_diagnostics.cpp @@ -18,27 +18,27 @@ namespace jres::analysis { std::string explain_assignment_failure( int stintIndex, - const std::string& violationDriver, + jres::internal::ID violationDriverId, const jres::internal::SolverInput& input, const std::vector& driverPool, - const std::map, int>& driverWorkVars, + const std::map, int>& driverWorkVars, const std::vector& colValues) { using namespace jres::internal; std::ostringstream ss; // Time points for the target stint - auto tStart = TimeHelpers::stringToTimePoint(input.stints[stintIndex].startTime); - auto tEnd = TimeHelpers::stringToTimePoint(input.stints[stintIndex].endTime); + auto tStart = input.stints[stintIndex].startTime; + auto tEnd = input.stints[stintIndex].endTime; // Identify which drivers are assigned to which stints in the CURRENT solution - std::map> driverAssignments; + std::map> driverAssignments; for (size_t s = 0; s < input.stints.size(); ++s) { for (const auto& p : driverPool) { - if (driverWorkVars.count({p.name, (int)s})) { - int idx = driverWorkVars.at({p.name, (int)s}); + if (driverWorkVars.count({p.nameId, (int)s})) { + int idx = driverWorkVars.at({p.nameId, (int)s}); if (idx < (int)colValues.size() && colValues[idx] > 0.5) { - driverAssignments[p.name].insert((int)s); + driverAssignments[p.nameId].insert((int)s); } } } @@ -46,31 +46,33 @@ std::string explain_assignment_failure( // Helper: Get stint duration in hours auto getDuration = [&](int sIdx) { - auto s = TimeHelpers::stringToTimePoint(input.stints[sIdx].startTime); - auto e = TimeHelpers::stringToTimePoint(input.stints[sIdx].endTime); - return std::chrono::duration>(e - s).count(); + return std::difftime(input.stints[sIdx].endTime, input.stints[sIdx].startTime) / 3600.0; }; ss << " Alternatives Analysis:"; for (const auto& candidate : driverPool) { - if (candidate.name == violationDriver) continue; + if (candidate.nameId == violationDriverId) continue; - ss << "\n - " << candidate.name << ": "; + std::string candidateName = input.strings.get_string(candidate.nameId); + + ss << "\n - " << candidateName << ": "; std::vector reasons; // Check Availability bool isUnavailable = false; - auto it = input.availability.find(candidate.name); + auto it = input.availability.find(candidate.nameId); if (it != input.availability.end()) { auto cursor = tStart; + // Align cursor to hour if not already (assuming availability is hourly) + cursor = TimeHelpers::roundToHour(cursor); + while (cursor < tEnd) { - std::string key = TimeHelpers::timePointToKey(cursor); - if (it->second.count(key) && it->second.at(key) == Availability::Unavailable) { + if (it->second.count(cursor) && it->second.at(cursor) == Availability::Unavailable) { isUnavailable = true; break; } - cursor += std::chrono::hours(1); + cursor += 3600; } } if (isUnavailable) { @@ -80,11 +82,11 @@ std::string explain_assignment_failure( // Check Consecutive Stints int consecutiveCount = 1; for (int k = stintIndex - 1; k >= 0; --k) { - if (driverAssignments[candidate.name].count(k)) consecutiveCount++; + if (driverAssignments[candidate.nameId].count(k)) consecutiveCount++; else break; } for (size_t k = stintIndex + 1; k < input.stints.size(); ++k) { - if (driverAssignments[candidate.name].count((int)k)) consecutiveCount++; + if (driverAssignments[candidate.nameId].count((int)k)) consecutiveCount++; else break; } @@ -95,15 +97,15 @@ std::string explain_assignment_failure( // Check Minimum Rest if (input.minimumRestHours > 0) { double minRestSec = input.minimumRestHours * 3600.0; - for (int assignedS : driverAssignments[candidate.name]) { - auto s1_start = TimeHelpers::stringToTimePoint(input.stints[stintIndex].startTime); - auto s1_end = TimeHelpers::stringToTimePoint(input.stints[stintIndex].endTime); - auto s2_start = TimeHelpers::stringToTimePoint(input.stints[assignedS].startTime); - auto s2_end = TimeHelpers::stringToTimePoint(input.stints[assignedS].endTime); + for (int assignedS : driverAssignments[candidate.nameId]) { + auto s1_start = input.stints[stintIndex].startTime; + auto s1_end = input.stints[stintIndex].endTime; + auto s2_start = input.stints[assignedS].startTime; + auto s2_end = input.stints[assignedS].endTime; double gap = 0.0; - if (s1_end <= s2_start) gap = std::chrono::duration(s2_start - s1_end).count(); - else if (s2_end <= s1_start) gap = std::chrono::duration(s1_start - s2_end).count(); + if (s1_end <= s2_start) gap = std::difftime(s2_start, s1_end); + else if (s2_end <= s1_start) gap = std::difftime(s1_start, s2_end); else gap = -1.0; if (gap < minRestSec - 1.0) { @@ -120,11 +122,11 @@ std::string explain_assignment_failure( if (input.maximumBusyHours > 0) { double busyDuration = getDuration(stintIndex); for (int k = stintIndex - 1; k >= 0; --k) { - if (driverAssignments[candidate.name].count(k)) busyDuration += getDuration(k); + if (driverAssignments[candidate.nameId].count(k)) busyDuration += getDuration(k); else break; } for (size_t k = stintIndex + 1; k < input.stints.size(); ++k) { - if (driverAssignments[candidate.name].count((int)k)) busyDuration += getDuration(k); + if (driverAssignments[candidate.nameId].count((int)k)) busyDuration += getDuration(k); else break; } if (busyDuration > input.maximumBusyHours) { @@ -171,8 +173,8 @@ std::string formatStintList(const std::vector& indices, const jres::interna std::vector formatHumanDiagnostic( const std::map& slackInfo, const std::set& unavailableVars, - const std::map, int>& driverWorkVars, - const std::map, int>& spotterWorkVars, + const std::map, int>& driverWorkVars, + const std::map, int>& spotterWorkVars, const std::vector& colValues, const jres::internal::SolverInput& input, const std::vector& driverPool, @@ -206,14 +208,14 @@ std::vector formatHumanDiagnostic( std::map> groupedViolations; std::vector globalViolations; - // Build reverse map for variable index -> (Member, Stint, Role) + // Build reverse map for variable index -> (MemberName, Stint, Role) std::map> varToInfo; for(const auto& [key, varIdx] : driverWorkVars) { - varToInfo[varIdx] = std::make_tuple(key.first, key.second, "Driver"); + varToInfo[varIdx] = std::make_tuple(input.strings.get_string(key.first), key.second, "Driver"); } for(const auto& [key, varIdx] : spotterWorkVars) { - varToInfo[varIdx] = std::make_tuple(key.first, key.second, "Spotter"); + varToInfo[varIdx] = std::make_tuple(input.strings.get_string(key.first), key.second, "Spotter"); } // Check Unavailable (Priority 0) @@ -235,23 +237,25 @@ std::vector formatHumanDiagnostic( if (info.type.find("Busy") != std::string::npos) priority = 1; else if (info.type.find("Rest") != std::string::npos) priority = 2; else if (info.type.find("Fair Share") != std::string::npos) priority = 2; + + std::string infoMemberName = input.strings.get_string(info.memberNameId); if (info.stintIndex >= 0) { std::string reason; - if (priority == 1) reason = "Max Busy Time exceeded (" + info.memberName + ")"; + if (priority == 1) reason = "Max Busy Time exceeded (" + infoMemberName + ")"; else if (priority == 2) { - if (info.type.find("Fair Share") != std::string::npos) reason = "Fair Share Rules violated (" + info.memberName + ")"; - else reason = "Rest Rules violated (" + info.memberName + ")"; + if (info.type.find("Fair Share") != std::string::npos) reason = "Fair Share Rules violated (" + infoMemberName + ")"; + else reason = "Rest Rules violated (" + infoMemberName + ")"; } - else reason = info.type + " (" + info.memberName + ")"; + else reason = info.type + " (" + infoMemberName + ")"; - groupedViolations[{priority, reason, info.memberName}].push_back(info.stintIndex); + groupedViolations[{priority, reason, infoMemberName}].push_back(info.stintIndex); } else { // Try to attribute global violation to stints bool assignedAny = false; for(const auto& [vIdx, tupleInfo] : varToInfo) { auto [memName, stintIdx, role] = tupleInfo; - if (memName == info.memberName && colValues[vIdx] > 0.5) { + if (memName == infoMemberName && colValues[vIdx] > 0.5) { std::string reason; if (priority == 1) reason = "Max Busy Time exceeded (" + memName + ")"; else if (priority == 2) { @@ -273,7 +277,7 @@ std::vector formatHumanDiagnostic( else reason = "Rest Rules violated"; } - globalViolations.push_back(reason + " (" + info.memberName + ")"); + globalViolations.push_back(reason + " (" + infoMemberName + ")"); } } } @@ -314,4 +318,4 @@ std::vector formatHumanDiagnostic( return report; } -} // namespace jres::analysis \ No newline at end of file +} // namespace jres::analysis diff --git a/src/analysis/solver_diagnostics.hpp b/src/analysis/solver_diagnostics.hpp index 727378f..9666617 100644 --- a/src/analysis/solver_diagnostics.hpp +++ b/src/analysis/solver_diagnostics.hpp @@ -27,10 +27,10 @@ namespace jres::analysis { */ std::string explain_assignment_failure( int stintIndex, - const std::string& violationDriver, + jres::internal::ID violationDriverId, const jres::internal::SolverInput& input, const std::vector& driverPool, - const std::map, int>& driverWorkVars, + const std::map, int>& driverWorkVars, const std::vector& colValues ); @@ -40,8 +40,8 @@ std::string explain_assignment_failure( std::vector formatHumanDiagnostic( const std::map& slackInfo, const std::set& unavailableVars, - const std::map, int>& driverWorkVars, - const std::map, int>& spotterWorkVars, + const std::map, int>& driverWorkVars, + const std::map, int>& spotterWorkVars, const std::vector& colValues, const jres::internal::SolverInput& input, const std::vector& driverPool, diff --git a/src/constraints/balancing.cpp b/src/constraints/balancing.cpp index 46742ff..1940551 100644 --- a/src/constraints/balancing.cpp +++ b/src/constraints/balancing.cpp @@ -14,8 +14,8 @@ static const double kCostFairness = 10.0; void add_role_coupling_incentive( Highs* highs, const std::vector& pool, - const std::map, int>& driverVars, - const std::map, int>& spotterVars, + const std::map, int>& driverVars, + const std::map, int>& spotterVars, size_t numStints, double weight) { @@ -23,12 +23,12 @@ void add_role_coupling_incentive( for (const auto &p : pool) { for (size_t s = 0; s < numStints - 1; ++s) { - bool hasDriver = driverVars.count({p.name, (int)s}); - bool hasSpotter = spotterVars.count({p.name, (int)s + 1}); + bool hasDriver = driverVars.count({p.nameId, (int)s}); + bool hasSpotter = spotterVars.count({p.nameId, (int)s + 1}); if (hasDriver && hasSpotter) { - int d_var = driverVars.at({p.name, (int)s}); - int s_var = spotterVars.at({p.name, (int)s + 1}); + int d_var = driverVars.at({p.nameId, (int)s}); + int s_var = spotterVars.at({p.nameId, (int)s + 1}); // If there is a transition from driving (stint s) to spotting (stint s+1), reward it. @@ -50,7 +50,7 @@ void add_balancing_constraints( Highs &highs, const std::vector &participants, const jres::internal::SolverInput& input, - const std::map, int>& workVars, + const std::map, int>& workVars, double avgStints) { for (const auto &p : participants) { @@ -59,8 +59,8 @@ void add_balancing_constraints( std::map varCounts; for (size_t s = 0; s < input.stints.size(); ++s) { - if (workVars.count({p.name, (int)s})) { - int v = workVars.at({p.name, (int)s}); + if (workVars.count({p.nameId, (int)s})) { + int v = workVars.at({p.nameId, (int)s}); varCounts[v] += 1.0; } } diff --git a/src/constraints/balancing.hpp b/src/constraints/balancing.hpp index e111f56..f01e74a 100644 --- a/src/constraints/balancing.hpp +++ b/src/constraints/balancing.hpp @@ -15,8 +15,8 @@ namespace jres::constraints { void add_role_coupling_incentive( Highs* highs, const std::vector& pool, - const std::map, int>& driverVars, - const std::map, int>& spotterVars, + const std::map, int>& driverVars, + const std::map, int>& spotterVars, size_t numStints, double weight); @@ -24,7 +24,7 @@ namespace jres::constraints { Highs &highs, const std::vector &participants, const jres::internal::SolverInput& input, - const std::map, int>& workVars, + const std::map, int>& workVars, double avgStints); } diff --git a/src/constraints/max_busy_time.cpp b/src/constraints/max_busy_time.cpp index 0394c45..b1aecf3 100644 --- a/src/constraints/max_busy_time.cpp +++ b/src/constraints/max_busy_time.cpp @@ -7,6 +7,7 @@ #include "Highs.h" #include #include +#include namespace jres::constraints { @@ -14,8 +15,8 @@ void apply_max_busy_time_constraints( Highs &highs, const jres::internal::SolverInput& input, const std::vector &participants, - const std::map, int>& driverVars, - const std::map, int>& spotterVars, + const std::map, int>& driverVars, + const std::map, int>& spotterVars, bool enforceCombined, std::map& slackInfo, const std::vector* fixedSchedule @@ -29,10 +30,8 @@ void apply_max_busy_time_constraints( std::vector stintDurations; stintDurations.reserve(input.stints.size()); for (const auto& stint : input.stints) { - auto s = TimeHelpers::stringToTimePoint(stint.startTime); - auto e = TimeHelpers::stringToTimePoint(stint.endTime); - long long ms = std::chrono::duration_cast(e - s).count(); - stintDurations.push_back(static_cast(ms) / 3600000.0); + double h = std::difftime(stint.endTime, stint.startTime) / 3600.0; + stintDurations.push_back(h); } for (const auto &p : participants) @@ -53,20 +52,20 @@ void apply_max_busy_time_constraints( // Driver if (fixedSchedule) { // Sequential Mode: Check fixed schedule - if (k < fixedSchedule->size() && (*fixedSchedule)[k].driver == p.name) { + if (k < fixedSchedule->size() && (*fixedSchedule)[k].driverId == p.nameId) { fixedAssignments++; } } else { // Integrated Mode: Add driver var to constraint - if (driverVars.count({p.name, (int)k})) { - coefficients[driverVars.at({p.name, (int)k})] += 1.0; + if (driverVars.count({p.nameId, (int)k})) { + coefficients[driverVars.at({p.nameId, (int)k})] += 1.0; } } // Spotter - if (spotterVars.count({p.name, (int)k})) { + if (spotterVars.count({p.nameId, (int)k})) { if (fixedSchedule || enforceCombined) { - coefficients[spotterVars.at({p.name, (int)k})] += 1.0; + coefficients[spotterVars.at({p.nameId, (int)k})] += 1.0; } } } @@ -99,4 +98,4 @@ void apply_max_busy_time_constraints( } } -} // namespace jres::constraints \ No newline at end of file +} // namespace jres::constraints diff --git a/src/constraints/max_busy_time.hpp b/src/constraints/max_busy_time.hpp index 3ba3b9b..dfac0b4 100644 --- a/src/constraints/max_busy_time.hpp +++ b/src/constraints/max_busy_time.hpp @@ -16,8 +16,8 @@ void apply_max_busy_time_constraints( Highs &highs, const jres::internal::SolverInput& input, const std::vector &participants, - const std::map, int>& driverVars, - const std::map, int>& spotterVars, + const std::map, int>& driverVars, + const std::map, int>& spotterVars, bool enforceCombined, std::map& slackInfo, const std::vector* fixedSchedule = nullptr diff --git a/src/constraints/minimum_rest.cpp b/src/constraints/minimum_rest.cpp index 845f267..28d2807 100644 --- a/src/constraints/minimum_rest.cpp +++ b/src/constraints/minimum_rest.cpp @@ -16,48 +16,40 @@ void apply_minimum_rest_constraints( Highs &highs, const jres::internal::SolverInput& input, const std::vector &participants, - const std::map, int>& driverVars, - const std::map, int>& spotterVars, + const std::map, int>& driverVars, + const std::map, int>& spotterVars, bool enforceCombined, std::map& slackInfo ) { using namespace jres::internal; - // Pre-parse stint times - std::vector startTimes; - std::vector endTimes; + if (input.stints.empty()) return; + + // Use time_t directly + std::vector startTimes; + std::vector endTimes; startTimes.reserve(input.stints.size()); endTimes.reserve(input.stints.size()); // Find race start and end - std::chrono::system_clock::time_point raceStart; - std::chrono::system_clock::time_point raceEnd; - bool raceTimesInit = false; - + std::time_t raceStart = input.stints[0].startTime; + std::time_t raceEnd = input.stints[0].endTime; + for (const auto& stint : input.stints) { - auto s = TimeHelpers::stringToTimePoint(stint.startTime); - auto e = TimeHelpers::stringToTimePoint(stint.endTime); - startTimes.push_back(s); - endTimes.push_back(e); - - if(!raceTimesInit) { - raceStart = s; - raceEnd = e; - raceTimesInit = true; - } else { - if(s < raceStart) raceStart = s; - if(e > raceEnd) raceEnd = e; - } + startTimes.push_back(stint.startTime); + endTimes.push_back(stint.endTime); + if (stint.startTime < raceStart) raceStart = stint.startTime; + if (stint.endTime > raceEnd) raceEnd = stint.endTime; } for (const auto &p : participants) { if (input.minimumRestHours <= 0) continue; - auto minRestDuration = std::chrono::hours(input.minimumRestHours); + long long minRestSeconds = (long long)input.minimumRestHours * 3600; // Generate Candidates - std::vector candidateStarts; + std::vector candidateStarts; candidateStarts.push_back(raceStart); for(const auto& t : endTimes) candidateStarts.push_back(t); @@ -66,7 +58,7 @@ void apply_minimum_rest_constraints( blockSets.reserve(candidateStarts.size()); for(const auto& tStart : candidateStarts) { - auto tEnd = tStart + minRestDuration; + auto tEnd = tStart + minRestSeconds; if (tEnd > raceEnd) continue; std::set blocked; @@ -74,12 +66,12 @@ void apply_minimum_rest_constraints( // Overlap check if (startTimes[s] < tEnd && endTimes[s] > tStart) { // Check Driver - if (driverVars.count({p.name, (int)s})) { - blocked.insert(driverVars.at({p.name, (int)s})); + if (driverVars.count({p.nameId, (int)s})) { + blocked.insert(driverVars.at({p.nameId, (int)s})); } // Check Spotter (if combined) - if (enforceCombined && spotterVars.count({p.name, (int)s})) { - blocked.insert(spotterVars.at({p.name, (int)s})); + if (enforceCombined && spotterVars.count({p.nameId, (int)s})) { + blocked.insert(spotterVars.at({p.nameId, (int)s})); } } } @@ -138,7 +130,7 @@ void apply_minimum_rest_constraints( SlackInfo info; info.type = "Minimum Rest (One Instance)"; - info.memberName = p.name; + info.memberNameId = p.nameId; info.stintIndex = -1; info.limit = 1.0; slackInfo[slackVar] = info; diff --git a/src/constraints/minimum_rest.hpp b/src/constraints/minimum_rest.hpp index cf7589c..dae8adf 100644 --- a/src/constraints/minimum_rest.hpp +++ b/src/constraints/minimum_rest.hpp @@ -17,8 +17,8 @@ namespace jres::constraints { Highs &highs, const jres::internal::SolverInput& input, const std::vector &participants, - const std::map, int>& driverVars, - const std::map, int>& spotterVars, + const std::map, int>& driverVars, + const std::map, int>& spotterVars, bool enforceCombined, std::map& slackInfo ); diff --git a/src/jres_internal_types.cpp b/src/jres_internal_types.cpp index bb8c767..52d6343 100644 --- a/src/jres_internal_types.cpp +++ b/src/jres_internal_types.cpp @@ -34,7 +34,7 @@ namespace TimeHelpers { std::time_t time = std::chrono::system_clock::to_time_t(tp); std::tm tm = *std::gmtime(&time); std::stringstream ss; - ss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S"); + ss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ"); return ss.str(); } @@ -46,6 +46,13 @@ namespace TimeHelpers { ss << std::put_time(&tm, "%Y-%m-%dT%H:00:00.000Z"); return ss.str(); } + + std::time_t roundToHour(std::time_t t) { + std::tm tm = *std::gmtime(&t); + tm.tm_min = 0; + tm.tm_sec = 0; + return timegm_portable(&tm); + } } @@ -63,42 +70,81 @@ Availability to_internal_availability(JresAvailability availability) { } SolverInput from_c_input(const JresSolverInput* c_input) { + if (!c_input) { + throw std::runtime_error("Input pointer is null"); + } + SolverInput input; input.consecutiveStints = c_input->consecutiveStints; input.minimumRestHours = c_input->minimumRestHours; input.maximumBusyHours = c_input->maximumBusyHours; if (c_input->firstStintDriver) { - input.firstStintDriver = c_input->firstStintDriver; + input.firstStintDriver = input.strings.get_id(c_input->firstStintDriver); } - for (int i = 0; i < c_input->teamMembers_len; ++i) { - TeamMember member; - member.name = c_input->teamMembers[i].name; - member.isDriver = c_input->teamMembers[i].isDriver; - member.isSpotter = c_input->teamMembers[i].isSpotter; - member.tzOffset = c_input->teamMembers[i].tzOffset; - input.teamMembers.push_back(member); + if (c_input->teamMembers_len > 0) { + if (!c_input->teamMembers) { + throw std::runtime_error("teamMembers is null but length is > 0"); + } + for (int i = 0; i < c_input->teamMembers_len; ++i) { + if (!c_input->teamMembers[i].name) { + throw std::runtime_error("Team member name is null"); + } + TeamMember member; + member.nameId = input.strings.get_id(c_input->teamMembers[i].name); + member.isDriver = c_input->teamMembers[i].isDriver; + member.isSpotter = c_input->teamMembers[i].isSpotter; + member.tzOffset = c_input->teamMembers[i].tzOffset; + input.teamMembers.push_back(member); + } } - for (int i = 0; i < c_input->stints_len; ++i) { - Stint stint; - stint.id = c_input->stints[i].id; - stint.startTime = c_input->stints[i].startTime; - stint.endTime = c_input->stints[i].endTime; - input.stints.push_back(stint); + if (c_input->stints_len > 0) { + if (!c_input->stints) { + throw std::runtime_error("stints is null but length is > 0"); + } + for (int i = 0; i < c_input->stints_len; ++i) { + if (!c_input->stints[i].startTime || !c_input->stints[i].endTime) { + throw std::runtime_error("Stint start/end time is null"); + } + Stint stint; + stint.id = c_input->stints[i].id; + auto startTp = TimeHelpers::stringToTimePoint(c_input->stints[i].startTime); + auto endTp = TimeHelpers::stringToTimePoint(c_input->stints[i].endTime); + stint.startTime = std::chrono::system_clock::to_time_t(startTp); + stint.endTime = std::chrono::system_clock::to_time_t(endTp); + input.stints.push_back(stint); + } } - for (int i = 0; i < c_input->availability_len; ++i) { - std::string name = c_input->availability[i].name; - for (int j = 0; j < c_input->availability[i].availability_len; ++j) { - std::string time = c_input->availability[i].availability[j].time; - // Normalize time to key format used by solver - auto tp = TimeHelpers::stringToTimePoint(time); - std::string key = TimeHelpers::timePointToKey(tp); + if (c_input->availability_len > 0) { + if (!c_input->availability) { + throw std::runtime_error("availability is null but length is > 0"); + } + for (int i = 0; i < c_input->availability_len; ++i) { + if (!c_input->availability[i].name) { + throw std::runtime_error("Availability member name is null"); + } + ID memberId = input.strings.get_id(c_input->availability[i].name); - JresAvailability availability = c_input->availability[i].availability[j].availability; - input.availability[name][key] = to_internal_availability(availability); + if (c_input->availability[i].availability_len > 0 && !c_input->availability[i].availability) { + throw std::runtime_error("Availability entries array is null"); + } + + for (int j = 0; j < c_input->availability[i].availability_len; ++j) { + if (!c_input->availability[i].availability[j].time) { + throw std::runtime_error("Availability time string is null"); + } + std::string time = c_input->availability[i].availability[j].time; + auto tp = TimeHelpers::stringToTimePoint(time); + std::time_t t = std::chrono::system_clock::to_time_t(tp); + // Normalize to hour bucket + std::time_t key = TimeHelpers::roundToHour(t); + + JresAvailability availability = c_input->availability[i].availability[j].availability; + input.availability[memberId][key] = to_internal_availability(availability); + } } } @@ -119,10 +165,18 @@ JresSolverOutput* to_c_output(const SolverOutput& output, const JresSolverOption for (size_t i = 0; i < output.schedule.size(); ++i) { c_output->schedule[i].id = output.schedule[i].id; - c_output->schedule[i].startTime = allocate_and_copy(output.schedule[i].startTime); - c_output->schedule[i].endTime = allocate_and_copy(output.schedule[i].endTime); - c_output->schedule[i].driver = allocate_and_copy(output.schedule[i].driver); - c_output->schedule[i].spotter = allocate_and_copy(output.schedule[i].spotter); + + auto startTp = std::chrono::system_clock::from_time_t(output.schedule[i].startTime); + auto endTp = std::chrono::system_clock::from_time_t(output.schedule[i].endTime); + + c_output->schedule[i].startTime = allocate_and_copy(TimeHelpers::timePointToString(startTp)); + c_output->schedule[i].endTime = allocate_and_copy(TimeHelpers::timePointToString(endTp)); + + std::string driver = (output.schedule[i].driverId != -1) ? output.strings.get_string(output.schedule[i].driverId) : "N/A"; + std::string spotter = (output.schedule[i].spotterId != -1) ? output.strings.get_string(output.schedule[i].spotterId) : "N/A"; + + c_output->schedule[i].driver = allocate_and_copy(driver); + c_output->schedule[i].spotter = allocate_and_copy(spotter); } c_output->diagnosis_len = output.diagnosis.size(); @@ -153,8 +207,8 @@ JresSolverOutput* to_c_output(const SolverOutput& output, const JresSolverOption c_output->config->consecutiveStints = output.config.consecutiveStints; c_output->config->minimumRestHours = output.config.minimumRestHours; c_output->config->maximumBusyHours = output.config.maximumBusyHours; - if (!output.config.firstStintDriver.empty()) { - c_output->config->firstStintDriver = allocate_and_copy(output.config.firstStintDriver); + if (output.config.firstStintDriver != -1) { + c_output->config->firstStintDriver = allocate_and_copy(output.strings.get_string(output.config.firstStintDriver)); } else { c_output->config->firstStintDriver = nullptr; } @@ -163,7 +217,8 @@ JresSolverOutput* to_c_output(const SolverOutput& output, const JresSolverOption c_output->teamMembers_len = output.teamMembers.size(); c_output->teamMembers = new JresTeamMember[c_output->teamMembers_len]; for (size_t i = 0; i < output.teamMembers.size(); ++i) { - c_output->teamMembers[i].name = allocate_and_copy(output.teamMembers[i].name); + std::string name = output.strings.get_string(output.teamMembers[i].nameId); + c_output->teamMembers[i].name = allocate_and_copy(name); c_output->teamMembers[i].isDriver = output.teamMembers[i].isDriver; c_output->teamMembers[i].isSpotter = output.teamMembers[i].isSpotter; c_output->teamMembers[i].tzOffset = output.teamMembers[i].tzOffset; diff --git a/src/jres_internal_types.hpp b/src/jres_internal_types.hpp index be4aeba..b1b9a2f 100644 --- a/src/jres_internal_types.hpp +++ b/src/jres_internal_types.hpp @@ -18,11 +18,33 @@ namespace TimeHelpers { std::chrono::system_clock::time_point stringToTimePoint(const std::string &utc_string); std::string timePointToString(std::chrono::system_clock::time_point tp); std::string timePointToKey(std::chrono::system_clock::time_point tp); + std::time_t roundToHour(std::time_t t); } // --- Data Structures --- +using ID = int; + +struct StringTable { + std::vector id_to_string; + std::map string_to_id; + + ID get_id(const std::string& s) { + auto it = string_to_id.find(s); + if (it != string_to_id.end()) return it->second; + ID id = (ID)id_to_string.size(); + id_to_string.push_back(s); + string_to_id[s] = id; + return id; + } + + std::string get_string(ID id) const { + if (id >= 0 && id < (ID)id_to_string.size()) return id_to_string[id]; + return ""; + } +}; + enum class Availability { Unavailable, Available, @@ -31,7 +53,7 @@ enum class Availability { struct TeamMember { - std::string name; + ID nameId = -1; bool isDriver = true; bool isSpotter = false; double tzOffset = 0.0; @@ -39,8 +61,8 @@ struct TeamMember struct Stint { int id; - std::string startTime; - std::string endTime; + std::time_t startTime; + std::time_t endTime; }; struct SolverInput @@ -48,23 +70,25 @@ struct SolverInput int consecutiveStints = 1; int minimumRestHours = 0; int maximumBusyHours = 8; - std::string firstStintDriver; + ID firstStintDriver = -1; std::vector teamMembers; - std::map> availability; + // Map: MemberID -> Time -> Availability + std::map> availability; std::vector stints; + StringTable strings; }; struct ScheduleEntry { int id; - std::string startTime; - std::string endTime; - std::string driver; - std::string spotter; + std::time_t startTime; + std::time_t endTime; + ID driverId = -1; + ID spotterId = -1; }; struct SlackInfo { std::string type; - std::string memberName; + ID memberNameId = -1; int stintIndex; double limit = 0.0; double actual = 0.0; @@ -84,7 +108,7 @@ struct InputConfig { int consecutiveStints = 1; int minimumRestHours = 0; int maximumBusyHours = 8; - std::string firstStintDriver; + ID firstStintDriver = -1; }; struct SolverOutput @@ -94,7 +118,7 @@ struct SolverOutput SolverStats stats; std::vector teamMembers; InputConfig config; - // Add any other output fields here, like diagnosis or metrics + StringTable strings; }; // --- Conversion Functions --- diff --git a/src/jres_json_converter.cpp b/src/jres_json_converter.cpp index 04e9c36..6f6bbd5 100644 --- a/src/jres_json_converter.cpp +++ b/src/jres_json_converter.cpp @@ -59,9 +59,10 @@ char* allocate_and_copy(const std::string& s) { JRES_SOLVER_API JresSolverInput* jres_input_from_json(const char* jsonData) { last_error_message.clear(); // Clear previous error + JresSolverInput* input = nullptr; try { json j = json::parse(jsonData); - JresSolverInput* input = new JresSolverInput(); + input = new JresSolverInput(); if (j.find("teamMembers") == j.end()) { throw std::runtime_error("Missing 'teamMembers' key in input JSON."); @@ -126,9 +127,11 @@ JRES_SOLVER_API JresSolverInput* jres_input_from_json(const char* jsonData) { } catch (const json::parse_error& e) { last_error_message = "JSON parse error: " + std::string(e.what()); + if (input) free_jres_solver_input(input); return nullptr; } catch (const std::exception& e) { last_error_message = "Error: " + std::string(e.what()); + if (input) free_jres_solver_input(input); return nullptr; } } @@ -260,29 +263,37 @@ JRES_SOLVER_API void free_jres_solver_output(JresSolverOutput* output) { } JRES_SOLVER_API void free_jres_solver_input(JresSolverInput* input) { + if (!input) return; + if (input->firstStintDriver) { delete[] input->firstStintDriver; } - for (int i = 0; i < input->teamMembers_len; ++i) { - delete[] input->teamMembers[i].name; + if (input->teamMembers) { + for (int i = 0; i < input->teamMembers_len; ++i) { + delete[] input->teamMembers[i].name; + } + delete[] input->teamMembers; } - delete[] input->teamMembers; - for (int i = 0; i < input->stints_len; ++i) { - delete[] input->stints[i].startTime; - delete[] input->stints[i].endTime; + if (input->stints) { + for (int i = 0; i < input->stints_len; ++i) { + delete[] input->stints[i].startTime; + delete[] input->stints[i].endTime; + } + delete[] input->stints; } - delete[] input->stints; - for (int i = 0; i < input->availability_len; ++i) { - for (int j = 0; j < input->availability[i].availability_len; ++j) { - delete[] input->availability[i].availability[j].time; + if (input->availability) { + for (int i = 0; i < input->availability_len; ++i) { + for (int j = 0; j < input->availability[i].availability_len; ++j) { + delete[] input->availability[i].availability[j].time; + } + delete[] input->availability[i].availability; + delete[] input->availability[i].name; } - delete[] input->availability[i].availability; - delete[] input->availability[i].name; + delete[] input->availability; } - delete[] input->availability; delete input; -} \ No newline at end of file +} diff --git a/src/jres_solver_base.cpp b/src/jres_solver_base.cpp deleted file mode 100644 index af98cc3..0000000 --- a/src/jres_solver_base.cpp +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @author popmonkey+jres@gmail.com - * @file src/jres_solver_base.cpp - * @brief Base class for the JRES Solver. - */ -#include "jres_solver_base.hpp" -#include -#include - -JresSolverBase::JresSolverBase(const jres::internal::SolverInput& input, const JresSolverOptions& options) - : m_input(input), m_options(options) -{ - // Filter Participant Pools - std::set seenNames; - for (const auto& member : m_input.teamMembers) { - if (seenNames.count(member.name)) { - throw std::runtime_error("Duplicate team member name: " + member.name); - } - seenNames.insert(member.name); - - if (member.isDriver) m_driverPool.push_back(member); - if (member.isSpotter) m_spotterPool.push_back(member); - } - - if (m_driverPool.empty()) { - throw std::runtime_error("No drivers available for this race."); - } -} \ No newline at end of file diff --git a/src/jres_solver_base.hpp b/src/jres_solver_base.hpp deleted file mode 100644 index 82cd4f8..0000000 --- a/src/jres_solver_base.hpp +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @author popmonkey+jres@gmail.com - * @file src/jres_solver_base.hpp - * @brief Base class for the JRES Solver. - */ -#pragma once - -#include "jres_internal_types.hpp" -#include "jres_solver/jres_solver.hpp" - -class JresSolverBase -{ -public: - JresSolverBase(const jres::internal::SolverInput& input, const JresSolverOptions& options); - virtual ~JresSolverBase() = default; - -protected: - const jres::internal::SolverInput& m_input; - const JresSolverOptions& m_options; - - // Filtered Participant Pools - std::vector m_driverPool; - std::vector m_spotterPool; -}; \ No newline at end of file diff --git a/src/jres_standard_solver.cpp b/src/jres_standard_solver.cpp index 0ec918a..3c0fd7b 100644 --- a/src/jres_standard_solver.cpp +++ b/src/jres_standard_solver.cpp @@ -26,8 +26,25 @@ static const double kRewardPreferred = -1.0; static const double kRewardProximity = -0.5; // Incentive for spotting adjacent to driving JresStandardSolver::JresStandardSolver(const jres::internal::SolverInput& input, const JresSolverOptions& options) - : JresSolverBase(input, options) + : m_input(input), m_options(options) { + // Filter Participant Pools + std::set seenNames; + for (const auto& member : m_input.teamMembers) { + if (seenNames.count(member.nameId)) { + std::string name = m_input.strings.get_string(member.nameId); + throw std::runtime_error("Duplicate team member name: " + name); + } + seenNames.insert(member.nameId); + + if (member.isDriver) m_driverPool.push_back(member); + if (member.isSpotter) m_spotterPool.push_back(member); + } + + if (m_driverPool.empty()) { + throw std::runtime_error("No drivers available for this race."); + } + m_highs = std::make_unique(); // Set HiGHS Options @@ -46,21 +63,10 @@ JresStandardSolver::~JresStandardSolver() = default; void JresStandardSolver::add_participant_model( Highs &highs, const std::vector &participants, - std::map, int>& workVars) + std::map, int>& workVars) { if (participants.empty()) return; - // 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 int consecutive = m_input.consecutiveStints; if (consecutive < 1) consecutive = 1; @@ -97,20 +103,18 @@ void JresStandardSolver::add_participant_model( // Map all stints in this block to this variable and accumulate cost for (int s_idx : block) { - workVars[{p.name, s_idx}] = workVarIdx; + workVars[{p.nameId, s_idx}] = workVarIdx; - auto s_time = startTimes[s_idx]; - auto e_time = endTimes[s_idx]; + auto s_time = m_input.stints[s_idx].startTime; + auto e_time = m_input.stints[s_idx].endTime; // 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); + auto t_cursor = jres::internal::TimeHelpers::roundToHour(s_time); while (t_cursor < e_time) { - std::string availabilityKey = jres::internal::TimeHelpers::timePointToKey(t_cursor); - auto member_availability_it = m_input.availability.find(p.name); + auto member_availability_it = m_input.availability.find(p.nameId); if (member_availability_it != m_input.availability.end()) { - auto time_availability_it = member_availability_it->second.find(availabilityKey); + auto time_availability_it = member_availability_it->second.find(t_cursor); if (time_availability_it != member_availability_it->second.end()) { if (time_availability_it->second == jres::internal::Availability::Unavailable) { total_cost += kPenaltyUnavailable; @@ -120,7 +124,7 @@ void JresStandardSolver::add_participant_model( } } } - t_cursor += std::chrono::hours(1); + t_cursor += 3600; } } @@ -138,6 +142,9 @@ jres::internal::SolverOutput JresStandardSolver::solve() using namespace std::chrono; auto startTotal = high_resolution_clock::now(); jres::internal::SolverOutput output; + + // Copy string table to output so we can resolve IDs later + output.strings = m_input.strings; // Populate Config output.config.consecutiveStints = m_input.consecutiveStints; @@ -145,16 +152,6 @@ jres::internal::SolverOutput JresStandardSolver::solve() output.config.maximumBusyHours = m_input.maximumBusyHours; output.config.firstStintDriver = m_input.firstStintDriver; - // --- Duplicate Name Check --- - std::set namesSeen; - for (const auto& m : m_input.teamMembers) { - if (namesSeen.count(m.name)) { - std::string err = "Duplicate team member name: " + m.name; - throw std::runtime_error(err); - } - namesSeen.insert(m.name); - } - // --- Arithmetic Pre-flight Check --- int totalStints = (int)m_input.stints.size(); auto capAnalysis = jres::internal::CapacityAnalyzer::calculate_max_potential_capacity(m_driverPool, m_input); @@ -173,7 +170,7 @@ jres::internal::SolverOutput JresStandardSolver::solve() add_participant_model(*m_highs, m_driverPool, m_driverWorkVars); // --- Hard Constraint: First Stint Driver --- - if (!m_input.firstStintDriver.empty()) { + if (m_input.firstStintDriver != -1) { bool found = false; if (m_driverWorkVars.count({m_input.firstStintDriver, 0})) { int varIdx = m_driverWorkVars.at({m_input.firstStintDriver, 0}); @@ -182,7 +179,8 @@ jres::internal::SolverOutput JresStandardSolver::solve() } if (!found) { - throw std::runtime_error("First stint driver '" + m_input.firstStintDriver + "' is not a valid driver or is unavailable."); + std::string name = m_input.strings.get_string(m_input.firstStintDriver); + throw std::runtime_error("First stint driver '" + name + "' is not a valid driver or is unavailable."); } } @@ -193,10 +191,7 @@ jres::internal::SolverOutput JresStandardSolver::solve() stint_durations_hours.reserve(m_input.stints.size()); for (const auto& stint : m_input.stints) { - auto s = jres::internal::TimeHelpers::stringToTimePoint(stint.startTime); - auto e = jres::internal::TimeHelpers::stringToTimePoint(stint.endTime); - long long ms = std::chrono::duration_cast(e - s).count(); - double h = static_cast(ms) / 3600000.0; + double h = std::difftime(stint.endTime, stint.startTime) / 3600.0; stint_durations_hours.push_back(h); total_duration_hours += h; } @@ -213,8 +208,8 @@ jres::internal::SolverOutput JresStandardSolver::solve() std::map varDurations; for (size_t s = 0; s < m_input.stints.size(); ++s) { - if (m_driverWorkVars.count({p.name, (int)s})) { - int v = m_driverWorkVars.at({p.name, (int)s}); + if (m_driverWorkVars.count({p.nameId, (int)s})) { + int v = m_driverWorkVars.at({p.nameId, (int)s}); varDurations[v] += stint_durations_hours[s]; } } @@ -233,7 +228,7 @@ jres::internal::SolverOutput JresStandardSolver::solve() jres::internal::SlackInfo info; info.type = "Fair Share Rule (Minimum Time)"; - info.memberName = p.name; + info.memberNameId = p.nameId; info.stintIndex = -1; info.limit = min_fair_share_hours; m_slackInfo[slackVar] = info; @@ -260,8 +255,8 @@ jres::internal::SolverOutput JresStandardSolver::solve() std::vector values; for (const auto &p : m_driverPool) { - if (m_driverWorkVars.count({p.name, (int)s})) { - indices.push_back(m_driverWorkVars.at({p.name, (int)s})); + if (m_driverWorkVars.count({p.nameId, (int)s})) { + indices.push_back(m_driverWorkVars.at({p.nameId, (int)s})); values.push_back(1.0); } } @@ -279,9 +274,9 @@ jres::internal::SolverOutput JresStandardSolver::solve() size_t target_s = s % N; for (const auto& p : m_driverPool) { - if (m_driverWorkVars.count({p.name, (int)s}) && m_driverWorkVars.count({p.name, (int)target_s})) { - int var_current = m_driverWorkVars.at({p.name, (int)s}); - int var_target = m_driverWorkVars.at({p.name, (int)target_s}); + if (m_driverWorkVars.count({p.nameId, (int)s}) && m_driverWorkVars.count({p.nameId, (int)target_s})) { + int var_current = m_driverWorkVars.at({p.nameId, (int)s}); + int var_target = m_driverWorkVars.at({p.nameId, (int)target_s}); // Create deviation variable d >= |current - target| // We minimize d, so cost is +weight @@ -326,8 +321,8 @@ jres::internal::SolverOutput JresStandardSolver::solve() std::vector indices; std::vector values; for (const auto& p : m_spotterPool) { - if (m_spotterWorkVars.count({p.name, (int)s})) { - indices.push_back(m_spotterWorkVars.at({p.name, (int)s})); + if (m_spotterWorkVars.count({p.nameId, (int)s})) { + indices.push_back(m_spotterWorkVars.at({p.nameId, (int)s})); values.push_back(1.0); } } @@ -341,8 +336,8 @@ jres::internal::SolverOutput JresStandardSolver::solve() for (const auto& p : m_input.teamMembers) { if (p.isDriver && p.isSpotter) { for (size_t s = 0; s < m_input.stints.size(); ++s) { - if (m_driverWorkVars.count({p.name, (int)s}) && m_spotterWorkVars.count({p.name, (int)s})) { - std::vector idx = { m_driverWorkVars.at({p.name, (int)s}), m_spotterWorkVars.at({p.name, (int)s}) }; + if (m_driverWorkVars.count({p.nameId, (int)s}) && m_spotterWorkVars.count({p.nameId, (int)s})) { + std::vector idx = { m_driverWorkVars.at({p.nameId, (int)s}), m_spotterWorkVars.at({p.nameId, (int)s}) }; std::vector val = {1.0, 1.0}; m_highs->addRow(0.0, 1.0, 2, idx.data(), val.data()); } @@ -406,15 +401,13 @@ jres::internal::SolverOutput JresStandardSolver::solve() entry.id = m_input.stints[s].id; entry.startTime = m_input.stints[s].startTime; entry.endTime = m_input.stints[s].endTime; - entry.driver = "N/A"; - entry.spotter = "N/A"; // Extract Driver for (const auto& p : m_driverPool) { - if (m_driverWorkVars.count({p.name, (int)s})) { - int idx = m_driverWorkVars.at({p.name, (int)s}); + if (m_driverWorkVars.count({p.nameId, (int)s})) { + int idx = m_driverWorkVars.at({p.nameId, (int)s}); if (colValues[idx] > 0.5) { - entry.driver = p.name; + entry.driverId = p.nameId; break; } } @@ -423,10 +416,10 @@ jres::internal::SolverOutput JresStandardSolver::solve() // Extract Spotter (if Integrated) if (m_options.spotterMode == JRES_SPOTTER_MODE_INTEGRATED) { for (const auto& p : m_spotterPool) { - if (m_spotterWorkVars.count({p.name, (int)s})) { - int idx = m_spotterWorkVars.at({p.name, (int)s}); + if (m_spotterWorkVars.count({p.nameId, (int)s})) { + int idx = m_spotterWorkVars.at({p.nameId, (int)s}); if (colValues[idx] > 0.5) { - entry.spotter = p.name; + entry.spotterId = p.nameId; break; } } @@ -464,8 +457,8 @@ jres::internal::SolverOutput JresStandardSolver::solve() std::vector indices; std::vector values; for (const auto& p : m_spotterPool) { - if (m_spotterWorkVars.count({p.name, (int)s})) { - indices.push_back(m_spotterWorkVars.at({p.name, (int)s})); + if (m_spotterWorkVars.count({p.nameId, (int)s})) { + indices.push_back(m_spotterWorkVars.at({p.nameId, (int)s})); values.push_back(1.0); } } @@ -477,9 +470,9 @@ jres::internal::SolverOutput JresStandardSolver::solve() // Cannot spot if driving for (size_t s = 0; s < m_input.stints.size(); ++s) { - const std::string& driverName = output.schedule[s].driver; - if (driverName != "N/A" && m_spotterWorkVars.count({driverName, (int)s})) { - spotterSolver.changeColBounds(m_spotterWorkVars.at({driverName, (int)s}), 0.0, 0.0); + jres::internal::ID driverId = output.schedule[s].driverId; + if (driverId != -1 && m_spotterWorkVars.count({driverId, (int)s})) { + spotterSolver.changeColBounds(m_spotterWorkVars.at({driverId, (int)s}), 0.0, 0.0); } } @@ -491,14 +484,14 @@ jres::internal::SolverOutput JresStandardSolver::solve() std::map spotterRewards; for (const auto& p : m_spotterPool) { for (size_t s = 0; s < m_input.stints.size(); ++s) { - if (!m_spotterWorkVars.count({p.name, (int)s})) continue; - int varIdx = m_spotterWorkVars.at({p.name, (int)s}); + if (!m_spotterWorkVars.count({p.nameId, (int)s})) continue; + int varIdx = m_spotterWorkVars.at({p.nameId, (int)s}); double additionalReward = 0.0; - if (s > 0 && output.schedule[s-1].driver == p.name) { + if (s > 0 && output.schedule[s-1].driverId == p.nameId) { additionalReward += (std::abs(m_options.roleCouplingWeight) > 1e-6) ? -m_options.roleCouplingWeight : kRewardProximity; } - if (s < m_input.stints.size() - 1 && output.schedule[s+1].driver == p.name) { + if (s < m_input.stints.size() - 1 && output.schedule[s+1].driverId == p.nameId) { additionalReward += kRewardProximity; } spotterRewards[varIdx] += additionalReward; @@ -529,7 +522,8 @@ jres::internal::SolverOutput JresStandardSolver::solve() for (const auto& [varIdx, info] : m_slackInfo) { if (varIdx < sColValues.size() && sColValues[varIdx] > 0.001) { std::ostringstream ss; - ss << "Violation: " << info.type << " for Spotter " << info.memberName; + std::string memberName = m_input.strings.get_string(info.memberNameId); + ss << "Violation: " << info.type << " for Spotter " << memberName; if (info.stintIndex >= 0) { ss << " at Stint " << info.stintIndex; } @@ -539,12 +533,13 @@ jres::internal::SolverOutput JresStandardSolver::solve() for (size_t s = 0; s < m_input.stints.size(); ++s) { for (const auto& p : m_spotterPool) { - if (m_spotterWorkVars.count({p.name, (int)s})) { - int idx = m_spotterWorkVars.at({p.name, (int)s}); + if (m_spotterWorkVars.count({p.nameId, (int)s})) { + int idx = m_spotterWorkVars.at({p.nameId, (int)s}); if (sColValues[idx] > 0.5) { - output.schedule[s].spotter = p.name; + output.schedule[s].spotterId = p.nameId; if (m_unavailableVars.count(idx)) { - output.diagnosis.push_back("Violation: Unavailable Spotter " + p.name + " assigned to Stint " + std::to_string(s)); + std::string name = m_input.strings.get_string(p.nameId); + output.diagnosis.push_back("Violation: Unavailable Spotter " + name + " assigned to Stint " + std::to_string(s)); } break; } @@ -559,16 +554,18 @@ jres::internal::SolverOutput JresStandardSolver::solve() // --- Final Validation --- for (size_t s = 0; s < output.schedule.size(); ++s) { - if (output.schedule[s].driver == "N/A") { - output.diagnosis.push_back("Stint " + std::to_string(s) + " (" + output.schedule[s].startTime + ") has no assigned driver."); + if (output.schedule[s].driverId == -1) { + std::string timeStr = jres::internal::TimeHelpers::timePointToString(std::chrono::system_clock::from_time_t(output.schedule[s].startTime)); + output.diagnosis.push_back("Stint " + std::to_string(s) + " (" + timeStr + ") has no assigned driver."); } bool spotterRequired = (m_options.spotterMode != JRES_SPOTTER_MODE_NONE && !m_options.allowNoSpotter); - if (spotterRequired && output.schedule[s].spotter == "N/A") { - output.diagnosis.push_back("Stint " + std::to_string(s) + " (" + output.schedule[s].startTime + ") has no assigned spotter."); + if (spotterRequired && output.schedule[s].spotterId == -1) { + std::string timeStr = jres::internal::TimeHelpers::timePointToString(std::chrono::system_clock::from_time_t(output.schedule[s].startTime)); + output.diagnosis.push_back("Stint " + std::to_string(s) + " (" + timeStr + ") has no assigned spotter."); } } output.teamMembers = m_input.teamMembers; return output; -} \ No newline at end of file +} diff --git a/src/jres_standard_solver.hpp b/src/jres_standard_solver.hpp index f232e83..483a53b 100644 --- a/src/jres_standard_solver.hpp +++ b/src/jres_standard_solver.hpp @@ -5,8 +5,8 @@ */ #pragma once -#include "jres_solver_base.hpp" #include "jres_internal_types.hpp" +#include "jres_solver/jres_solver.hpp" #include #include #include @@ -14,7 +14,7 @@ // Forward declaration class Highs; -class JresStandardSolver : public JresSolverBase +class JresStandardSolver { public: JresStandardSolver(const jres::internal::SolverInput& input, const JresSolverOptions& options); @@ -23,17 +23,24 @@ class JresStandardSolver : public JresSolverBase jres::internal::SolverOutput solve(); private: + const jres::internal::SolverInput& m_input; + const JresSolverOptions& m_options; + + // Filtered Participant Pools + std::vector m_driverPool; + std::vector m_spotterPool; + // Helper to build the complex variable model for drivers/spotters void add_participant_model( Highs &highs, const std::vector &participants, - std::map, int>& workVars + std::map, int>& workVars ); std::unique_ptr m_highs; - std::map, int> m_driverWorkVars; - std::map, int> m_spotterWorkVars; - std::map, int> m_switchVars; + std::map, int> m_driverWorkVars; + std::map, int> m_spotterWorkVars; + std::map, int> m_switchVars; // Elastic Solver State std::map m_slackInfo; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d928c0b..a1c026f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -42,6 +42,7 @@ add_executable(solver_tests test_availability_overlap.cpp test_availability_boundaries.cpp test_validation.cpp + test_long_race.cpp ) add_dependencies(solver_tests jres_solver_lib) diff --git a/test/integration/run_test.sh b/test/integration/run_test.sh index d07fa25..d718963 100755 --- a/test/integration/run_test.sh +++ b/test/integration/run_test.sh @@ -66,10 +66,13 @@ if ! grep -q -e "--- ITINERARIES ---" "$SUMMARY_TXT"; then exit 1 fi -echo "All checks passed." +echo "All checks passed for file output mode." # Cleanup rm "$SOLUTION_JSON" "$SUMMARY_TXT" +echo "Running stdout formatter test..." +./test/integration/test_formatter_stdout.sh + echo "Integration test passed!" -exit 0 \ No newline at end of file +exit 0 diff --git a/test/integration/test_formatter_stdout.sh b/test/integration/test_formatter_stdout.sh new file mode 100755 index 0000000..aab1af2 --- /dev/null +++ b/test/integration/test_formatter_stdout.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Test the formatter's ability to print to stdout + +# Exit on error +set -e + +# Path to the executables +BUILD_DIR="build" +FORMATTER="$BUILD_DIR/jres_formatter" +SOLVER="$BUILD_DIR/jres_solver" +INPUT_FILE="data/24h_race.json" +SOLUTION_FILE="/tmp/test_solution_stdout.json" + +# Make sure we are in the project root +if [ ! -f "CMakeLists.txt" ]; then + echo "Error: This script must be run from the project root directory." + exit 1 +fi + +# Make sure binaries exist +if [ ! -f "$SOLVER" ]; then + echo "Error: Solver binary not found at $SOLVER. Build the project first." + exit 1 +fi +if [ ! -f "$FORMATTER" ]; then + echo "Error: Formatter binary not found at $FORMATTER. Build the project first." + exit 1 +fi + +# Cleanup previous runs +rm -f "$SOLUTION_FILE" + +echo "Running solver to generate solution..." +$SOLVER -i $INPUT_FILE -s sequential -o $SOLUTION_FILE --quiet + +if [ ! -f "$SOLUTION_FILE" ]; then + echo "Error: Solver failed to generate solution file." + exit 1 +fi + +echo "Running formatter without -o flag..." +# Run the formatter and capture stdout +OUTPUT=$($FORMATTER -i "$SOLUTION_FILE") + +# Check if the output contains expected strings from the summary report +if [[ "$OUTPUT" == *"--- DRIVER SUMMARY ---"* ]] && [[ "$OUTPUT" == *"--- SCHEDULE ---"* ]]; then + echo "Success: Formatter output to stdout detected." +else + echo "Error: Formatter did not print expected summary to stdout." + echo "Output was:" + echo "$OUTPUT" + exit 1 +fi + +# Clean up +rm "$SOLUTION_FILE" +exit 0 diff --git a/test/test_formatter_itinerary.cpp b/test/test_formatter_itinerary.cpp index 2c78cd1..5cd161f 100644 --- a/test/test_formatter_itinerary.cpp +++ b/test/test_formatter_itinerary.cpp @@ -110,4 +110,4 @@ TEST(FormatterItineraryTest, NegativeTimezone) { EXPECT_EQ(drive_block1.activity, "Driving Stint #1"); EXPECT_EQ(drive_block1.start_local.to_string(), "2022-12-31 21:00:00"); // 02:00 UTC - 5h EXPECT_EQ(drive_block1.end_local.to_string(), "2022-12-31 22:00:00"); // 03:00 UTC - 5h -} \ No newline at end of file +} diff --git a/test/test_long_race.cpp b/test/test_long_race.cpp new file mode 100644 index 0000000..0b71d2d --- /dev/null +++ b/test/test_long_race.cpp @@ -0,0 +1,186 @@ +#include "gtest/gtest.h" +#include "jres_solver/jres_solver.hpp" +#include "nlohmann/json.hpp" +#include +#include +#include +#include + +using json = nlohmann::json; + +// Helper to format ISO8601 time +std::string format_time(int hour) { + std::ostringstream oss; + int day = 17 + (hour / 24); + oss << "2026-01-" << std::setfill('0') << std::setw(2) << day << "T" + << std::setfill('0') << std::setw(2) << (hour % 24) << ":00:00"; + return oss.str(); +} + +TEST(LongRaceTest, Solves48HourRace) { + // Scenario: 4 Team Members, 48 Stints (1 hour each). + // This tests if the solver can handle a larger problem size (longer duration). + + json j; + j["success"] = true; + j["consecutiveStints"] = 2; // Encourage double stints + j["minimumRestHours"] = 6; // Mandatory rest + + json members = json::array(); + std::vector names = {"Driver A", "Driver B", "Driver C", "Driver D"}; + for (const auto& name : names) { + members.push_back({ + {"name", name}, + {"isDriver", true}, + {"isSpotter", true} + }); + } + j["teamMembers"] = members; + + json stints = json::array(); + int num_stints = 48; + for (int i = 0; i < num_stints; ++i) { + stints.push_back({ + {"id", i + 1}, + {"startTime", format_time(i)}, + {"endTime", format_time(i + 1)} + }); + } + j["stints"] = stints; + + // Everyone available all the time + j["availability"] = json::object(); + for (const auto& name : names) { + json member_avail = json::object(); + for (int i = 0; i < num_stints; ++i) { + member_avail[format_time(i)] = "Available"; + } + // Also add the end time of the last stint as an availability point? + // The solver typically looks at stint start times or specific intervals. + // Based on other tests, it seems to be keyed by time. + // Let's just ensure we cover the stint start times. + j["availability"][name] = member_avail; + } + + j["firstStintDriver"] = nullptr; + + std::string json_str = j.dump(); + JresSolverInput* input = jres_input_from_json(json_str.c_str()); + ASSERT_NE(input, nullptr); + + JresSolverOptions options = {}; + options.timeLimit = 30; // Give it a bit more time for a larger problem + options.spotterMode = JRES_SPOTTER_MODE_INTEGRATED; + options.allowNoSpotter = false; + options.optimalityGap = 0.05; // Allow 5% gap to speed it up + + JresSolverOutput* output = solve_race_schedule(input, &options); + + // Check if we got a solution + ASSERT_NE(output, nullptr); + EXPECT_EQ(output->schedule_len, num_stints); + EXPECT_EQ(output->diagnosis_len, 0); + + // Basic validation: ensure no one drives > 4 hours in 6 hours (implicit check by solver, but good to know it solved) + + free_jres_solver_output(output); + free_jres_solver_input(input); +} + +TEST(LongRaceTest, DaySpecificAvailability) { + // Scenario: Verify that availability at 10:00 Day 1 is distinct from 10:00 Day 2. + // Stint 10 starts at 10:00 on Day 1. + // Stint 34 starts at 10:00 on Day 2 (10 + 24 = 34). + + // We will restrict availability such that: + // - Stint 10 MUST be driven by Driver A (Driver B is unavailable). + // - Stint 34 MUST be driven by Driver B (Driver A is unavailable). + // If the solver conflates days, it might think Driver A is unavailable at Stint 10 (if Day 2 overwrites Day 1) + // or Driver A is available at Stint 34 (if Day 1 overwrites Day 2). + + json j; + j["success"] = true; + j["consecutiveStints"] = 1; + j["minimumRestHours"] = 0; + + json members = json::array(); + members.push_back({{"name", "Driver A"}, {"isDriver", true}, {"isSpotter", false}}); + members.push_back({{"name", "Driver B"}, {"isDriver", true}, {"isSpotter", false}}); + members.push_back({{"name", "Driver C"}, {"isDriver", true}, {"isSpotter", false}}); + j["teamMembers"] = members; + + json stints = json::array(); + int num_stints = 48; + for (int i = 0; i < num_stints; ++i) { + stints.push_back({ + {"id", i + 1}, + {"startTime", format_time(i)}, + {"endTime", format_time(i + 1)} + }); + } + j["stints"] = stints; + + j["availability"] = json::object(); + + // Default: Everyone available everywhere + json avail_A = json::object(); + json avail_B = json::object(); + json avail_C = json::object(); + for (int i = 0; i < num_stints; ++i) { + avail_A[format_time(i)] = "Available"; + avail_B[format_time(i)] = "Available"; + avail_C[format_time(i)] = "Available"; + } + + // Constraint for Stint 10 (Hour 10) + // Driver B and C are unavailable, so A must drive + avail_B[format_time(10)] = "Unavailable"; + avail_C[format_time(10)] = "Unavailable"; + + // Constraint for Stint 34 (Hour 34 = 10 + 24) + // Driver A and C are unavailable, so B must drive + avail_A[format_time(34)] = "Unavailable"; + avail_C[format_time(34)] = "Unavailable"; + + j["availability"]["Driver A"] = avail_A; + j["availability"]["Driver B"] = avail_B; + j["availability"]["Driver C"] = avail_C; + + j["firstStintDriver"] = nullptr; + + std::string json_str = j.dump(); + JresSolverInput* input = jres_input_from_json(json_str.c_str()); + ASSERT_NE(input, nullptr); + + JresSolverOptions options = {}; + options.timeLimit = 30; + options.spotterMode = JRES_SPOTTER_MODE_NONE; + options.allowNoSpotter = true; // No spotters needed for this test + options.optimalityGap = 0.0; + + JresSolverOutput* output = solve_race_schedule(input, &options); + + ASSERT_NE(output, nullptr); + if (output->diagnosis_len > 0) { + for(int i=0; idiagnosis_len; ++i) { + std::cout << "Diagnosis: " << output->diagnosis[i] << std::endl; + } + } + EXPECT_EQ(output->diagnosis_len, 0); + EXPECT_EQ(output->schedule_len, num_stints); + + // Verify Stint 10 + // schedule[10] corresponds to stint with id 11 (index 10) + // startTime is format_time(10) + EXPECT_STREQ(output->schedule[10].driver, "Driver A") + << "Stint 10 (Day 1 10:00) should be Driver A. Driver B was unavailable."; + + // Verify Stint 34 + // schedule[34] corresponds to stint with id 35 (index 34) + // startTime is format_time(34) + EXPECT_STREQ(output->schedule[34].driver, "Driver B") + << "Stint 34 (Day 2 10:00) should be Driver B. Driver A was unavailable."; + + free_jres_solver_output(output); + free_jres_solver_input(input); +} diff --git a/test/test_max_busy.cpp b/test/test_max_busy.cpp index 8bf46ff..27cc519 100644 --- a/test/test_max_busy.cpp +++ b/test/test_max_busy.cpp @@ -236,4 +236,4 @@ TEST(MaxBusyTest, MaxBusySequential) { free_jres_solver_output(output); free_jres_solver_input(input); -} \ No newline at end of file +} diff --git a/test/test_minimum_rest.cpp b/test/test_minimum_rest.cpp index a48311c..4d6f11d 100644 --- a/test/test_minimum_rest.cpp +++ b/test/test_minimum_rest.cpp @@ -16,25 +16,31 @@ #define TOSTRING(x) STRINGIFY(x) // Helper to check rest times -void check_rest_times(const jres::internal::SolverOutput& output, int minimumRestHours) { - if (output.schedule.empty()) return; +void check_rest_times(const JresSolverOutput* output, int minimumRestHours) { + if (!output || output->schedule_len == 0) return; // Determine Race Start and End - auto raceStart = jres::internal::TimeHelpers::stringToTimePoint(output.schedule[0].startTime); - auto raceEnd = jres::internal::TimeHelpers::stringToTimePoint(output.schedule[0].endTime); - for(const auto& s : output.schedule) { - auto tS = jres::internal::TimeHelpers::stringToTimePoint(s.startTime); - auto tE = jres::internal::TimeHelpers::stringToTimePoint(s.endTime); + auto raceStart = jres::internal::TimeHelpers::stringToTimePoint(output->schedule[0].startTime); + auto raceEnd = jres::internal::TimeHelpers::stringToTimePoint(output->schedule[0].endTime); + for(int i=0; ischedule_len; ++i) { + auto tS = jres::internal::TimeHelpers::stringToTimePoint(output->schedule[i].startTime); + auto tE = jres::internal::TimeHelpers::stringToTimePoint(output->schedule[i].endTime); if(tS < raceStart) raceStart = tS; if(tE > raceEnd) raceEnd = tE; } auto minRestDuration = std::chrono::hours(minimumRestHours); - std::map> driver_stints; - for (const auto& entry : output.schedule) { - if (entry.driver != "N/A") { - driver_stints[entry.driver].push_back(entry); + struct Entry { + std::string startTime; + std::string endTime; + }; + std::map> driver_stints; + + for (int i=0; ischedule_len; ++i) { + std::string driver = output->schedule[i].driver; + if (driver != "N/A") { + driver_stints[driver].push_back({output->schedule[i].startTime, output->schedule[i].endTime}); } } @@ -120,19 +126,7 @@ TEST(MinimumRestTest, Enforcement) { // If it returns valid output, check constraints. if (output && output->schedule_len > 0) { - jres::internal::SolverOutput internal_output; - // Reconstruct internal output for helper - for(int i=0; ischedule_len; ++i) { - internal_output.schedule.push_back({ - output->schedule[i].id, - output->schedule[i].startTime, - output->schedule[i].endTime, - output->schedule[i].driver, - output->schedule[i].spotter - }); - } - // Check for 2 hours minimum rest as defined in the JSON - check_rest_times(internal_output, 2); + check_rest_times(output, 2); } else { // If infeasible, that's also a valid outcome for certain constraints, // though this specific scenario should be feasible. @@ -183,18 +177,7 @@ TEST(MinimumRestTest, FeasibleScenario) { ASSERT_NE(output, nullptr); ASSERT_EQ(output->schedule_len, 4); - jres::internal::SolverOutput internal_output; - for(int i=0; ischedule_len; ++i) { - internal_output.schedule.push_back({ - output->schedule[i].id, - output->schedule[i].startTime, - output->schedule[i].endTime, - output->schedule[i].driver, - output->schedule[i].spotter - }); - } - - check_rest_times(internal_output, 1); + check_rest_times(output, 1); free_jres_solver_input(input); free_jres_solver_output(output); diff --git a/test_formatter_stdout.sh b/test_formatter_stdout.sh deleted file mode 100755 index 7d89893..0000000 --- a/test_formatter_stdout.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -# Test the formatter's ability to print to stdout - -# Path to the formatter executable -FORMATTER="./build/jres_formatter" -INPUT_FILE="data/24h_race.json" - -# First, ensure we have a solution file to format. -# We'll run the solver on the input file and pipe it to a temporary file. -SOLVER="./build/jres_solver" -SOLUTION_FILE="/tmp/test_solution.json" - -echo "Running solver to generate solution..." -$SOLVER -i $INPUT_FILE -s sequential -o $SOLUTION_FILE - -if [ ! -f $SOLUTION_FILE ]; then - echo "Error: Solver failed to generate solution file." - exit 1 -fi - -echo "Running formatter without -o flag..." -# Run the formatter and capture stdout -OUTPUT=$($FORMATTER -i $SOLUTION_FILE) - -# Check if the output contains expected strings from the summary report -if [[ "$OUTPUT" == *"--- DRIVER SUMMARY ---"* ]] && [[ "$OUTPUT" == *"--- SCHEDULE ---"* ]]; then - echo "Success: Formatter output to stdout detected." -else - echo "Error: Formatter did not print expected summary to stdout." - echo "Output was:" - echo "$OUTPUT" - exit 1 -fi - -# Clean up -rm $SOLUTION_FILE -exit 0