Skip to content

Commit 91b9082

Browse files
authored
Merge pull request #51 from popmonkey/detailed-diagnostics
Detailed "human readable" diagnosis
2 parents 1cbc5fb + cabcc91 commit 91b9082

7 files changed

Lines changed: 380 additions & 32 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ add_library(jres_solver_lib
162162
src/jres_solver_base.cpp
163163
src/jres_standard_solver.cpp
164164
src/analysis/capacity_analyzer.cpp
165+
src/analysis/solver_diagnostics.cpp
165166
src/constraints/balancing.cpp
166167
src/constraints/minimum_rest.cpp
167168
src/constraints/max_busy_time.cpp
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
#include "solver_diagnostics.hpp"
2+
#include "capacity_analyzer.hpp"
3+
#include "../utils/date_utils.hpp"
4+
#include <sstream>
5+
#include <iomanip>
6+
#include <set>
7+
#include <chrono>
8+
#include <algorithm>
9+
#include <cmath>
10+
#include <tuple>
11+
12+
namespace jres::analysis {
13+
14+
std::string explain_assignment_failure(
15+
int stintIndex,
16+
const std::string& violationDriver,
17+
const jres::internal::SolverInput& input,
18+
const std::vector<jres::internal::TeamMember>& driverPool,
19+
const std::map<std::pair<std::string, int>, int>& driverWorkVars,
20+
const std::vector<double>& colValues)
21+
{
22+
using namespace jres::internal;
23+
std::ostringstream ss;
24+
25+
// Time points for the target stint
26+
auto tStart = TimeHelpers::stringToTimePoint(input.stints[stintIndex].startTime);
27+
auto tEnd = TimeHelpers::stringToTimePoint(input.stints[stintIndex].endTime);
28+
29+
// Identify which drivers are assigned to which stints in the CURRENT solution
30+
std::map<std::string, std::set<int>> driverAssignments;
31+
for (size_t s = 0; s < input.stints.size(); ++s) {
32+
for (const auto& p : driverPool) {
33+
if (driverWorkVars.count({p.name, (int)s})) {
34+
int idx = driverWorkVars.at({p.name, (int)s});
35+
if (idx < (int)colValues.size() && colValues[idx] > 0.5) {
36+
driverAssignments[p.name].insert((int)s);
37+
}
38+
}
39+
}
40+
}
41+
42+
// Helper: Get stint duration in hours
43+
auto getDuration = [&](int sIdx) {
44+
auto s = TimeHelpers::stringToTimePoint(input.stints[sIdx].startTime);
45+
auto e = TimeHelpers::stringToTimePoint(input.stints[sIdx].endTime);
46+
return std::chrono::duration<double, std::ratio<3600>>(e - s).count();
47+
};
48+
49+
ss << " Alternatives Analysis:";
50+
51+
for (const auto& candidate : driverPool) {
52+
if (candidate.name == violationDriver) continue;
53+
54+
ss << "\n - " << candidate.name << ": ";
55+
std::vector<std::string> reasons;
56+
57+
// Check Availability
58+
bool isUnavailable = false;
59+
auto it = input.availability.find(candidate.name);
60+
if (it != input.availability.end()) {
61+
auto cursor = tStart;
62+
while (cursor < tEnd) {
63+
std::string key = TimeHelpers::timePointToKey(cursor);
64+
if (it->second.count(key) && it->second.at(key) == Availability::Unavailable) {
65+
isUnavailable = true;
66+
break;
67+
}
68+
cursor += std::chrono::hours(1);
69+
}
70+
}
71+
if (isUnavailable) {
72+
reasons.push_back("Also Unavailable");
73+
}
74+
75+
// Check Consecutive Stints
76+
int consecutiveCount = 1;
77+
for (int k = stintIndex - 1; k >= 0; --k) {
78+
if (driverAssignments[candidate.name].count(k)) consecutiveCount++;
79+
else break;
80+
}
81+
for (size_t k = stintIndex + 1; k < input.stints.size(); ++k) {
82+
if (driverAssignments[candidate.name].count((int)k)) consecutiveCount++;
83+
else break;
84+
}
85+
86+
if (consecutiveCount > input.consecutiveStints) {
87+
reasons.push_back("Max Consecutive Limit (" + std::to_string(consecutiveCount) + "/" + std::to_string(input.consecutiveStints) + ")");
88+
}
89+
90+
// Check Minimum Rest
91+
if (input.minimumRestHours > 0) {
92+
double minRestSec = input.minimumRestHours * 3600.0;
93+
for (int assignedS : driverAssignments[candidate.name]) {
94+
auto s1_start = TimeHelpers::stringToTimePoint(input.stints[stintIndex].startTime);
95+
auto s1_end = TimeHelpers::stringToTimePoint(input.stints[stintIndex].endTime);
96+
auto s2_start = TimeHelpers::stringToTimePoint(input.stints[assignedS].startTime);
97+
auto s2_end = TimeHelpers::stringToTimePoint(input.stints[assignedS].endTime);
98+
99+
double gap = 0.0;
100+
if (s1_end <= s2_start) gap = std::chrono::duration<double>(s2_start - s1_end).count();
101+
else if (s2_end <= s1_start) gap = std::chrono::duration<double>(s1_start - s2_end).count();
102+
else gap = -1.0;
103+
104+
if (gap < minRestSec - 1.0) {
105+
double needed = (minRestSec - gap) / 3600.0;
106+
std::ostringstream rss;
107+
rss << std::fixed << std::setprecision(1) << " Needs " << needed << "h more rest";
108+
reasons.push_back(rss.str());
109+
break;
110+
}
111+
}
112+
}
113+
114+
// Check Max Busy Time
115+
if (input.maximumBusyHours > 0) {
116+
double busyDuration = getDuration(stintIndex);
117+
for (int k = stintIndex - 1; k >= 0; --k) {
118+
if (driverAssignments[candidate.name].count(k)) busyDuration += getDuration(k);
119+
else break;
120+
}
121+
for (size_t k = stintIndex + 1; k < input.stints.size(); ++k) {
122+
if (driverAssignments[candidate.name].count((int)k)) busyDuration += getDuration(k);
123+
else break;
124+
}
125+
if (busyDuration > input.maximumBusyHours) {
126+
reasons.push_back("Max Busy Time Exceeded (" + std::to_string(busyDuration) + "h > " + std::to_string(input.maximumBusyHours) + "h)");
127+
}
128+
}
129+
130+
if (reasons.empty()) {
131+
ss << "Available (Unknown constraint or softer optimization preference)";
132+
} else {
133+
for (size_t i=0; i<reasons.size(); ++i) {
134+
if (i > 0) ss << ", ";
135+
ss << reasons[i];
136+
}
137+
}
138+
}
139+
return ss.str();
140+
}
141+
142+
/**
143+
* @brief Helper to format a list of stint indices into a human-readable string using IDs.
144+
*/
145+
std::string formatStintList(const std::vector<int>& indices, const jres::internal::SolverInput& input) {
146+
if (indices.empty()) return "";
147+
148+
std::vector<int> sorted = indices;
149+
std::sort(sorted.begin(), sorted.end());
150+
151+
std::ostringstream ss;
152+
for (size_t i = 0; i < sorted.size(); ++i) {
153+
int startIdx = sorted[i];
154+
int endIdx = startIdx;
155+
while (i + 1 < sorted.size() && sorted[i + 1] == endIdx + 1) {
156+
endIdx = sorted[++i];
157+
}
158+
159+
if (ss.tellp() > 0) ss << ", ";
160+
if (startIdx == endIdx) ss << input.stints[startIdx].id;
161+
else ss << input.stints[startIdx].id << "-" << input.stints[endIdx].id;
162+
}
163+
return ss.str();
164+
}
165+
166+
std::vector<std::string> formatHumanDiagnostic(
167+
const std::map<int, jres::internal::SlackInfo>& slackInfo,
168+
const std::set<int>& unavailableVars,
169+
const std::map<std::pair<std::string, int>, int>& driverWorkVars,
170+
const std::map<std::pair<std::string, int>, int>& spotterWorkVars,
171+
const std::vector<double>& colValues,
172+
const jres::internal::SolverInput& input,
173+
const std::vector<jres::internal::TeamMember>& driverPool,
174+
const std::vector<jres::internal::TeamMember>& spotterPool)
175+
{
176+
using namespace jres::internal;
177+
std::vector<std::string> report;
178+
179+
// --- Roster Health Check (Drivers) ---
180+
if (!driverPool.empty()) {
181+
auto cap = CapacityAnalyzer::calculate_max_potential_capacity(driverPool, input);
182+
if (cap.totalCapacity < (int)input.stints.size()) {
183+
std::ostringstream ss;
184+
ss << "Driver Roster Understaffed: You have " << cap.totalCapacity
185+
<< " stints of coverage for a " << input.stints.size() << " stint race.";
186+
report.push_back(ss.str());
187+
}
188+
}
189+
190+
// --- Collect Violations per Stint ---
191+
struct ViolationKey {
192+
int priority;
193+
std::string reason;
194+
std::string member;
195+
196+
bool operator<(const ViolationKey& other) const {
197+
return std::tie(priority, reason, member) < std::tie(other.priority, other.reason, other.member);
198+
}
199+
};
200+
201+
std::map<ViolationKey, std::vector<int>> groupedViolations;
202+
std::vector<std::string> globalViolations;
203+
204+
// Build reverse map for variable index -> (Member, Stint, Role)
205+
std::map<int, std::tuple<std::string, int, std::string>> varToInfo;
206+
207+
for(const auto& [key, varIdx] : driverWorkVars) {
208+
varToInfo[varIdx] = std::make_tuple(key.first, key.second, "Driver");
209+
}
210+
for(const auto& [key, varIdx] : spotterWorkVars) {
211+
varToInfo[varIdx] = std::make_tuple(key.first, key.second, "Spotter");
212+
}
213+
214+
// Check Unavailable (Priority 0)
215+
for (int varIdx : unavailableVars) {
216+
if (varIdx < (int)colValues.size() && colValues[varIdx] > 0.5) {
217+
if (varToInfo.count(varIdx)) {
218+
auto [member, stintIdx, role] = varToInfo[varIdx];
219+
std::string reason = std::string("No one could ") + (role == "Spotter" ? "spot" : "drive") +
220+
" without being Unavailable (" + member + ")";
221+
groupedViolations[{0, reason, member}].push_back(stintIdx);
222+
}
223+
}
224+
}
225+
226+
// Check Slack (Rest, Busy, etc)
227+
for (const auto& [varIdx, info] : slackInfo) {
228+
if (varIdx < (int)colValues.size() && colValues[varIdx] > 0.001) {
229+
int priority = 3;
230+
if (info.type.find("Busy") != std::string::npos) priority = 1;
231+
else if (info.type.find("Rest") != std::string::npos) priority = 2;
232+
else if (info.type.find("Fair Share") != std::string::npos) priority = 2;
233+
234+
if (info.stintIndex >= 0) {
235+
std::string reason;
236+
if (priority == 1) reason = "Max Busy Time exceeded (" + info.memberName + ")";
237+
else if (priority == 2) {
238+
if (info.type.find("Fair Share") != std::string::npos) reason = "Fair Share Rules violated (" + info.memberName + ")";
239+
else reason = "Rest Rules violated (" + info.memberName + ")";
240+
}
241+
else reason = info.type + " (" + info.memberName + ")";
242+
243+
groupedViolations[{priority, reason, info.memberName}].push_back(info.stintIndex);
244+
} else {
245+
// Try to attribute global violation to stints
246+
bool assignedAny = false;
247+
for(const auto& [vIdx, tupleInfo] : varToInfo) {
248+
auto [memName, stintIdx, role] = tupleInfo;
249+
if (memName == info.memberName && colValues[vIdx] > 0.5) {
250+
std::string reason;
251+
if (priority == 1) reason = "Max Busy Time exceeded (" + memName + ")";
252+
else if (priority == 2) {
253+
if (info.type.find("Fair Share") != std::string::npos) reason = "Fair Share Rules violated (" + memName + ")";
254+
else reason = "Rest Rules violated (" + memName + ")";
255+
}
256+
else reason = info.type + " (" + memName + ")";
257+
258+
groupedViolations[{priority, reason, memName}].push_back(stintIdx);
259+
assignedAny = true;
260+
}
261+
}
262+
263+
if (!assignedAny) {
264+
std::string reason = info.type;
265+
if (priority == 2) {
266+
if (info.type.find("Fair Share") != std::string::npos)
267+
reason = "Fair Share Rules violated";
268+
else
269+
reason = "Rest Rules violated";
270+
}
271+
globalViolations.push_back(reason + " (" + info.memberName + ")");
272+
}
273+
}
274+
}
275+
}
276+
277+
// --- Sort & Format Grouped Violations ---
278+
std::vector<ViolationKey> sortedKeys;
279+
for(const auto& [key, _] : groupedViolations) sortedKeys.push_back(key);
280+
std::sort(sortedKeys.begin(), sortedKeys.end());
281+
282+
for (const auto& key : sortedKeys) {
283+
const auto& stints = groupedViolations[key];
284+
std::string stintStr = formatStintList(stints, input);
285+
286+
std::ostringstream ss;
287+
if (stints.size() == 1) ss << "Stint " << stintStr << ": " << key.reason;
288+
else ss << "Stints " << stintStr << ": " << key.reason;
289+
report.push_back(ss.str());
290+
291+
// Actionable Advice
292+
if (key.priority == 0) {
293+
report.push_back(" -> Advice: Mark " + key.member + " as Available during this window.");
294+
} else if (key.priority == 1) {
295+
report.push_back(" -> Advice: Increase 'Maximum Busy Hours' (currently " + std::to_string(input.maximumBusyHours) + "h) or add more participants.");
296+
} else if (key.priority == 2) {
297+
if (key.reason.find("Fair Share") != std::string::npos)
298+
report.push_back(" -> Advice: Ensure " + key.member + " is Available for more stints to meet the minimum driving time.");
299+
else
300+
report.push_back(" -> Advice: Reduce 'Minimum Rest Hours' (currently " + std::to_string(input.minimumRestHours) + "h) or increase availability of other participants to cover the gap.");
301+
}
302+
}
303+
304+
// Append Global Violations (Unattributed)
305+
for (const auto& g : globalViolations) {
306+
report.push_back("Global Violation: " + g);
307+
}
308+
309+
return report;
310+
}
311+
312+
} // namespace jres::analysis
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#pragma once
2+
3+
#include "../jres_internal_types.hpp"
4+
#include <vector>
5+
#include <map>
6+
#include <string>
7+
#include <set>
8+
9+
namespace jres::analysis {
10+
11+
/**
12+
* @brief Analyzes why a specific driver was not assigned to a stint, analyzing availability,
13+
* constraints, and other restrictions.
14+
*
15+
* @param stintIndex The index of the stint to analyze.
16+
* @param violationDriver The driver who was invalidly assigned (slack violation).
17+
* @param input The solver input configuration and data.
18+
* @param driverPool The list of available drivers.
19+
* @param driverWorkVars Map of (DriverName, StintIndex) to column index in the MIP model.
20+
* @param colValues The solution column values from the solver.
21+
* @return A detailed string explanation of why other drivers were not suitable candidates.
22+
*/
23+
std::string explain_assignment_failure(
24+
int stintIndex,
25+
const std::string& violationDriver,
26+
const jres::internal::SolverInput& input,
27+
const std::vector<jres::internal::TeamMember>& driverPool,
28+
const std::map<std::pair<std::string, int>, int>& driverWorkVars,
29+
const std::vector<double>& colValues
30+
);
31+
32+
/**
33+
* @brief Formats raw solver slack and violation information into a human-readable report.
34+
*/
35+
std::vector<std::string> formatHumanDiagnostic(
36+
const std::map<int, jres::internal::SlackInfo>& slackInfo,
37+
const std::set<int>& unavailableVars,
38+
const std::map<std::pair<std::string, int>, int>& driverWorkVars,
39+
const std::map<std::pair<std::string, int>, int>& spotterWorkVars,
40+
const std::vector<double>& colValues,
41+
const jres::internal::SolverInput& input,
42+
const std::vector<jres::internal::TeamMember>& driverPool,
43+
const std::vector<jres::internal::TeamMember>& spotterPool
44+
);
45+
46+
} // namespace jres::analysis

src/constraints/minimum_rest.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
namespace jres::constraints {
77

8-
static const double kPenaltySlack = 1000000.0;
8+
static const double kPenaltySlack = 100000.0;
99

1010
void apply_minimum_rest_constraints(
1111
Highs &highs,

0 commit comments

Comments
 (0)