diff --git a/data/no_solution.json b/data/no_solution.json new file mode 100644 index 0000000..f092265 --- /dev/null +++ b/data/no_solution.json @@ -0,0 +1,194 @@ +{ + "success": true, + "availability": { + "Brandon": { + "2025-11-22T07:00:00.000Z": "Available", + "2025-11-22T08:00:00.000Z": "Available", + "2025-11-22T09:00:00.000Z": "Available", + "2025-11-22T10:00:00.000Z": "Available", + "2025-11-22T11:00:00.000Z": "Available", + "2025-11-22T12:00:00.000Z": "Available", + "2025-11-22T13:00:00.000Z": "Available", + "2025-11-22T14:00:00.000Z": "Unavailable", + "2025-11-22T15:00:00.000Z": "Unavailable", + "2025-11-22T16:00:00.000Z": "Unavailable", + "2025-11-22T17:00:00.000Z": "Unavailable", + "2025-11-22T18:00:00.000Z": "Unavailable", + "2025-11-22T19:00:00.000Z": "Unavailable", + "2025-11-22T20:00:00.000Z": "Available", + "2025-11-22T21:00:00.000Z": "Available", + "2025-11-22T22:00:00.000Z": "Available", + "2025-11-22T23:00:00.000Z": "Available", + "2025-11-23T00:00:00.000Z": "Available", + "2025-11-23T01:00:00.000Z": "Available", + "2025-11-23T02:00:00.000Z": "Available", + "2025-11-23T03:00:00.000Z": "Unavailable", + "2025-11-23T04:00:00.000Z": "Unavailable", + "2025-11-23T05:00:00.000Z": "Unavailable", + "2025-11-23T06:00:00.000Z": "Unavailable", + "2025-11-23T07:00:00.000Z": "Unavailable", + "2025-11-23T08:00:00.000Z": "Unavailable" + }, + "Cesar": { + "2025-11-22T07:00:00.000Z": "Unavailable", + "2025-11-22T08:00:00.000Z": "Unavailable", + "2025-11-22T09:00:00.000Z": "Unavailable", + "2025-11-22T10:00:00.000Z": "Unavailable", + "2025-11-22T11:00:00.000Z": "Unavailable", + "2025-11-22T12:00:00.000Z": "Unavailable", + "2025-11-22T13:00:00.000Z": "Unavailable", + "2025-11-22T14:00:00.000Z": "Unavailable", + "2025-11-22T15:00:00.000Z": "Unavailable", + "2025-11-22T16:00:00.000Z": "Unavailable", + "2025-11-22T17:00:00.000Z": "Unavailable", + "2025-11-22T18:00:00.000Z": "Unavailable", + "2025-11-22T19:00:00.000Z": "Unavailable", + "2025-11-22T20:00:00.000Z": "Unavailable", + "2025-11-22T21:00:00.000Z": "Unavailable", + "2025-11-22T22:00:00.000Z": "Unavailable", + "2025-11-22T23:00:00.000Z": "Available", + "2025-11-23T00:00:00.000Z": "Available", + "2025-11-23T01:00:00.000Z": "Available", + "2025-11-23T02:00:00.000Z": "Available", + "2025-11-23T03:00:00.000Z": "Available", + "2025-11-23T04:00:00.000Z": "Available", + "2025-11-23T05:00:00.000Z": "Available", + "2025-11-23T06:00:00.000Z": "Available", + "2025-11-23T07:00:00.000Z": "Available", + "2025-11-23T08:00:00.000Z": "Available" + }, + "Harvey": { + "2025-11-22T07:00:00.000Z": "Available", + "2025-11-22T08:00:00.000Z": "Available", + "2025-11-22T09:00:00.000Z": "Available", + "2025-11-22T10:00:00.000Z": "Available", + "2025-11-22T11:00:00.000Z": "Available", + "2025-11-22T12:00:00.000Z": "Unavailable", + "2025-11-22T13:00:00.000Z": "Unavailable", + "2025-11-22T14:00:00.000Z": "Unavailable", + "2025-11-22T15:00:00.000Z": "Unavailable", + "2025-11-22T16:00:00.000Z": "Available", + "2025-11-22T17:00:00.000Z": "Available", + "2025-11-22T18:00:00.000Z": "Available", + "2025-11-22T19:00:00.000Z": "Available", + "2025-11-22T20:00:00.000Z": "Unavailable", + "2025-11-22T21:00:00.000Z": "Unavailable", + "2025-11-22T22:00:00.000Z": "Unavailable", + "2025-11-22T23:00:00.000Z": "Unavailable", + "2025-11-23T00:00:00.000Z": "Unavailable", + "2025-11-23T01:00:00.000Z": "Unavailable", + "2025-11-23T02:00:00.000Z": "Unavailable", + "2025-11-23T03:00:00.000Z": "Unavailable", + "2025-11-23T04:00:00.000Z": "Unavailable", + "2025-11-23T05:00:00.000Z": "Unavailable", + "2025-11-23T06:00:00.000Z": "Unavailable", + "2025-11-23T07:00:00.000Z": "Unavailable", + "2025-11-23T08:00:00.000Z": "Unavailable" + }, + "Jay": { + "2025-11-22T07:00:00.000Z": "Unavailable", + "2025-11-22T08:00:00.000Z": "Unavailable", + "2025-11-22T09:00:00.000Z": "Unavailable", + "2025-11-22T10:00:00.000Z": "Unavailable", + "2025-11-22T11:00:00.000Z": "Unavailable", + "2025-11-22T12:00:00.000Z": "Unavailable", + "2025-11-22T13:00:00.000Z": "Available", + "2025-11-22T14:00:00.000Z": "Available", + "2025-11-22T15:00:00.000Z": "Available", + "2025-11-22T16:00:00.000Z": "Available", + "2025-11-22T17:00:00.000Z": "Available", + "2025-11-22T18:00:00.000Z": "Available", + "2025-11-22T19:00:00.000Z": "Available", + "2025-11-22T20:00:00.000Z": "Available", + "2025-11-22T21:00:00.000Z": "Available", + "2025-11-22T22:00:00.000Z": "Unavailable", + "2025-11-22T23:00:00.000Z": "Unavailable", + "2025-11-23T00:00:00.000Z": "Unavailable", + "2025-11-23T01:00:00.000Z": "Unavailable", + "2025-11-23T02:00:00.000Z": "Unavailable", + "2025-11-23T03:00:00.000Z": "Unavailable", + "2025-11-23T04:00:00.000Z": "Unavailable", + "2025-11-23T05:00:00.000Z": "Unavailable", + "2025-11-23T06:00:00.000Z": "Unavailable", + "2025-11-23T07:00:00.000Z": "Unavailable", + "2025-11-23T08:00:00.000Z": "Unavailable" + }, + "Jack": { + "2025-11-22T07:00:00.000Z": "Available", + "2025-11-22T08:00:00.000Z": "Available", + "2025-11-22T09:00:00.000Z": "Available", + "2025-11-22T10:00:00.000Z": "Available", + "2025-11-22T11:00:00.000Z": "Available", + "2025-11-22T12:00:00.000Z": "Available", + "2025-11-22T13:00:00.000Z": "Available", + "2025-11-22T14:00:00.000Z": "Available", + "2025-11-22T15:00:00.000Z": "Available", + "2025-11-22T16:00:00.000Z": "Available", + "2025-11-22T17:00:00.000Z": "Available", + "2025-11-22T18:00:00.000Z": "Available", + "2025-11-22T19:00:00.000Z": "Available", + "2025-11-22T20:00:00.000Z": "Available", + "2025-11-22T21:00:00.000Z": "Available", + "2025-11-22T22:00:00.000Z": "Available", + "2025-11-22T23:00:00.000Z": "Unavailable", + "2025-11-23T00:00:00.000Z": "Unavailable", + "2025-11-23T01:00:00.000Z": "Unavailable", + "2025-11-23T02:00:00.000Z": "Unavailable", + "2025-11-23T03:00:00.000Z": "Unavailable", + "2025-11-23T04:00:00.000Z": "Unavailable", + "2025-11-23T05:00:00.000Z": "Unavailable", + "2025-11-23T06:00:00.000Z": "Unavailable", + "2025-11-23T07:00:00.000Z": "Unavailable", + "2025-11-23T08:00:00.000Z": "Unavailable" + } + }, + "teamMembers": [ + { + "name": "Brandon", + "timezone": "-5", + "isDriver": true, + "isSpotter": true, + "preferredStints": 2, + "minimumRestHours": 0 + }, + { + "name": "Cesar", + "timezone": "-5", + "isDriver": true, + "isSpotter": true, + "preferredStints": 2, + "minimumRestHours": 0 + }, + { + "name": "Harvey", + "timezone": "0", + "isDriver": true, + "isSpotter": true, + "preferredStints": 2, + "minimumRestHours": 0 + }, + { + "name": "Jay", + "timezone": "-5", + "isDriver": true, + "isSpotter": true, + "preferredStints": 2, + "minimumRestHours": 0 + }, + { + "name": "Jack", + "timezone": "-5", + "isDriver": true, + "isSpotter": true, + "preferredStints": 2, + "minimumRestHours": 0 + } + ], + "durationHours": 24, + "raceStartUTC": "2025-11-22T07:00:00.000Z", + "avgLapTimeInSeconds": 100.8, + "fuelTankSize": 75, + "fuelUsePerLap": 3.22, + "pitTimeInSeconds": 55.5, + "firstStintDriver": "Jack" +} diff --git a/src/jres_diagnostic_solver.cpp b/src/jres_diagnostic_solver.cpp index 4642b10..9ab2638 100644 --- a/src/jres_diagnostic_solver.cpp +++ b/src/jres_diagnostic_solver.cpp @@ -78,6 +78,8 @@ json JresDiagnosticSolver::diagnose() // BUILD DIAGNOSTIC MODEL // ========================================================= + std::map, int> spotterAssignVars; + // --- Driver Assignment Variables --- for (const auto& p : m_driverPool) { for (int s = 0; s < m_totalStints; ++s) { @@ -97,6 +99,25 @@ json JresDiagnosticSolver::diagnose() } } + // --- Spotter Assignment Variables (Integrated Mode) --- + if (m_ctx.spotterMode == SpotterMode::Integrated && !m_spotterPool.empty()) { + for (const auto& p : m_spotterPool) { + for (int s = 0; s < m_totalStints; ++s) { + int idx = solver.getNumCol(); + spotterAssignVars[{p.name, s}] = idx; + + solver.addVar(0.0, 1.0); + solver.changeColIntegrality(idx, HighsVarType::kInteger); + + if (!isDriverAvailable(p.name, s)) { + solver.changeColCost(idx, 100000.0); + } else { + solver.changeColCost(idx, 0.0); + } + } + } + } + // --- Coverage Constraints (Exactly 1 Driver) --- for (int s = 0; s < m_totalStints; ++s) { std::vector idx; @@ -130,6 +151,60 @@ json JresDiagnosticSolver::diagnose() solver.addRow(1.0, 1.0, (int)idx.size(), idx.data(), val.data()); } + // --- Spotter Coverage Constraints (Integrated Mode) --- + if (m_ctx.spotterMode == SpotterMode::Integrated && !m_spotterPool.empty()) { + for (int s = 0; s < m_totalStints; ++s) { + std::vector idx; + std::vector val; + + for (const auto& p : m_spotterPool) { + idx.push_back(spotterAssignVars.at({p.name, s})); + val.push_back(1.0); + } + + // Slack for missing spotter + int missingSpotterIdx = solver.getNumCol(); + solver.addVar(0.0, 1.0); + solver.changeColIntegrality(missingSpotterIdx, HighsVarType::kInteger); + double missingCost = m_ctx.allowNoSpotter ? 50000.0 : 1000000.0; + solver.changeColCost(missingSpotterIdx, missingCost); + idx.push_back(missingSpotterIdx); + val.push_back(1.0); + + // Slack for extra spotter + int extraSpotterIdx = solver.getNumCol(); + solver.addVar(0.0, 5.0); + solver.changeColIntegrality(extraSpotterIdx, HighsVarType::kInteger); + solver.changeColCost(extraSpotterIdx, 500000.0); + idx.push_back(extraSpotterIdx); + val.push_back(-1.0); + + double lb = m_ctx.allowNoSpotter ? 0.0 : 1.0; + solver.addRow(lb, 1.0, (int)idx.size(), idx.data(), val.data()); + } + + // Driver cannot spot for themselves simultaneously + for (const auto& p : m_ctx.raceData.teamMembers) { + if (p.isDriver && p.isSpotter) { + for (int s = 0; s < m_totalStints; ++s) { + int slackIdx = solver.getNumCol(); + solver.addVar(0.0, 1.0); + solver.changeColIntegrality(slackIdx, HighsVarType::kInteger); + solver.changeColCost(slackIdx, 500000.0); + + std::vector idx = { + assignVars.at({p.name, s}), + spotterAssignVars.at({p.name, s}), + slackIdx + }; + std::vector val = {1.0, 1.0, -1.0}; + // driver + spotter - slack <= 1 + solver.addRow(-kHighsInf, 1.0, 3, idx.data(), val.data()); + } + } + } + } + // --- Max Consecutive Stints --- for (const auto& p : m_driverPool) { int maxConsecutive = p.preferredStints; @@ -155,6 +230,31 @@ json JresDiagnosticSolver::diagnose() solver.addRow(-kHighsInf, maxConsecutive, (int)idx.size(), idx.data(), val.data()); } } + + if (m_ctx.spotterMode == SpotterMode::Integrated && !m_spotterPool.empty()) { + for (const auto& p : m_spotterPool) { + int maxConsecutive = p.preferredStints; + for (int s = 0; s < m_totalStints - maxConsecutive; ++s) { + std::vector idx; + std::vector val; + + for (int i = 0; i <= maxConsecutive; ++i) { + idx.push_back(spotterAssignVars.at({p.name, s + i})); + val.push_back(1.0); + } + + int slackIdx = solver.getNumCol(); + solver.addVar(0.0, (double)(maxConsecutive + 1)); + solver.changeColIntegrality(slackIdx, HighsVarType::kInteger); + solver.changeColCost(slackIdx, 10000.0); + + idx.push_back(slackIdx); + val.push_back(-1.0); + + solver.addRow(-kHighsInf, maxConsecutive, (int)idx.size(), idx.data(), val.data()); + } + } + } // --- Fair Share --- double totalLaps = m_totalStints * m_stintLaps; @@ -224,6 +324,42 @@ json JresDiagnosticSolver::diagnose() } } + if (m_ctx.spotterMode == SpotterMode::Integrated && !m_spotterPool.empty()) { + for (const auto &p : m_spotterPool) { + int minRestHours = p.minimumRestHours; + if (minRestHours > 0 && m_stintWithPitSeconds > 0) { + int minRestStints = static_cast(std::floor((minRestHours * 3600) / m_stintWithPitSeconds)); + if (minRestStints > 0) { + int possibleRestStarts = m_totalStints - minRestStints + 1; + for (int s = 0; s < possibleRestStarts; ++s) { + for (int k = 1; k <= minRestStints; ++k) { + if (s + k < m_totalStints) { + std::vector idx; + std::vector val; + + idx.push_back(spotterAssignVars.at({p.name, s})); + val.push_back(1.0); + + idx.push_back(spotterAssignVars.at({p.name, s + k})); + val.push_back(1.0); + + int slackIdx = solver.getNumCol(); + solver.addVar(0.0, 1.0); + solver.changeColIntegrality(slackIdx, HighsVarType::kInteger); + solver.changeColCost(slackIdx, 50000.0); + + idx.push_back(slackIdx); + val.push_back(-1.0); + + solver.addRow(-kHighsInf, 1.0, (int)idx.size(), idx.data(), val.data()); + } + } + } + } + } + } + } + // --- First Stint Driver --- if (!m_ctx.raceData.firstStintDriver.empty()) { std::string firstName = m_ctx.raceData.firstStintDriver; @@ -248,6 +384,21 @@ json JresDiagnosticSolver::diagnose() // SOLVE // ========================================================= + // --- Pre-declare violation tracking variables for use in both solve and sequential sections --- + std::vector emptyStints; + std::vector unavailableStints; + std::vector emptySpotterStints; + std::vector unavailableSpotterStints; + + std::map driverRestViolations; + std::map>> driverConsecutiveDetails; + std::map driverUnavailableCounts; + std::map fairShareViolations; + + std::map spotterRestViolations; + std::map>> spotterConsecutiveDetails; + std::map spotterUnavailableCounts; + double diagnosticTimeLimit = std::max((double)m_ctx.timeLimit, 60.0); if (m_ctx.timeLimit > 0) { solver.setOptionValue("time_limit", diagnosticTimeLimit); @@ -256,6 +407,225 @@ json JresDiagnosticSolver::diagnose() solver.run(); + // ========================================================= + // SEQUENTIAL SPOTTER MODE (If Needed) + // ========================================================= + + if (m_ctx.spotterMode == SpotterMode::Sequential && !m_spotterPool.empty()) { + // Extract driver assignments from the first solve + std::vector driverAssignments(m_totalStints, "N/A"); + const auto& solution = solver.getSolution(); + const std::vector& colValues = solution.col_value; + + for (int s = 0; s < m_totalStints; ++s) { + for (const auto& p : m_driverPool) { + if (colValues[assignVars.at({p.name, s})] > 0.5) { + driverAssignments[s] = p.name; + break; + } + } + } + + // Create a separate solver for spotters + Highs spotterSolver; + spotterSolver.setOptionValue("output_flag", false); + if (m_ctx.timeLimit > 0) { + spotterSolver.setOptionValue("time_limit", diagnosticTimeLimit); + } + spotterSolver.setOptionValue("mip_rel_gap", m_ctx.optimalityGap); + + // Add spotter variables + for (const auto& p : m_spotterPool) { + for (int s = 0; s < m_totalStints; ++s) { + int idx = spotterSolver.getNumCol(); + spotterAssignVars[{p.name, s}] = idx; + + spotterSolver.addVar(0.0, 1.0); + spotterSolver.changeColIntegrality(idx, HighsVarType::kInteger); + + if (!isDriverAvailable(p.name, s)) { + spotterSolver.changeColCost(idx, 100000.0); + } else { + spotterSolver.changeColCost(idx, 0.0); + } + } + } + + // Spotter coverage constraints + for (int s = 0; s < m_totalStints; ++s) { + std::vector idx; + std::vector val; + + for (const auto& p : m_spotterPool) { + idx.push_back(spotterAssignVars.at({p.name, s})); + val.push_back(1.0); + } + + int missingSpotterIdx = spotterSolver.getNumCol(); + spotterSolver.addVar(0.0, 1.0); + spotterSolver.changeColIntegrality(missingSpotterIdx, HighsVarType::kInteger); + double missingCost = m_ctx.allowNoSpotter ? 50000.0 : 1000000.0; + spotterSolver.changeColCost(missingSpotterIdx, missingCost); + idx.push_back(missingSpotterIdx); + val.push_back(1.0); + + int extraSpotterIdx = spotterSolver.getNumCol(); + spotterSolver.addVar(0.0, 5.0); + spotterSolver.changeColIntegrality(extraSpotterIdx, HighsVarType::kInteger); + spotterSolver.changeColCost(extraSpotterIdx, 500000.0); + idx.push_back(extraSpotterIdx); + val.push_back(-1.0); + + double lb = m_ctx.allowNoSpotter ? 0.0 : 1.0; + spotterSolver.addRow(lb, 1.0, (int)idx.size(), idx.data(), val.data()); + } + + // Prevent drivers from spotting themselves + for (int s = 0; s < m_totalStints; ++s) { + const std::string& driverName = driverAssignments[s]; + if (driverName == "N/A") continue; + + auto it = std::find_if(m_spotterPool.begin(), m_spotterPool.end(), + [&](const TeamMember& m){ return m.name == driverName; }); + if (it != m_spotterPool.end()) { + int varIdx = spotterAssignVars.at({driverName, s}); + spotterSolver.changeColBounds(varIdx, 0.0, 0.0); + } + } + + // Add spotter max consecutive constraints + for (const auto& p : m_spotterPool) { + int maxConsecutive = p.preferredStints; + for (int s = 0; s < m_totalStints - maxConsecutive; ++s) { + std::vector idx; + std::vector val; + + for (int i = 0; i <= maxConsecutive; ++i) { + idx.push_back(spotterAssignVars.at({p.name, s + i})); + val.push_back(1.0); + } + + int slackIdx = spotterSolver.getNumCol(); + spotterSolver.addVar(0.0, (double)(maxConsecutive + 1)); + spotterSolver.changeColIntegrality(slackIdx, HighsVarType::kInteger); + spotterSolver.changeColCost(slackIdx, 10000.0); + + idx.push_back(slackIdx); + val.push_back(-1.0); + + spotterSolver.addRow(-kHighsInf, maxConsecutive, (int)idx.size(), idx.data(), val.data()); + } + } + + // Add spotter minimum rest constraints + for (const auto &p : m_spotterPool) { + int minRestHours = p.minimumRestHours; + if (minRestHours > 0 && m_stintWithPitSeconds > 0) { + int minRestStints = static_cast(std::floor((minRestHours * 3600) / m_stintWithPitSeconds)); + if (minRestStints > 0) { + int possibleRestStarts = m_totalStints - minRestStints + 1; + for (int s = 0; s < possibleRestStarts; ++s) { + for (int k = 1; k <= minRestStints; ++k) { + if (s + k < m_totalStints) { + std::vector idx; + std::vector val; + + idx.push_back(spotterAssignVars.at({p.name, s})); + val.push_back(1.0); + + idx.push_back(spotterAssignVars.at({p.name, s + k})); + val.push_back(1.0); + + int slackIdx = spotterSolver.getNumCol(); + spotterSolver.addVar(0.0, 1.0); + spotterSolver.changeColIntegrality(slackIdx, HighsVarType::kInteger); + spotterSolver.changeColCost(slackIdx, 50000.0); + + idx.push_back(slackIdx); + val.push_back(-1.0); + + spotterSolver.addRow(-kHighsInf, 1.0, (int)idx.size(), idx.data(), val.data()); + } + } + } + } + } + } + + // Solve spotter schedule + spotterSolver.run(); + + // Update spotter-related violation data with sequential results + const auto& spotterSol = spotterSolver.getSolution(); + const std::vector& spotterColValues = spotterSol.col_value; + + spotterRestViolations.clear(); + spotterConsecutiveDetails.clear(); + spotterUnavailableCounts.clear(); + emptySpotterStints.clear(); + + for (const auto& p : m_spotterPool) { + // Availability + for (int s = 0; s < m_totalStints; ++s) { + if (spotterColValues[spotterAssignVars.at({p.name, s})] > 0.5) { + if (!isDriverAvailable(p.name, s)) { + spotterUnavailableCounts[p.name]++; + unavailableSpotterStints.push_back(s + 1); + } + } + } + + // Max Consecutive + int consecutive = 0; + int startStint = -1; + for (int s = 0; s < m_totalStints; ++s) { + if (spotterColValues[spotterAssignVars.at({p.name, s})] > 0.5) { + if (consecutive == 0) startStint = s + 1; + consecutive++; + } else { + if (consecutive > p.preferredStints) { + spotterConsecutiveDetails[p.name].push_back({startStint, s}); + } + consecutive = 0; + } + } + if (consecutive > p.preferredStints) { + spotterConsecutiveDetails[p.name].push_back({startStint, m_totalStints}); + } + + // Min Rest + if (p.minimumRestHours > 0) { + int minRestStints = static_cast(std::floor((p.minimumRestHours * 3600) / m_stintWithPitSeconds)); + int lastSpottedStint = -999; + for (int s = 0; s < m_totalStints; ++s) { + if (spotterColValues[spotterAssignVars.at({p.name, s})] > 0.5) { + if (lastSpottedStint != -999) { + int stintsSinceLast = s - lastSpottedStint - 1; + if (stintsSinceLast >= 0 && stintsSinceLast < minRestStints) { + spotterRestViolations[p.name]++; + } + } + lastSpottedStint = s; + } + } + } + } + + // Check for empty spotter stints + for (int s = 0; s < m_totalStints; ++s) { + bool hasSpotter = false; + for (const auto& p : m_spotterPool) { + if (spotterColValues[spotterAssignVars.at({p.name, s})] > 0.5) { + hasSpotter = true; + break; + } + } + if (!hasSpotter && !m_ctx.allowNoSpotter) { + emptySpotterStints.push_back(s + 1); + } + } + } + // ========================================================= // INTELLIGENT REPORT GENERATION // ========================================================= @@ -264,17 +634,9 @@ json JresDiagnosticSolver::diagnose() const auto& solution = solver.getSolution(); const std::vector& colValues = solution.col_value; - // --- Raw Data Collection --- - std::vector emptyStints; - std::vector unavailableStints; - - std::map driverRestViolations; - std::map>> driverConsecutiveDetails; - std::map driverUnavailableCounts; - std::map fairShareViolations; - + // --- Raw Data Collection (for Integrated mode and drivers) --- // Coverage Check - struct DiagEntry { std::vector drivers; }; + struct DiagEntry { std::vector drivers; std::vector spotters; }; std::vector schedule(m_totalStints); for (const auto& p : m_driverPool) { @@ -285,11 +647,24 @@ json JresDiagnosticSolver::diagnose() } } + if (m_ctx.spotterMode == SpotterMode::Integrated && !m_spotterPool.empty()) { + for (const auto& p : m_spotterPool) { + for (int s = 0; s < m_totalStints; ++s) { + if (colValues[spotterAssignVars.at({p.name, s})] > 0.5) { + schedule[s].spotters.push_back(p.name); + } + } + } + } + for (int s = 0; s < m_totalStints; ++s) { if (schedule[s].drivers.empty()) emptyStints.push_back(s + 1); + if (m_ctx.spotterMode == SpotterMode::Integrated && !m_ctx.allowNoSpotter && schedule[s].spotters.empty()) { + emptySpotterStints.push_back(s + 1); + } } - // Rule Checks + // Rule Checks (Drivers) for (const auto& p : m_driverPool) { // Availability @@ -336,9 +711,62 @@ json JresDiagnosticSolver::diagnose() } } } - - // Fair Share - if (minStintsPerParticipant > 0) { + } + + // Rule Checks (Spotters - Integrated Mode Only, Sequential handled separately) + if (m_ctx.spotterMode == SpotterMode::Integrated && !m_spotterPool.empty()) { + for (const auto& p : m_spotterPool) { + + // Availability + for (int s = 0; s < m_totalStints; ++s) { + if (colValues[spotterAssignVars.at({p.name, s})] > 0.5) { + if (!isDriverAvailable(p.name, s)) { + spotterUnavailableCounts[p.name]++; + unavailableSpotterStints.push_back(s + 1); + } + } + } + + // Max Consecutive + int consecutive = 0; + int startStint = -1; + for (int s = 0; s < m_totalStints; ++s) { + if (colValues[spotterAssignVars.at({p.name, s})] > 0.5) { + if (consecutive == 0) startStint = s + 1; + consecutive++; + } else { + if (consecutive > p.preferredStints) { + spotterConsecutiveDetails[p.name].push_back({startStint, s}); + } + consecutive = 0; + } + } + if (consecutive > p.preferredStints) { + spotterConsecutiveDetails[p.name].push_back({startStint, m_totalStints}); + } + + // Min Rest + if (p.minimumRestHours > 0) { + int minRestStints = static_cast(std::floor((p.minimumRestHours * 3600) / m_stintWithPitSeconds)); + int lastSpottedStint = -999; + for (int s = 0; s < m_totalStints; ++s) { + if (colValues[spotterAssignVars.at({p.name, s})] > 0.5) { + if (lastSpottedStint != -999) { + int stintsSinceLast = s - lastSpottedStint - 1; + if (stintsSinceLast >= 0 && stintsSinceLast < minRestStints) { + spotterRestViolations[p.name]++; + } + } + lastSpottedStint = s; + } + } + } + } + } + + // Fair Share (Drivers only) + if (minStintsPerParticipant > 0) { + for (const auto& p : m_driverPool) { int totalDriven = 0; for (int s = 0; s < m_totalStints; ++s) { if (colValues[assignVars.at({p.name, s})] > 0.5) totalDriven++; @@ -357,14 +785,20 @@ json JresDiagnosticSolver::diagnose() " stints (" + formatStintList(emptyStints) + "). This usually means the total roster size is too small or constraints are too strict during these times."); } - // Systemic Rest Violations + // Critical Gaps (No Spotter) + if (!emptySpotterStints.empty() && !m_ctx.allowNoSpotter) { + issues.push_back("CRITICAL: No spotters could be assigned to " + std::to_string(emptySpotterStints.size()) + + " stints (" + formatStintList(emptySpotterStints) + "). The spotter roster may be too small or constraints are too strict."); + } + + // Systemic Rest Violations (Drivers) int totalRestViolations = 0; for(auto const& [name, count] : driverRestViolations) totalRestViolations += count; if (totalRestViolations > 3) { int minRest = m_driverPool.empty() ? 0 : m_driverPool[0].minimumRestHours; issues.push_back("SYSTEMIC FAILURE: The 'Minimum Rest' setting (" + std::to_string(minRest) + - "h) is causing widespread conflicts. The diagnostic solver had to violate rest rules " + + "h) is causing widespread conflicts for drivers. The diagnostic solver had to violate rest rules " + std::to_string(totalRestViolations) + " times to fill the schedule. Suggestion: Reduce minimum rest or add more drivers."); } else { for(auto const& [name, count] : driverRestViolations) { @@ -372,18 +806,38 @@ json JresDiagnosticSolver::diagnose() } } - // Availability Hotspots + // Systemic Rest Violations (Spotters) + int totalSpotterRestViolations = 0; + for(auto const& [name, count] : spotterRestViolations) totalSpotterRestViolations += count; + + if (totalSpotterRestViolations > 3) { + issues.push_back("SYSTEMIC FAILURE: Spotter 'Minimum Rest' constraints are causing widespread conflicts. " + + std::to_string(totalSpotterRestViolations) + " violations occurred. Suggestion: Reduce spotter rest requirements or add more spotters."); + } else { + for(auto const& [name, count] : spotterRestViolations) { + if (count > 0) issues.push_back("Spotter " + name + " violated rest rules " + std::to_string(count) + " times."); + } + } + + // Availability Hotspots (Drivers) if (!unavailableStints.empty()) { std::string range = formatStintList(unavailableStints); issues.push_back("AVAILABILITY GAP: Drivers were forced to drive during their unavailable blocks in stints: " + range + ". Please verify driver availability during these times."); } - // Consecutive Warnings (With Context Check) + // Availability Hotspots (Spotters) + if (!unavailableSpotterStints.empty()) { + std::string range = formatStintList(unavailableSpotterStints); + issues.push_back("AVAILABILITY GAP: Spotters were forced to spot during their unavailable blocks in stints: " + range + + ". Please verify spotter availability during these times."); + } + + // Consecutive Warnings (Drivers with Context Check) for (const auto& [name, ranges] : driverConsecutiveDetails) { for (const auto& range : ranges) { int start = range.first; // 1-based - int end = range.second; // 1-based inclusize + int end = range.second; // 1-based inclusive // Context Check: Was anyone else available? bool alternativeExists = false; @@ -409,6 +863,34 @@ json JresDiagnosticSolver::diagnose() } } + // Consecutive Warnings (Spotters) + for (const auto& [name, ranges] : spotterConsecutiveDetails) { + for (const auto& range : ranges) { + int start = range.first; + int end = range.second; + + bool alternativeExists = false; + for (int s = start - 1; s < end; ++s) { + for (const auto& other : m_spotterPool) { + if (other.name == name) continue; + if (isDriverAvailable(other.name, s)) { + alternativeExists = true; + break; + } + } + if (alternativeExists) break; + } + + std::string msg = "Spotter " + name + " exceeded max consecutive stint limit (Spotted Stints: " + + std::to_string(start) + "-" + std::to_string(end) + ")."; + + if (!alternativeExists) { + msg += " Note: No other spotters were available during this period."; + } + issues.push_back(msg); + } + } + // Fair Share for (const auto& [name, missing] : fairShareViolations) { issues.push_back("Driver " + name + " is under-utilized by " + std::to_string(missing) + " stints (Fair share requires more driving)."); diff --git a/test/test_diagnosis.cpp b/test/test_diagnosis.cpp index f06eac3..cec003b 100644 --- a/test/test_diagnosis.cpp +++ b/test/test_diagnosis.cpp @@ -103,4 +103,115 @@ namespace { } EXPECT_TRUE(found) << "Expected diagnosis to contain detailed consecutive warning."; } + + // Scenario: Test diagnostic with integrated spotter mode + // Spotter is unavailable for some stints + const char* UNAVAILABLE_SPOTTER_JSON = R"({ + "availability": { + "Lauda": { "1973-06-09T14:00:00.000Z": "Available" }, + "Prost": { "1973-06-09T14:00:00.000Z": "Unavailable", "1973-06-09T14:40:00.000Z": "Available" } + }, + "teamMembers": [ + { "name": "Lauda", "isDriver": true }, + { "name": "Prost", "isSpotter": true } + ], + "durationHours": 1.5, + "raceStartUTC": "1973-06-09T14:00:00.000Z", + "avgLapTimeInSeconds": 120.0, + "fuelTankSize": 120, + "fuelUsePerLap": 5.0, + "pitTimeInSeconds": 60 + })"; + + TEST(DiagnosisTest, UnavailableSpotterIntegrated) { + JresSolverOptions options; + options.timeLimit = 10; + options.spotterMode = JRES_SPOTTER_MODE_INTEGRATED; + options.allowNoSpotter = false; + + char* resultJsonCStr = nullptr; + int resultCode = diagnose_race_schedule(UNAVAILABLE_SPOTTER_JSON, options, &resultJsonCStr); + + ASSERT_EQ(resultCode, 0); + ASSERT_NE(resultJsonCStr, nullptr); + + std::string resultJsonString(resultJsonCStr); + free_solver_result(resultJsonCStr); + json resultJson = json::parse(resultJsonString); + + ASSERT_FALSE(resultJson["success"].get()); + ASSERT_TRUE(resultJson.contains("diagnosis")); + + json diagnosis = resultJson["diagnosis"]; + ASSERT_FALSE(diagnosis.empty()); + + // Check for spotter-related issues + bool foundSpotterIssue = false; + for (const auto& issue : diagnosis) { + std::string msg = issue.get(); + if (msg.find("Spotter") != std::string::npos || + msg.find("spotter") != std::string::npos) { + foundSpotterIssue = true; + break; + } + } + EXPECT_TRUE(foundSpotterIssue) << "Expected diagnosis to contain spotter-related issue"; + } + + // Scenario: Test diagnostic with sequential spotter mode + const char* SEQUENTIAL_SPOTTER_JSON = R"({ + "availability": { + "Lauda": { "1973-06-09T14:00:00.000Z": "Available" }, + "Prost": { "1973-06-09T14:00:00.000Z": "Available" }, + "Senna": { "1973-06-09T14:00:00.000Z": "Available" } + }, + "teamMembers": [ + { "name": "Lauda", "isDriver": true, "isSpotter": true }, + { "name": "Prost", "isDriver": true, "isSpotter": true }, + { "name": "Senna", "isSpotter": true, "preferredStints": 1 } + ], + "durationHours": 2.0, + "raceStartUTC": "1973-06-09T14:00:00.000Z", + "avgLapTimeInSeconds": 120.0, + "fuelTankSize": 100, + "fuelUsePerLap": 5.0, + "pitTimeInSeconds": 60 + })"; + + TEST(DiagnosisTest, SequentialSpotterMode) { + JresSolverOptions options; + options.timeLimit = 10; + options.spotterMode = JRES_SPOTTER_MODE_SEQUENTIAL; + options.allowNoSpotter = false; + + char* resultJsonCStr = nullptr; + int resultCode = diagnose_race_schedule(SEQUENTIAL_SPOTTER_JSON, options, &resultJsonCStr); + + ASSERT_EQ(resultCode, 0); + ASSERT_NE(resultJsonCStr, nullptr); + + std::string resultJsonString(resultJsonCStr); + free_solver_result(resultJsonCStr); + json resultJson = json::parse(resultJsonString); + + // This should either succeed or fail with spotter constraint issues + ASSERT_TRUE(resultJson.contains("diagnosis")); + + if (!resultJson["success"].get()) { + json diagnosis = resultJson["diagnosis"]; + // If it fails, there should be spotter-related issues + bool hasSpotterMention = false; + for (const auto& issue : diagnosis) { + std::string msg = issue.get(); + if (msg.find("Spotter") != std::string::npos || + msg.find("spotter") != std::string::npos) { + hasSpotterMention = true; + break; + } + } + // Sequential mode might work or fail depending on constraints + // Just verify the diagnosis is coherent + EXPECT_TRUE(diagnosis.is_array()); + } + } }