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
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
51 changes: 42 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,34 @@ 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

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. <br> **Integrated Mode:** Applies to combined Driving and Spotting time. <br> **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. |
Expand All @@ -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. <br> **Integrated Mode:** Applies to combined Driving and Spotting time. <br> **Sequential Mode:** Applies only to Driving. |
| `tzOffset` | `double` | Timezone offset in hours from UTC. |

`JresStint`
Expand Down Expand Up @@ -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 |
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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. <br> **Integrated Mode:** Applies to combined Driving and Spotting time. <br> **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). |
Expand All @@ -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. <br> **Integrated Mode:** Applies to combined Driving and Spotting time. <br> **Sequential Mode:** Applies only to Driving. |
| `tzOffset` | Number | `0.0` | Timezone offset in hours from UTC. |

#### Availability Map & Time Formatting
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. | |

Expand Down
6 changes: 3 additions & 3 deletions cmd/formatter/cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>();
} else {
// 2. Auto-detect from extension
// Auto-detect from extension
std::string ext = get_extension(output_path);
if (ext == ".csv") {
format = "csv";
Expand All @@ -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 <zip|csv|txt> or use a standard file extension." << std::endl;
Expand Down
23 changes: 17 additions & 6 deletions cmd/solver/cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>())
("o,output", "Optional. Path to save the schedule as a JSON file.", cxxopts::value<std::string>())
("t,time-limit", "Maximum time in seconds to let the solver run.", cxxopts::value<int>()->default_value("5"))
("q,quiet", "Suppress INFO logs and final schedule print-out.", cxxopts::value<bool>()->default_value("false"))
("s,spotter-mode", "Method for scheduling spotters (none, integrated, sequential).", cxxopts::value<std::string>()->default_value("none"))
("allow-no-spotter", "Allow stints to have no spotter assigned.", cxxopts::value<bool>()->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<double>()->default_value("0.2"))
("d,diagnose", "Run diagnostics to explain why a schedule is infeasible.", cxxopts::value<bool>()->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<int>()->default_value("5"))
("d,diagnose", "Run diagnostics to explain why a schedule is infeasible.", cxxopts::value<bool>()->default_value("false"))
("s,spotter-mode", "Method for scheduling spotters (none, integrated, sequential).", cxxopts::value<std::string>()->default_value("none"))
("allow-no-spotter", "Allow stints to have no spotter assigned.", cxxopts::value<bool>()->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<double>()->default_value("0.2"));

options.add_options("Advanced Optimization")
("switching-penalty", "Penalty cost for switching drivers (positive disincentivizes switching).", cxxopts::value<double>()->default_value("0.0"))
("role-coupling-weight", "Reward weight for coupling roles (positive incentivizes driver->spotter).", cxxopts::value<double>()->default_value("0.0"))
("rotation-beat-weight", "Penalty weight for rotation deviation (positive incentivizes adherence).", cxxopts::value<double>()->default_value("0.0"));

auto result = options.parse(argc, argv);

if (result.count("version"))
Expand Down Expand Up @@ -113,6 +121,9 @@ int main(int argc, char **argv)

solverOptions.allowNoSpotter = result["allow-no-spotter"].as<bool>();
solverOptions.optimalityGap = result["optimality-gap"].as<double>();
solverOptions.switchingPenalty = result["switching-penalty"].as<double>();
solverOptions.roleCouplingWeight = result["role-coupling-weight"].as<double>();
solverOptions.rotationBeatWeight = result["rotation-beat-weight"].as<double>();

// Call the Solver Library
JresSolverInput* solverInput = jres_input_from_json(raceDataJsonString.c_str());
Expand Down
28 changes: 9 additions & 19 deletions data/24h_race.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -368,4 +358,4 @@
}
],
"firstStintDriver": "James"
}
}
22 changes: 7 additions & 15 deletions data/no_solution.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
14 changes: 5 additions & 9 deletions data/short_race.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Loading