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
6 changes: 5 additions & 1 deletion src/jres_internal_types.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,12 @@ SolverInput from_c_input(const JresSolverInput* c_input) {
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);

JresAvailability availability = c_input->availability[i].availability[j].availability;
input.availability[name][time] = to_internal_availability(availability);
input.availability[name][key] = to_internal_availability(availability);
}
}

Expand Down
60 changes: 57 additions & 3 deletions src/jres_standard_solver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

// Penalty Constants
static const double kPenaltySlack = 1000000.0;
static const double kPenaltyUnavailable = 1000000.0;
static const double kPenaltyUnavailable = 10000000.0;
static const double kRewardPreferred = -1.0;
static const double kRewardConsecutive = -2.0;
static const double kRewardProximity = -0.5; // Incentive for spotting adjacent to driving
Expand Down Expand Up @@ -426,9 +426,63 @@ jres::internal::SolverOutput JresStandardSolver::solve()
// --- Build Driver Model ---
add_participant_model(*m_highs, m_driverPool, m_driverWorkVars);

// --- Hard Constraint: Fair Share (Relaxed with penalty) ---
const double num_stints = m_input.stints.size();
// --- Hard Constraint: iRacing Fair Share Rule ---
// Rule: Fair Share = 1/4 of (Total Duration / Num Drivers)
// We enforce this using Time Duration.
// 1. Calculate Total Duration and Stint Durations
double total_duration_hours = 0.0;
std::vector<double> stint_durations_hours;
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<std::chrono::milliseconds>(e - s).count();
double h = static_cast<double>(ms) / 3600000.0;
stint_durations_hours.push_back(h);
total_duration_hours += h;
}

const double num_drivers = m_driverPool.size();
if (num_drivers > 0) {
// "Equal Share" = Total / NumDrivers.
// "Fair Share" = Equal Share / 4.
double min_fair_share_hours = (total_duration_hours / num_drivers) * 0.25;

for (const auto &p : m_driverPool) {
std::vector<int> idx;
std::vector<double> val;

for (size_t s = 0; s < m_input.stints.size(); ++s) {
if (m_driverWorkVars.count({p.name, s})) {
idx.push_back(m_driverWorkVars.at({p.name, s}));
val.push_back(stint_durations_hours[s]);
}
}

if (idx.empty()) continue; // Should catch this elsewhere if they have 0 avail

// Elastic Constraint: Sum(duration * x) + slack >= min_fair_share
int slackVar = m_highs->getNumCol();
m_highs->addVar(0.0, kHighsInf);
m_highs->changeColCost(slackVar, kPenaltySlack);

SlackInfo info;
info.type = "Fair Share Rule (Minimum Time)";
info.memberName = p.name;
info.stintIndex = -1;
info.limit = min_fair_share_hours;
m_slackInfo[slackVar] = info;

idx.push_back(slackVar);
val.push_back(1.0);

m_highs->addRow(min_fair_share_hours, kHighsInf, (int)idx.size(), idx.data(), val.data());
}
}

// --- Incentivize Balanced Driving (Soft Constraint) ---
const double num_stints = m_input.stints.size();
const double avg_stints_per_driver = num_drivers > 0 ? num_stints / num_drivers : 0;

if (num_drivers > 0) {
Expand Down
1 change: 1 addition & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ add_executable(solver_tests
test_balancing.cpp
test_optimization.cpp
test_minimum_rest.cpp
test_fair_share.cpp
)

add_dependencies(solver_tests jres_solver_lib)
Expand Down
2 changes: 1 addition & 1 deletion test/test_diagnosis.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ namespace {
"teamMembers": [
{ "name": "Lauda", "isDriver": true, "isSpotter": true, "minimumRestHours": 0 },
{ "name": "Prost", "isDriver": true, "isSpotter": true, "minimumRestHours": 0 },
{ "name": "Senna", "isSpotter": true, "maxStints": 1, "minimumRestHours": 0 }
{ "name": "Senna", "isDriver": false, "isSpotter": true, "maxStints": 1, "minimumRestHours": 0 }
],
"availability": {
"Lauda": { "1973-06-09T14:00:00.000Z": "Available" },
Expand Down
199 changes: 199 additions & 0 deletions test/test_fair_share.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#include "gtest/gtest.h"
#include "jres_solver/jres_solver.hpp"
#include <string>
#include <vector>
#include <map>
#include <cstring>

// Helper to create a simple stint
JresStint create_stint(int id, const char* start, const char* end) {
JresStint s;
s.id = id;
s.startTime = start;
s.endTime = end;
return s;
}

// Helper to create a driver
JresTeamMember create_driver(const char* name, int maxStints = 10) {
JresTeamMember m;
m.name = name;
m.isDriver = 1;
m.isSpotter = 0;
m.maxStints = maxStints;
m.minimumRestHours = 0;
m.tzOffset = 0.0;
return m;
}

TEST(FairShareTest, EnforcesMinimumRequirement) {
// Scenario: 4 stints of 1 hour each. Total = 4 hours.
// 2 Drivers.
// Equal Share = 2 hours.
// Fair Share = 2 / 4 = 0.5 hours.
// Each driver must drive at least 0.5 hours.
// Stint length = 1 hour.
// So 1 stint is enough. This should pass easily.

// Scenario 2:
// 8 stints of 1 hour. Total = 8 hours.
// 2 Drivers.
// Equal Share = 4 hours.
// Fair Share = 1 hour.
// Driver A is restricted to only 0 stints? Impossible.
// Driver A is restricted to 1 stint. 1 >= 1. OK.

// Scenario 3 (The tricky one):
// 20 stints (20 hours). 2 Drivers.
// Equal = 10 hours.
// Fair = 2.5 hours.
// Driver A is restricted to only 2 stints (Indices 0 and 1) via Availability.
// Max potential for A = 2 hours.
// Requirement = 2.5 hours.
// Violation!

std::vector<JresStint> stints;
// 20 x 1-hour stints
std::vector<std::string> startTimes;
std::vector<std::string> endTimes;
startTimes.reserve(20);
endTimes.reserve(20);
stints.reserve(20);

for(int i=0; i<20; ++i) {
char bufS[32], bufE[32];
snprintf(bufS, sizeof(bufS), "2024-01-01T%02d:00:00Z", i);
snprintf(bufE, sizeof(bufE), "2024-01-01T%02d:00:00Z", i+1);
startTimes.push_back(bufS);
endTimes.push_back(bufE);
stints.push_back(create_stint(i, startTimes.back().c_str(), endTimes.back().c_str()));
}

std::vector<JresTeamMember> members;
members.push_back(create_driver("DriverA", 5)); // MaxConsecutive is fine
members.push_back(create_driver("DriverB", 20));

// Create Availability for DriverA
// Available for 0 and 1. Unavailable for 2..19.
std::vector<JresAvailabilityEntry> availEntriesA;
availEntriesA.reserve(20);

for(int i=0; i<20; ++i) {
JresAvailabilityEntry e;
e.time = startTimes[i].c_str();
if (i < 2) {
e.availability = JRES_AVAILABILITY_AVAILABLE;
} else {
e.availability = JRES_AVAILABILITY_UNAVAILABLE;
}
availEntriesA.push_back(e);
}

JresMemberAvailability mavA;
mavA.name = "DriverA";
mavA.availability = availEntriesA.data();
mavA.availability_len = (int)availEntriesA.size();

std::vector<JresMemberAvailability> availabilities;
availabilities.push_back(mavA);

JresSolverInput input;
input.stints = stints.data();
input.stints_len = (int)stints.size();
input.teamMembers = members.data();
input.teamMembers_len = (int)members.size();
input.availability = availabilities.data();
input.availability_len = (int)availabilities.size();

JresSolverOptions options;
options.timeLimit = 5;
options.spotterMode = JRES_SPOTTER_MODE_NONE;
options.allowNoSpotter = true;
options.optimalityGap = 0.0;

// This should fail (or return diagnosis) because DriverA capacity=2h < FairShare (2.5h)
JresSolverOutput* output = diagnose_race_schedule(&input, &options);

ASSERT_NE(output, nullptr);

bool foundFairShareViolation = false;
for(int i=0; i<output->diagnosis_len; ++i) {
std::string diag = output->diagnosis[i];
if (diag.find("Fair Share") != std::string::npos) {
foundFairShareViolation = true;
break;
}
}

// Debug output if not found
if (!foundFairShareViolation) {
for(int i=0; i<output->diagnosis_len; ++i) {
printf("Diag: %s\n", output->diagnosis[i]);
}
}

EXPECT_TRUE(foundFairShareViolation) << "Should have detected Fair Share violation for DriverA";

free_jres_solver_output(output);
}

TEST(FairShareTest, SuccessScenario) {
// 20 stints. 2 drivers. Fair share = 2.5h.
// Driver A max = 3. 3 >= 2.5. OK.

std::vector<JresStint> stints;
std::vector<std::string> startTimes, endTimes;
startTimes.reserve(20);
endTimes.reserve(20);
stints.reserve(20);

for(int i=0; i<20; ++i) {
char bufS[32], bufE[32];
snprintf(bufS, sizeof(bufS), "2024-01-01T%02d:00:00Z", i);
snprintf(bufE, sizeof(bufE), "2024-01-01T%02d:00:00Z", i+1);
startTimes.push_back(bufS);
endTimes.push_back(bufE);
stints.push_back(create_stint(i, startTimes.back().c_str(), endTimes.back().c_str()));
}

std::vector<JresTeamMember> members;
members.push_back(create_driver("DriverA", 3)); // 3 stints >= 2.5h
members.push_back(create_driver("DriverB", 20));

JresSolverInput input;
input.stints = stints.data();
input.stints_len = (int)stints.size();
input.teamMembers = members.data();
input.teamMembers_len = (int)members.size();
input.availability = nullptr;
input.availability_len = 0;

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);

// Should be feasible
EXPECT_EQ(output->diagnosis_len, 0);
EXPECT_EQ(output->schedule_len, 20);

// Check Driver A assignments
int driverAStints = 0;
for(int i=0; i<output->schedule_len; ++i) {
if (std::string(output->schedule[i].driver) == "DriverA") {
driverAStints++;
}
}
// Optimization might give A more or less, but must be >= 3? No, >= 2.5. But A can only do 3 max.
// And optimization (balancing) will try to give A more if B has 17.
// Actually, balancing tries to equalize. Average is 10.
// A is capped at 3. B takes rest.
// So A should have exactly 3 probably (to be closer to 10).
EXPECT_GE(driverAStints, 3);

free_jres_solver_output(output);
}