Skip to content

Commit 261ac65

Browse files
committed
add a hard fair share requirement
1 parent 0a87b60 commit 261ac65

5 files changed

Lines changed: 263 additions & 5 deletions

File tree

src/jres_internal_types.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,12 @@ SolverInput from_c_input(const JresSolverInput* c_input) {
8888
std::string name = c_input->availability[i].name;
8989
for (int j = 0; j < c_input->availability[i].availability_len; ++j) {
9090
std::string time = c_input->availability[i].availability[j].time;
91+
// Normalize time to key format used by solver
92+
auto tp = TimeHelpers::stringToTimePoint(time);
93+
std::string key = TimeHelpers::timePointToKey(tp);
94+
9195
JresAvailability availability = c_input->availability[i].availability[j].availability;
92-
input.availability[name][time] = to_internal_availability(availability);
96+
input.availability[name][key] = to_internal_availability(availability);
9397
}
9498
}
9599

src/jres_standard_solver.cpp

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
// Penalty Constants
1717
static const double kPenaltySlack = 1000000.0;
18-
static const double kPenaltyUnavailable = 1000000.0;
18+
static const double kPenaltyUnavailable = 10000000.0;
1919
static const double kRewardPreferred = -1.0;
2020
static const double kRewardConsecutive = -2.0;
2121
static const double kRewardProximity = -0.5; // Incentive for spotting adjacent to driving
@@ -426,9 +426,63 @@ jres::internal::SolverOutput JresStandardSolver::solve()
426426
// --- Build Driver Model ---
427427
add_participant_model(*m_highs, m_driverPool, m_driverWorkVars);
428428

429-
// --- Hard Constraint: Fair Share (Relaxed with penalty) ---
430-
const double num_stints = m_input.stints.size();
429+
// --- Hard Constraint: iRacing Fair Share Rule ---
430+
// Rule: Fair Share = 1/4 of (Total Duration / Num Drivers)
431+
// We enforce this using Time Duration.
432+
// 1. Calculate Total Duration and Stint Durations
433+
double total_duration_hours = 0.0;
434+
std::vector<double> stint_durations_hours;
435+
stint_durations_hours.reserve(m_input.stints.size());
436+
437+
for (const auto& stint : m_input.stints) {
438+
auto s = jres::internal::TimeHelpers::stringToTimePoint(stint.startTime);
439+
auto e = jres::internal::TimeHelpers::stringToTimePoint(stint.endTime);
440+
long long ms = std::chrono::duration_cast<std::chrono::milliseconds>(e - s).count();
441+
double h = static_cast<double>(ms) / 3600000.0;
442+
stint_durations_hours.push_back(h);
443+
total_duration_hours += h;
444+
}
445+
431446
const double num_drivers = m_driverPool.size();
447+
if (num_drivers > 0) {
448+
// "Equal Share" = Total / NumDrivers.
449+
// "Fair Share" = Equal Share / 4.
450+
double min_fair_share_hours = (total_duration_hours / num_drivers) * 0.25;
451+
452+
for (const auto &p : m_driverPool) {
453+
std::vector<int> idx;
454+
std::vector<double> val;
455+
456+
for (size_t s = 0; s < m_input.stints.size(); ++s) {
457+
if (m_driverWorkVars.count({p.name, s})) {
458+
idx.push_back(m_driverWorkVars.at({p.name, s}));
459+
val.push_back(stint_durations_hours[s]);
460+
}
461+
}
462+
463+
if (idx.empty()) continue; // Should catch this elsewhere if they have 0 avail
464+
465+
// Elastic Constraint: Sum(duration * x) + slack >= min_fair_share
466+
int slackVar = m_highs->getNumCol();
467+
m_highs->addVar(0.0, kHighsInf);
468+
m_highs->changeColCost(slackVar, kPenaltySlack);
469+
470+
SlackInfo info;
471+
info.type = "Fair Share Rule (Minimum Time)";
472+
info.memberName = p.name;
473+
info.stintIndex = -1;
474+
info.limit = min_fair_share_hours;
475+
m_slackInfo[slackVar] = info;
476+
477+
idx.push_back(slackVar);
478+
val.push_back(1.0);
479+
480+
m_highs->addRow(min_fair_share_hours, kHighsInf, (int)idx.size(), idx.data(), val.data());
481+
}
482+
}
483+
484+
// --- Incentivize Balanced Driving (Soft Constraint) ---
485+
const double num_stints = m_input.stints.size();
432486
const double avg_stints_per_driver = num_drivers > 0 ? num_stints / num_drivers : 0;
433487

434488
if (num_drivers > 0) {

test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ add_executable(solver_tests
3131
test_balancing.cpp
3232
test_optimization.cpp
3333
test_minimum_rest.cpp
34+
test_fair_share.cpp
3435
)
3536

3637
add_dependencies(solver_tests jres_solver_lib)

test/test_diagnosis.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ namespace {
111111
"teamMembers": [
112112
{ "name": "Lauda", "isDriver": true, "isSpotter": true, "minimumRestHours": 0 },
113113
{ "name": "Prost", "isDriver": true, "isSpotter": true, "minimumRestHours": 0 },
114-
{ "name": "Senna", "isSpotter": true, "maxStints": 1, "minimumRestHours": 0 }
114+
{ "name": "Senna", "isDriver": false, "isSpotter": true, "maxStints": 1, "minimumRestHours": 0 }
115115
],
116116
"availability": {
117117
"Lauda": { "1973-06-09T14:00:00.000Z": "Available" },

test/test_fair_share.cpp

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#include "gtest/gtest.h"
2+
#include "jres_solver/jres_solver.hpp"
3+
#include <string>
4+
#include <vector>
5+
#include <map>
6+
#include <cstring>
7+
8+
// Helper to create a simple stint
9+
JresStint create_stint(int id, const char* start, const char* end) {
10+
JresStint s;
11+
s.id = id;
12+
s.startTime = start;
13+
s.endTime = end;
14+
return s;
15+
}
16+
17+
// Helper to create a driver
18+
JresTeamMember create_driver(const char* name, int maxStints = 10) {
19+
JresTeamMember m;
20+
m.name = name;
21+
m.isDriver = 1;
22+
m.isSpotter = 0;
23+
m.maxStints = maxStints;
24+
m.minimumRestHours = 0;
25+
m.tzOffset = 0.0;
26+
return m;
27+
}
28+
29+
TEST(FairShareTest, EnforcesMinimumRequirement) {
30+
// Scenario: 4 stints of 1 hour each. Total = 4 hours.
31+
// 2 Drivers.
32+
// Equal Share = 2 hours.
33+
// Fair Share = 2 / 4 = 0.5 hours.
34+
// Each driver must drive at least 0.5 hours.
35+
// Stint length = 1 hour.
36+
// So 1 stint is enough. This should pass easily.
37+
38+
// Scenario 2:
39+
// 8 stints of 1 hour. Total = 8 hours.
40+
// 2 Drivers.
41+
// Equal Share = 4 hours.
42+
// Fair Share = 1 hour.
43+
// Driver A is restricted to only 0 stints? Impossible.
44+
// Driver A is restricted to 1 stint. 1 >= 1. OK.
45+
46+
// Scenario 3 (The tricky one):
47+
// 20 stints (20 hours). 2 Drivers.
48+
// Equal = 10 hours.
49+
// Fair = 2.5 hours.
50+
// Driver A is restricted to only 2 stints (Indices 0 and 1) via Availability.
51+
// Max potential for A = 2 hours.
52+
// Requirement = 2.5 hours.
53+
// Violation!
54+
55+
std::vector<JresStint> stints;
56+
// 20 x 1-hour stints
57+
std::vector<std::string> startTimes;
58+
std::vector<std::string> endTimes;
59+
startTimes.reserve(20);
60+
endTimes.reserve(20);
61+
stints.reserve(20);
62+
63+
for(int i=0; i<20; ++i) {
64+
char bufS[32], bufE[32];
65+
snprintf(bufS, sizeof(bufS), "2024-01-01T%02d:00:00Z", i);
66+
snprintf(bufE, sizeof(bufE), "2024-01-01T%02d:00:00Z", i+1);
67+
startTimes.push_back(bufS);
68+
endTimes.push_back(bufE);
69+
stints.push_back(create_stint(i, startTimes.back().c_str(), endTimes.back().c_str()));
70+
}
71+
72+
std::vector<JresTeamMember> members;
73+
members.push_back(create_driver("DriverA", 5)); // MaxConsecutive is fine
74+
members.push_back(create_driver("DriverB", 20));
75+
76+
// Create Availability for DriverA
77+
// Available for 0 and 1. Unavailable for 2..19.
78+
std::vector<JresAvailabilityEntry> availEntriesA;
79+
availEntriesA.reserve(20);
80+
81+
for(int i=0; i<20; ++i) {
82+
JresAvailabilityEntry e;
83+
e.time = startTimes[i].c_str();
84+
if (i < 2) {
85+
e.availability = JRES_AVAILABILITY_AVAILABLE;
86+
} else {
87+
e.availability = JRES_AVAILABILITY_UNAVAILABLE;
88+
}
89+
availEntriesA.push_back(e);
90+
}
91+
92+
JresMemberAvailability mavA;
93+
mavA.name = "DriverA";
94+
mavA.availability = availEntriesA.data();
95+
mavA.availability_len = (int)availEntriesA.size();
96+
97+
std::vector<JresMemberAvailability> availabilities;
98+
availabilities.push_back(mavA);
99+
100+
JresSolverInput input;
101+
input.stints = stints.data();
102+
input.stints_len = (int)stints.size();
103+
input.teamMembers = members.data();
104+
input.teamMembers_len = (int)members.size();
105+
input.availability = availabilities.data();
106+
input.availability_len = (int)availabilities.size();
107+
108+
JresSolverOptions options;
109+
options.timeLimit = 5;
110+
options.spotterMode = JRES_SPOTTER_MODE_NONE;
111+
options.allowNoSpotter = true;
112+
options.optimalityGap = 0.0;
113+
114+
// This should fail (or return diagnosis) because DriverA capacity=2h < FairShare (2.5h)
115+
JresSolverOutput* output = diagnose_race_schedule(&input, &options);
116+
117+
ASSERT_NE(output, nullptr);
118+
119+
bool foundFairShareViolation = false;
120+
for(int i=0; i<output->diagnosis_len; ++i) {
121+
std::string diag = output->diagnosis[i];
122+
if (diag.find("Fair Share") != std::string::npos) {
123+
foundFairShareViolation = true;
124+
break;
125+
}
126+
}
127+
128+
// Debug output if not found
129+
if (!foundFairShareViolation) {
130+
for(int i=0; i<output->diagnosis_len; ++i) {
131+
printf("Diag: %s\n", output->diagnosis[i]);
132+
}
133+
}
134+
135+
EXPECT_TRUE(foundFairShareViolation) << "Should have detected Fair Share violation for DriverA";
136+
137+
free_jres_solver_output(output);
138+
}
139+
140+
TEST(FairShareTest, SuccessScenario) {
141+
// 20 stints. 2 drivers. Fair share = 2.5h.
142+
// Driver A max = 3. 3 >= 2.5. OK.
143+
144+
std::vector<JresStint> stints;
145+
std::vector<std::string> startTimes, endTimes;
146+
startTimes.reserve(20);
147+
endTimes.reserve(20);
148+
stints.reserve(20);
149+
150+
for(int i=0; i<20; ++i) {
151+
char bufS[32], bufE[32];
152+
snprintf(bufS, sizeof(bufS), "2024-01-01T%02d:00:00Z", i);
153+
snprintf(bufE, sizeof(bufE), "2024-01-01T%02d:00:00Z", i+1);
154+
startTimes.push_back(bufS);
155+
endTimes.push_back(bufE);
156+
stints.push_back(create_stint(i, startTimes.back().c_str(), endTimes.back().c_str()));
157+
}
158+
159+
std::vector<JresTeamMember> members;
160+
members.push_back(create_driver("DriverA", 3)); // 3 stints >= 2.5h
161+
members.push_back(create_driver("DriverB", 20));
162+
163+
JresSolverInput input;
164+
input.stints = stints.data();
165+
input.stints_len = (int)stints.size();
166+
input.teamMembers = members.data();
167+
input.teamMembers_len = (int)members.size();
168+
input.availability = nullptr;
169+
input.availability_len = 0;
170+
171+
JresSolverOptions options;
172+
options.timeLimit = 5;
173+
options.spotterMode = JRES_SPOTTER_MODE_NONE;
174+
options.allowNoSpotter = true;
175+
options.optimalityGap = 0.0;
176+
177+
JresSolverOutput* output = solve_race_schedule(&input, &options);
178+
ASSERT_NE(output, nullptr);
179+
180+
// Should be feasible
181+
EXPECT_EQ(output->diagnosis_len, 0);
182+
EXPECT_EQ(output->schedule_len, 20);
183+
184+
// Check Driver A assignments
185+
int driverAStints = 0;
186+
for(int i=0; i<output->schedule_len; ++i) {
187+
if (std::string(output->schedule[i].driver) == "DriverA") {
188+
driverAStints++;
189+
}
190+
}
191+
// Optimization might give A more or less, but must be >= 3? No, >= 2.5. But A can only do 3 max.
192+
// And optimization (balancing) will try to give A more if B has 17.
193+
// Actually, balancing tries to equalize. Average is 10.
194+
// A is capped at 3. B takes rest.
195+
// So A should have exactly 3 probably (to be closer to 10).
196+
EXPECT_GE(driverAStints, 3);
197+
198+
free_jres_solver_output(output);
199+
}

0 commit comments

Comments
 (0)