diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2c666a1..ec3c702 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -161,6 +161,9 @@ add_library(jres_solver_lib
src/jres_internal_types.cpp
src/jres_solver_base.cpp
src/jres_standard_solver.cpp
+ src/analysis/capacity_analyzer.cpp
+ src/constraints/balancing.cpp
+ src/constraints/minimum_rest.cpp
)
set_target_properties(jres_solver_lib PROPERTIES OUTPUT_NAME "jres_solver")
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1818962..b3baab7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,11 +15,12 @@ It has been structured as a C-API library (`jres_solver`) and a simple CLI clien
| └── jres_solver/ # The public C-API header for the library
├── src/ # The C++ library implementation
| ├── jres_solver.cpp # C-API Wrapper and Orchestrator
-| ├── jres_solver_base.cpp # Shared logic for both solvers
-| ├── jres_standard_solver.cpp # Optimized Strict Solver
-| ├── jres_diagnostic_solver.cpp # Relaxed Diagnostic Solver
+| ├── jres_solver_base.cpp # Shared logic for the solver
+| ├── jres_standard_solver.cpp # Standard Solver (Elastic/Diagnostic enabled)
| ├── jres_internal_types.cpp # Internal C++ data structures
| ├── jres_json_converter.cpp # JSON conversion logic
+| ├── analysis/ # Analysis logic (e.g. capacity)
+| ├── constraints/ # Constraint implementations
| ├── formatter/ # Formatter implementation
| └── utils/ # Utility functions
├── lib/
diff --git a/README.md b/README.md
index f24fe28..58cd552 100644
--- a/README.md
+++ b/README.md
@@ -10,9 +10,21 @@ This library can be used to solve for optimal driver and spotter schedules for e
* **[Tools](./TOOLS.md)** - releases include some command line tools that use the library
* **[Development](./CONTRIBUTING.md)** - instructions for development of the library
+## CLI Quick Start
+
+The `jres_solver` tool supports several optimization parameters:
+
+* **General:** `-i` (Input), `-o` (Output), `-t` (Time Limit), `-s` (Spotter Mode)
+* **Advanced Weights:**
+ * `--switching-penalty`: Cost for driver swaps (positive disincentivizes switching)
+ * `--role-coupling-weight`: Incentive for role coupling (positive incentivizes coupling)
+ * `--rotation-beat-weight`: Penalty for fairness deviation (positive incentivizes adherence)
+
+See [TOOLS.md](./TOOLS.md) for full usage.
+
## The Library
-**JresSolver** is a C++ library designed to optimize endurance racing schedules. It uses the **HiGHS** Mixed Integer Programming (MIP) solver to assign drivers (and optional spotters) to race stints while satisfying constraints such as fuel usage, maximum drive times, minimum rest periods, and driver availability.
+**JresSolver** is a C++ library designed to optimize endurance racing schedules. It uses the **HiGHS** Mixed Integer Programming (MIP) solver to assign drivers (and optional spotters) to race stints while satisfying constraints such as fuel usage, maximum drive times, minimum rest periods, and driver availability. The library utilizes a modular constraint architecture for flexibility and extensibility.
### Data Structures
@@ -20,10 +32,12 @@ The C-API uses the following structs to pass data to and from the solver.
#### Input Structures
-`JresSolverInput` is the main input struct. It contains arrays of the other input structs.
+`JresSolverInput` is the main input struct. It contains arrays of the other input structs and global constraints.
| Field | Type | Description |
| :--- | :--- | :--- |
+| `consecutiveStints` | `int` | Hard constraint: Required number of consecutive stints a driver must perform (block size). |
+| `minimumRestHours` | `int` | Hard constraint: Minimum contiguous rest time required once per race.
**Integrated Mode:** Applies to combined Driving and Spotting time.
**Sequential Mode:** Applies only to Driving. |
| `teamMembers` | `JresTeamMember*` | A pointer to an array of team members. |
| `teamMembers_len` | `int` | The number of team members. |
| `availability` | `JresMemberAvailability*` | A pointer to an array of availability information. |
@@ -38,8 +52,6 @@ The C-API uses the following structs to pass data to and from the solver.
| `name` | `const char*` | Unique identifier for the member. |
| `isDriver` | `int` | `1` if the member can drive, `0` otherwise. |
| `isSpotter` | `int` | `1` if the member can spot, `0` otherwise. |
-| `maxStints`| `int` | Hard constraint: Maximum number of consecutive stints a member can perform. |
-| `minimumRestHours` | `int` | Hard constraint: Minimum contiguous rest time required once per race.
**Integrated Mode:** Applies to combined Driving and Spotting time.
**Sequential Mode:** Applies only to Driving. |
| `tzOffset` | `double` | Timezone offset in hours from UTC. |
`JresStint`
@@ -78,6 +90,18 @@ These structs are used to represent the availability of team members.
| `teamMembers` | `JresTeamMember*` | A pointer to an array of team members, including their tzOffset. |
| `teamMembers_len` | `int` | The number of team members. |
+`JresSolverOptions`
+
+| Field | Type | Description |
+| :--- | :--- | :--- |
+| `timeLimit` | `int` | Maximum time in seconds to let the solver run. |
+| `spotterMode` | `JresSpotterMode` | Type of spotter scheduling to use (`NONE`, `INTEGRATED`, `SEQUENTIAL`). |
+| `allowNoSpotter` | `bool` | Allow stints to have no spotter assigned. |
+| `optimalityGap` | `double` | Solver stops when the gap to optimal is less than this (e.g., `0.2`). |
+| `switchingPenalty`| `double` | Penalty applied when switching drivers between stints (default: `0.0`). |
+| `roleCouplingWeight`| `double` | Weight for coupling driver and spotter roles (default: `0.0`). |
+| `rotationBeatWeight`| `double` | Weight for adhering to a rotation beat or fairness metric (default: `0.0`). |
+
`JresScheduleEntry`
| Field | Type | Description |
@@ -99,6 +123,7 @@ options.timeLimit = 5;
options.spotterMode = JRES_SPOTTER_MODE_INTEGRATED;
options.allowNoSpotter = false;
options.optimalityGap = 0.2;
+options.switchingPenalty = 10.0; // Encourage longer driver shifts
// Create input struct from JSON
JresSolverInput* input = jres_input_from_json(raceDataJson);
@@ -140,6 +165,14 @@ Mixed Integer Programming problems like race scheduling are NP-hard. The solver
The solver prioritizes hard constraints (rest times, fuel, availability) first. The optimality gap only affects soft preferences like minimizing consecutive stints. A 20% gap on these preferences is imperceptible in real-world use.
+#### Switching Penalty
+
+The `switchingPenalty` option adds a cost to the optimization objective every time the driver changes between two consecutive stints.
+
+* **Goal**: To encourage the solver to keep the same driver in the car for multiple stints (e.g., doing a "double stint"), even if it's not strictly required by the `consecutiveStints` constraint.
+* **Relationship to `consecutiveStints`**: `consecutiveStints` is a **hard constraint** (the solver *must* schedule blocks of this size). The `switchingPenalty` is a **soft incentive** that applies at the boundaries of these blocks to discourage swapping drivers even when a block ends.
+* **Recommended Value**: Start with `10.0`. If the solver still switches drivers too often for your preference, increase it. If it starts violating preferred availability slots just to avoid a switch, decrease it.
+
-----
### JSON Helper Functions
@@ -160,6 +193,8 @@ The `raceDataJson` string passed to `jres_input_from_json` must strictly follow
| Field | Type | Required | Description |
| :--- | :--- | :--- | :--- |
+| `consecutiveStints` | Integer | No (Default `1`) | Hard constraint: Required number of consecutive stints a driver must perform (block size). |
+| `minimumRestHours` | Integer | No (Default `0`) | Hard constraint: Minimum contiguous rest time required once per race.
**Integrated Mode:** Applies to combined Driving and Spotting time.
**Sequential Mode:** Applies only to Driving. |
| `teamMembers` | Array | Yes | List of drivers and spotters (see below). |
| `availability` | Object | Yes | Map of availability constraints (see below). |
| `stints` | Array | Yes | List of pre-defined race stints (see below). |
@@ -179,8 +214,6 @@ The `raceDataJson` string passed to `jres_input_from_json` must strictly follow
| `name` | String | **Required** | Unique identifier for the member. |
| `isDriver` | Boolean | `true` | Can this member drive? |
| `isSpotter` | Boolean | `false` | Can this member spot? |
-| `maxStints` | Integer| `1` | Hard constraint: Maximum number of consecutive stints a member can perform. |
-| `minimumRestHours` | Integer| `0` | Hard constraint: Minimum contiguous rest time required once per race.
**Integrated Mode:** Applies to combined Driving and Spotting time.
**Sequential Mode:** Applies only to Driving. |
| `tzOffset` | Number | `0.0` | Timezone offset in hours from UTC. |
#### Availability Map & Time Formatting
@@ -203,13 +236,13 @@ The `availability` object maps a **Team Member's Name** to a dictionary of **Tim
```json
{
+ "consecutiveStints": 2,
+ "minimumRestHours": 4,
"teamMembers": [
{
"name": "Niki",
"isDriver": true,
- "isSpotter": true,
- "maxStints": 2,
- "minimumRestHours": 4
+ "isSpotter": true
},
{
"name": "Alain",
diff --git a/TOOLS.md b/TOOLS.md
index 52782e2..14e966e 100644
--- a/TOOLS.md
+++ b/TOOLS.md
@@ -53,6 +53,9 @@ jres_solver.exe [options]
| `-s` | `--spotter-mode` | Strategy for assigning spotters. Options: `none`, `integrated`, `sequential`. | `none` |
| | `--allow-no-spotter` | Allow specific stints to have no spotter assigned (if spotter mode is active). | `false` |
| `-g` | `--optimality-gap` | Stop solver when the solution is within this gap of perfection (e.g., `0.2` for 20%). | `0.2` |
+| | `--switching-penalty`| Penalty cost for switching drivers (positive disincentivizes switching). | `0.0` |
+| | `--role-coupling-weight`| Reward weight for coupling roles (positive incentivizes driver->spotter). | `0.0` |
+| | `--rotation-beat-weight`| Penalty weight for rotation deviation (positive incentivizes adherence). | `0.0` |
| `-d` | `--diagnose` | Run in **Diagnostic Mode** to explain why a schedule is infeasible. | `false` |
| `-h` | `--help` | Print usage instructions. | |
diff --git a/cmd/formatter/cli.cpp b/cmd/formatter/cli.cpp
index ae0b2a5..b4fbc07 100644
--- a/cmd/formatter/cli.cpp
+++ b/cmd/formatter/cli.cpp
@@ -71,10 +71,10 @@ int main(int argc, char* argv[]) {
// --- Logic: Determine Format ---
if (result.count("format")) {
- // 1. Explicitly provided by user
+ // Explicitly provided by user
format = result["format"].as();
} else {
- // 2. Auto-detect from extension
+ // Auto-detect from extension
std::string ext = get_extension(output_path);
if (ext == ".csv") {
format = "csv";
@@ -83,7 +83,7 @@ int main(int argc, char* argv[]) {
} else if (ext == ".zip") {
format = "zip";
} else {
- // 3. Unknown extension and no flag -> Error
+ // Unknown extension and no flag -> Error
std::cerr << "Error: Could not determine output format from filename extension ("
<< ext << ")." << std::endl;
std::cerr << "Please explicitly specify format using -f or use a standard file extension." << std::endl;
diff --git a/cmd/solver/cli.cpp b/cmd/solver/cli.cpp
index 6e9de82..1b70a2c 100644
--- a/cmd/solver/cli.cpp
+++ b/cmd/solver/cli.cpp
@@ -27,18 +27,26 @@ int main(int argc, char **argv)
{
// --- Parse Command-Line Arguments ---
cxxopts::Options options("solver", "JRES endurance race solver.");
- options.add_options()
+
+ options.add_options("General")
("i,input", "Path to the race data .json file. Reads from stdin if not provided.", cxxopts::value())
("o,output", "Optional. Path to save the schedule as a JSON file.", cxxopts::value())
- ("t,time-limit", "Maximum time in seconds to let the solver run.", cxxopts::value()->default_value("5"))
("q,quiet", "Suppress INFO logs and final schedule print-out.", cxxopts::value()->default_value("false"))
- ("s,spotter-mode", "Method for scheduling spotters (none, integrated, sequential).", cxxopts::value()->default_value("none"))
- ("allow-no-spotter", "Allow stints to have no spotter assigned.", cxxopts::value()->default_value("false"))
- ("g,optimality-gap", "Solver stops when the gap to optimal is less than this (e.g., 0.2 for 20%).", cxxopts::value()->default_value("0.2"))
- ("d,diagnose", "Run diagnostics to explain why a schedule is infeasible.", cxxopts::value()->default_value("false"))
("v,version", "Print version information and exit.")
("h,help", "Print usage.");
+ options.add_options("Solver Configuration")
+ ("t,time-limit", "Maximum time in seconds to let the solver run.", cxxopts::value()->default_value("5"))
+ ("d,diagnose", "Run diagnostics to explain why a schedule is infeasible.", cxxopts::value()->default_value("false"))
+ ("s,spotter-mode", "Method for scheduling spotters (none, integrated, sequential).", cxxopts::value()->default_value("none"))
+ ("allow-no-spotter", "Allow stints to have no spotter assigned.", cxxopts::value()->default_value("false"))
+ ("g,optimality-gap", "Solver stops when the gap to optimal is less than this (e.g., 0.2 for 20%).", cxxopts::value()->default_value("0.2"));
+
+ options.add_options("Advanced Optimization")
+ ("switching-penalty", "Penalty cost for switching drivers (positive disincentivizes switching).", cxxopts::value()->default_value("0.0"))
+ ("role-coupling-weight", "Reward weight for coupling roles (positive incentivizes driver->spotter).", cxxopts::value()->default_value("0.0"))
+ ("rotation-beat-weight", "Penalty weight for rotation deviation (positive incentivizes adherence).", cxxopts::value()->default_value("0.0"));
+
auto result = options.parse(argc, argv);
if (result.count("version"))
@@ -113,6 +121,9 @@ int main(int argc, char **argv)
solverOptions.allowNoSpotter = result["allow-no-spotter"].as();
solverOptions.optimalityGap = result["optimality-gap"].as();
+ solverOptions.switchingPenalty = result["switching-penalty"].as();
+ solverOptions.roleCouplingWeight = result["role-coupling-weight"].as();
+ solverOptions.rotationBeatWeight = result["rotation-beat-weight"].as();
// Call the Solver Library
JresSolverInput* solverInput = jres_input_from_json(raceDataJsonString.c_str());
diff --git a/data/24h_race.json b/data/24h_race.json
index c2eb5bb..bcfbed3 100644
--- a/data/24h_race.json
+++ b/data/24h_race.json
@@ -1,53 +1,43 @@
{
"success": true,
+ "consecutiveStints": 2,
+ "minimumRestHours": 6,
"teamMembers": [
{
"name": "Niki",
"isDriver": true,
"isSpotter": true,
- "tzOffset": 1,
- "maxStints": 1,
- "minimumRestHours": 6
+ "tzOffset": 1
},
{
"name": "Ayrton",
"isDriver": true,
"isSpotter": false,
- "tzOffset": -3,
- "maxStints": 2,
- "minimumRestHours": 6
+ "tzOffset": -3
},
{
"name": "Jack",
"isDriver": true,
"isSpotter": true,
- "tzOffset": 11,
- "maxStints": 3,
- "minimumRestHours": 6
+ "tzOffset": 11
},
{
"name": "James",
"isDriver": true,
"isSpotter": true,
- "tzOffset": 0,
- "maxStints": 2,
- "minimumRestHours": 6
+ "tzOffset": 0
},
{
"name": "Mario",
"isDriver": true,
"isSpotter": true,
- "tzOffset": -5,
- "maxStints": 1,
- "minimumRestHours": 6
+ "tzOffset": -5
},
{
"name": "Ricky",
"isDriver": false,
"isSpotter": true,
- "tzOffset": -6,
- "maxStints": 2,
- "minimumRestHours": 6
+ "tzOffset": -6
}
],
"availability": {
@@ -368,4 +358,4 @@
}
],
"firstStintDriver": "James"
-}
+}
\ No newline at end of file
diff --git a/data/no_solution.json b/data/no_solution.json
index 2a50b04..cedb9e0 100644
--- a/data/no_solution.json
+++ b/data/no_solution.json
@@ -1,39 +1,31 @@
{
+ "consecutiveStints": 2,
+ "minimumRestHours": 0,
"teamMembers": [
{
"name": "Brandon",
"isDriver": true,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 0
+ "isSpotter": true
},
{
"name": "Cesar",
"isDriver": true,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 0
+ "isSpotter": true
},
{
"name": "Harvey",
"isDriver": true,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 0
+ "isSpotter": true
},
{
"name": "Jay",
"isDriver": true,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 0
+ "isSpotter": true
},
{
"name": "Jack",
"isDriver": true,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 0
+ "isSpotter": true
}
],
"availability": {
diff --git a/data/short_race.json b/data/short_race.json
index 61612e8..b5e1549 100644
--- a/data/short_race.json
+++ b/data/short_race.json
@@ -1,29 +1,25 @@
{
"success": true,
+ "consecutiveStints": 2,
+ "minimumRestHours": 0,
"teamMembers": [
{
"name": "Niki",
"tzOffset": 1,
"isDriver": true,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 0
+ "isSpotter": true
},
{
"name": "Ayrton",
"tzOffset": -3,
"isDriver": true,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 0
+ "isSpotter": true
},
{
"name": "Alain",
"tzOffset": 1,
"isDriver": false,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 0
+ "isSpotter": true
}
],
"availability": {
diff --git a/data/short_race_no_solution.json b/data/short_race_no_solution.json
index 5ec3f58..72827e8 100644
--- a/data/short_race_no_solution.json
+++ b/data/short_race_no_solution.json
@@ -1,29 +1,25 @@
{
"success": true,
+ "consecutiveStints": 2,
+ "minimumRestHours": 8,
"teamMembers": [
{
"name": "Niki",
"tzOffset": 1,
"isDriver": true,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 8
+ "isSpotter": true
},
{
"name": "Ayrton",
"tzOffset": -3,
"isDriver": true,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 8
+ "isSpotter": true
},
{
"name": "Alain",
"tzOffset": 1,
"isDriver": false,
- "isSpotter": true,
- "preferredStints": 2,
- "minimumRestHours": 8
+ "isSpotter": true
}
],
"availability": {
diff --git a/include/jres_solver/jres_solver.hpp b/include/jres_solver/jres_solver.hpp
index 234f29a..0125651 100644
--- a/include/jres_solver/jres_solver.hpp
+++ b/include/jres_solver/jres_solver.hpp
@@ -60,6 +60,12 @@ struct JresSolverOptions {
bool allowNoSpotter;
/** @brief The solver will terminate when the gap between the primal and dual objective bound is less than this value. */
double optimalityGap;
+ /** @brief Penalty applied when switching drivers between stints (default: 0.0). */
+ double switchingPenalty;
+ /** @brief Weight for coupling driver and spotter roles (e.g. proximity or same-person) (default: 0.0). */
+ double roleCouplingWeight;
+ /** @brief Weight for adhering to a rotation beat or fairness metric (default: 0.0). */
+ double rotationBeatWeight;
};
/**
@@ -72,10 +78,6 @@ struct JresTeamMember {
int isDriver;
/** @brief 1 if the member can spot, 0 otherwise. */
int isSpotter;
- /** @brief Maximum number of consecutive stints a member can perform. */
- int maxStints;
- /** @brief Minimum rest time in hours required after a shift. */
- int minimumRestHours;
/** @brief Timezone offset, in hours from UTC. */
double tzOffset;
};
@@ -118,6 +120,10 @@ struct JresMemberAvailability {
* @brief The main input struct for the solver.
*/
struct JresSolverInput {
+ /** @brief Required consecutive stints (drivers must do this many stints in a row, except potentially the last one). */
+ int consecutiveStints;
+ /** @brief Minimum rest time in hours required after a shift. */
+ int minimumRestHours;
/** @brief A pointer to an array of team members. */
JresTeamMember* teamMembers;
/** @brief The number of team members. */
diff --git a/src/analysis/capacity_analyzer.cpp b/src/analysis/capacity_analyzer.cpp
new file mode 100644
index 0000000..e72f25e
--- /dev/null
+++ b/src/analysis/capacity_analyzer.cpp
@@ -0,0 +1,115 @@
+#include "capacity_analyzer.hpp"
+#include
+#include
+#include
+
+namespace jres::internal {
+
+CapacityAnalysis CapacityAnalyzer::calculate_max_potential_capacity(
+ const std::vector& participants,
+ const SolverInput& input)
+{
+ // Parse stint times once
+ 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;
+ 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);
+
+ if(!raceTimesInit) {
+ raceStart = s;
+ raceEnd = e;
+ raceTimesInit = true;
+ } else {
+ if(s < raceStart) raceStart = s;
+ if(e > raceEnd) raceEnd = e;
+ }
+ }
+
+ CapacityAnalysis analysis;
+ analysis.totalCapacity = 0;
+ std::ostringstream ss;
+
+ for (const auto& p : participants) {
+ // Build Availability
+ std::vector is_available(input.stints.size(), true);
+ auto member_availability_it = input.availability.find(p.name);
+ if (member_availability_it != input.availability.end()) {
+ for (size_t s = 0; s < input.stints.size(); ++s) {
+ std::string key = TimeHelpers::timePointToKey(startTimes[s]);
+ auto time_it = member_availability_it->second.find(key);
+ if (time_it != member_availability_it->second.end() &&
+ time_it->second == Availability::Unavailable) {
+ is_available[s] = false;
+ }
+ }
+ }
+
+ std::vector planned_drive(input.stints.size(), false);
+ int base_capacity = 0;
+ double driver_total_hours = 0.0;
+
+ for(size_t s=0; s(endTimes[s] - startTimes[s]).count();
+ driver_total_hours += static_cast(duration_ms) / 3600000.0;
+ }
+ }
+
+ // Adjust for Global Minimum Rest (One Instance)
+ int final_capacity = base_capacity;
+ if (input.minimumRestHours > 0) {
+ auto minRestDuration = std::chrono::hours(input.minimumRestHours);
+ int min_loss = base_capacity;
+ bool found_valid_window = false;
+
+ 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;
+ if (tEnd > raceEnd) continue;
+ found_valid_window = true;
+
+ int current_loss = 0;
+ for(size_t s=0; s tStart) {
+ current_loss++;
+ }
+ }
+ }
+ if (current_loss < min_loss) min_loss = current_loss;
+ }
+
+ if (!found_valid_window) {
+ final_capacity = 0; // Impossible to satisfy rest
+ } else {
+ final_capacity -= min_loss;
+ }
+ }
+
+ analysis.totalCapacity += final_capacity;
+
+ ss << "\n- " << p.name << ": " << final_capacity
+ << " stints (approx " << std::fixed << std::setprecision(1) << driver_total_hours
+ << "h, MinRest=" << input.minimumRestHours << "h)";
+ }
+ analysis.details = ss.str();
+ return analysis;
+}
+
+} // namespace jres::internal
diff --git a/src/analysis/capacity_analyzer.hpp b/src/analysis/capacity_analyzer.hpp
new file mode 100644
index 0000000..8735d82
--- /dev/null
+++ b/src/analysis/capacity_analyzer.hpp
@@ -0,0 +1,19 @@
+#pragma once
+#include "../jres_internal_types.hpp"
+
+namespace jres::internal {
+
+ struct CapacityAnalysis {
+ int totalCapacity;
+ std::string details;
+ };
+
+ class CapacityAnalyzer {
+ public:
+ static CapacityAnalysis calculate_max_potential_capacity(
+ const std::vector& participants,
+ const SolverInput& input
+ );
+ };
+
+}
diff --git a/src/constraints/balancing.cpp b/src/constraints/balancing.cpp
new file mode 100644
index 0000000..fbef55a
--- /dev/null
+++ b/src/constraints/balancing.cpp
@@ -0,0 +1,95 @@
+#include "balancing.hpp"
+#include "Highs.h"
+#include
+
+namespace jres::constraints {
+
+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,
+ size_t numStints,
+ double weight)
+{
+ if (std::abs(weight) < 1e-6) return;
+
+ 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});
+
+ if (hasDriver && hasSpotter) {
+ int d_var = driverVars.at({p.name, (int)s});
+ int s_var = spotterVars.at({p.name, (int)s + 1});
+
+ // If there is a transition from driving (stint s) to spotting (stint s+1), reward it.
+
+ int coupling_var = highs->getNumCol();
+ highs->addVar(0.0, 1.0);
+ highs->changeColIntegrality(coupling_var, HighsVarType::kInteger);
+ highs->changeColCost(coupling_var, -weight);
+
+ // z <= d_var
+ highs->addRow(-kHighsInf, 0.0, 2, std::vector{coupling_var, d_var}.data(), std::vector{1.0, -1.0}.data());
+ // z <= s_var
+ highs->addRow(-kHighsInf, 0.0, 2, std::vector{coupling_var, s_var}.data(), std::vector{1.0, -1.0}.data());
+ }
+ }
+ }
+}
+
+void add_balancing_constraints(
+ Highs &highs,
+ const std::vector &participants,
+ const jres::internal::SolverInput& input,
+ const std::map, int>& workVars,
+ double avgStints)
+{
+ for (const auto &p : participants) {
+ std::vector stint_indices;
+ std::vector stint_values;
+
+ 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});
+ varCounts[v] += 1.0;
+ }
+ }
+ for(auto const& [v, count] : varCounts) {
+ stint_indices.push_back(v);
+ stint_values.push_back(count);
+ }
+
+ if (stint_indices.empty()) continue;
+
+ int total_stints_var = highs.getNumCol();
+ highs.addVar(0.0, kHighsInf);
+ stint_indices.push_back(total_stints_var);
+ stint_values.push_back(-1.0);
+ highs.addRow(0.0, 0.0, (int)stint_indices.size(), stint_indices.data(), stint_values.data());
+
+ int over_avg_var = highs.getNumCol();
+ highs.addVar(0.0, kHighsInf);
+ int under_avg_var = highs.getNumCol();
+ highs.addVar(0.0, kHighsInf);
+
+ std::vector idx_over = {over_avg_var, total_stints_var};
+ std::vector val_over = {1.0, -1.0};
+ highs.addRow(0.0, kHighsInf, 2, idx_over.data(), val_over.data());
+ highs.changeRowBounds(highs.getNumRow() - 1, -avgStints, kHighsInf);
+
+ std::vector idx_under = {under_avg_var, total_stints_var};
+ std::vector val_under = {1.0, 1.0};
+ highs.addRow(0.0, kHighsInf, 2, idx_under.data(), val_under.data());
+ highs.changeRowBounds(highs.getNumRow() - 1, avgStints, kHighsInf);
+
+ highs.changeColCost(over_avg_var, kCostFairness);
+ highs.changeColCost(under_avg_var, kCostFairness);
+ }
+}
+
+} // namespace jres::constraints
diff --git a/src/constraints/balancing.hpp b/src/constraints/balancing.hpp
new file mode 100644
index 0000000..dbca537
--- /dev/null
+++ b/src/constraints/balancing.hpp
@@ -0,0 +1,25 @@
+#pragma once
+#include "../jres_internal_types.hpp"
+#include