diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b3baab7..4f7ce84 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -135,9 +135,6 @@ The `jres_solver` executable is a client that uses the `jres_solver` library.
# Run with a file
./jres_solver -i ../data/short_race.json -s integrated
-# Pipe from stdin
-cat ../data/24h_race.json | ./jres_solver -s sequential --allow-no-spotter
-
# Run diagnostics on a failing schedule
./jres_solver -i ../data/infeasible.json --diagnose
diff --git a/GEMINI.md b/GEMINI.md
index 8a243a3..8bbfa29 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -1,3 +1,18 @@
+# ⚠️ CRITICAL AGENT INSTRUCTIONS ⚠️
+
+**Adhere strictly to the following mandates. Failure to do so is a critical error.**
+
+1. **NO GIT MODIFICATIONS:**
+ * **STATUS:** **STRICTLY READ-ONLY**.
+ * **FORBIDDEN COMMANDS:** `git add`, `git commit`, `git push`, `git merge`, `git rebase`, `git checkout` (for creating branches), `git stash`, and ANY other command that modifies the git index, history, or working tree.
+ * **ALLOWED COMMANDS:** `git status`, `git log`, `git diff`, `git show`.
+ * **ACTION:** If the user asks you to commit changes, **REFUSE** and remind them that you are an AI assistant without authority to alter the project's version control history. You may propose commit messages or explain what changed, but **DO NOT** execute the commands.
+
+2. **Verbosity:** Low. Do not explain the code unless asked. Just output the diff or the file.
+3. **Reasoning:** Perform deep reasoning internally, but output only the final solution.
+
+---
+
# Gemini Project: JRES Solver C++
This document provides instructions for understanding, building, and contributing to the JRES Solver C++ project.
@@ -64,6 +79,11 @@ The project uses GoogleTest for its test suite. To run the tests, execute the fo
ctest
```
+There is also a script to test the formatter's stdout functionality:
+```bash
+./test_formatter_stdout.sh
+```
+
### Running the CLI Tools
The compiled executables are located in the `build/` directory.
@@ -74,8 +94,8 @@ The compiled executables are located in the `build/` directory.
# Run with an input file
./jres_solver -i ../data/short_race.json -s integrated
-# Pipe data from stdin and output to a file
-cat ../data/24h_race.json | ./jres_solver -s sequential -o /tmp/24_race_solution.json
+# Run with an input file and output to a file
+./jres_solver -i ../data/24h_race.json -s sequential -o /tmp/24_race_solution.json
# Run diagnostics on an infeasible schedule
./jres_solver -i ../data/short_race_no_solution.json --diagnose
@@ -96,9 +116,4 @@ The formatter takes the JSON output from the solver and can generate different r
* **Dependencies:** C++ library dependencies are managed as Git submodules (`cxxopts`, `nlohmann/json`). The HiGHS solver is an external dependency.
* **Testing:** The test suite is built with GoogleTest and run via CTest. New tests should be added to the `test/` directory.
* **API Design:** The core logic is exposed as a C-API for wider compatibility. Helper functions are provided for JSON serialization and deserialization.
-* **Code Style:** The codebase is written in C++. Please follow the existing coding style when contributing.
-
-# MODEL INSTRUCTIONS
-- **Verbosity:** Low. Do not explain the code unless asked. Just output the diff or the file.
-- **Reasoning:** Perform deep reasoning internally, but output only the final solution.
-- **Git Operations:** READ-ONLY. You may use `git status`, `git log`, or `git diff` to understand the context. You must *NEVER* run `git add`, `git commit`, `git push`, or any command that modifies the git history or index. Leave all version control management to the user.
+* **Code Style:** The codebase is written in C++. Please follow the existing coding style when contributing.
\ No newline at end of file
diff --git a/README.md b/README.md
index 58cd552..b93c68f 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,6 @@
This library can be used to solve for optimal driver and spotter schedules for endurance racing events. It uses the **HiGHS** optimization library.
->[!NOTE]
->this is based on the python JRES Solver https://github.com/popmonkey/jres_solver
-
## Additional Documentation
* **[Tools](./TOOLS.md)** - releases include some command line tools that use the library
@@ -16,7 +13,6 @@ 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)
@@ -37,7 +33,8 @@ The C-API uses the following structs to pass data to and from the solver.
| 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. |
+| `minimumRestHours` | `int` | Hard constraint: Minimum contiguous rest time required once per race. |
+| `firstStintDriver` | `const char*` | Hard constraint: The name of the team member who must drive the first stint. |
| `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. |
@@ -98,7 +95,6 @@ These structs are used to represent the availability of team members.
| `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`). |
@@ -123,7 +119,6 @@ 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);
@@ -165,14 +160,6 @@ 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
@@ -194,7 +181,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. |
+| `minimumRestHours` | Integer | No (Default `0`) | Hard constraint: Minimum contiguous rest time required once per race. |
+| `firstStintDriver` | String | No | Hard constraint: The name of the team member who must drive the first stint. |
| `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). |
@@ -238,6 +226,7 @@ The `availability` object maps a **Team Member's Name** to a dictionary of **Tim
{
"consecutiveStints": 2,
"minimumRestHours": 4,
+ "firstStintDriver": "Niki",
"teamMembers": [
{
"name": "Niki",
@@ -333,6 +322,10 @@ When the solver fails, the `schedule` array will be empty, and the `diagnosis` a
}
```
+---
+>[!NOTE]
+>this is based on the python JRES Solver https://github.com/popmonkey/jres_solver
+
---
_Created by popmonkey, Gemini 2.5, Gemini 3.0, and ChatGPT 5.1_
diff --git a/TOOLS.md b/TOOLS.md
index 14e966e..01a2e14 100644
--- a/TOOLS.md
+++ b/TOOLS.md
@@ -3,7 +3,7 @@
This suite consists of two command-line tools designed to generate and format driver schedules for endurance racing events.
> [!NOTE]
-Currently the easiest way to generate input for the tools (and library) is to use [the JRES Availability Planner spreadsheet](https://docs.google.com/spreadsheets/d/1k2WaNDhXjyHXLirju2IQKxrYExQpDCT5z6jCBM71KVc/edit?usp=sharing)
+Currently the easiest way to generate input for the tools (and library) is to use [the JRES Availability Planner spreadsheet](https://docs.google.com/spreadsheets/d/1hayKeug7IdZqwmta68PlqOCc8Y1JaL-QeaVal_-APxA/edit?usp=sharing)
## Platform Support
@@ -39,21 +39,20 @@ jres_solver.exe [options]
### Input/Output Behavior
-* **Input:** Accepts a JSON file via the `-i` flag. If no input flag is provided, it reads from **Standard Input (stdin)**.
+* **Input:** Accepts a JSON file via the `-i` flag (Required).
* **Output:** Prints the schedule summary to **stdout** (unless `-q` is used). To save the raw solution for the Formatter, use the `-o` flag.
### Options
| Flag | Long Flag | Description | Default |
| :--- | :------------------- | :-------------------------------------------------------------------------------------------- | :------ |
-| `-i` | `--input` | Path to the race data `.json` file. Reads from `stdin` if omitted. | `stdin` |
+| `-i` | `--input` | Path to the race data `.json` file. (Required) | |
| `-o` | `--output` | Path to save the calculated schedule (JSON). **Required for the Formatter.** | `stdout`|
| `-t` | `--time-limit` | Maximum time (in seconds) to let the optimizer run. | `5` |
| `-q` | `--quiet` | Suppress INFO logs and the printed schedule summary. | `false` |
| `-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` |
@@ -113,16 +112,7 @@ For complex schedules, allow more time while maintaining a practical optimality
./jres_solver -i race_config.json -o solution.json -t 30 --optimality-gap 0.2
```
-**Pipeline Usage:**
-Pipe a JSON generator directly into the solver.
-```sh
-# Linux / macOS
-cat race_data.json | ./jres_solver -o solution.json
-
-# Windows PowerShell
-Get-Content race_data.json | .\jres_solver.exe -o solution.json
-```
---
diff --git a/cmd/formatter/cli.cpp b/cmd/formatter/cli.cpp
index b4fbc07..5cd5040 100644
--- a/cmd/formatter/cli.cpp
+++ b/cmd/formatter/cli.cpp
@@ -34,7 +34,7 @@ int main(int argc, char* argv[]) {
options.add_options()
("i,input", "Input JSON file (solved schedule)", cxxopts::value())
- ("o,output", "Output file path", cxxopts::value())
+ ("o,output", "Output file path (optional, defaults to stdout if omitted)", cxxopts::value())
("f,format", "Output format (zip, csv, txt). Auto-detected from output filename if omitted.", cxxopts::value())
("v,version", "Print version information and exit.")
("h,help", "Print usage")
@@ -60,13 +60,12 @@ int main(int argc, char* argv[]) {
return 1;
}
- if (!result.count("output")) {
- std::cerr << "Error: Output file is required (-o)" << std::endl;
- return 1;
- }
-
std::string input_path = result["input"].as();
- std::string output_path = result["output"].as();
+ std::string output_path = "";
+ if (result.count("output")) {
+ output_path = result["output"].as();
+ }
+
std::string format;
// --- Logic: Determine Format ---
@@ -74,22 +73,26 @@ int main(int argc, char* argv[]) {
// Explicitly provided by user
format = result["format"].as();
} else {
- // Auto-detect from extension
- std::string ext = get_extension(output_path);
- if (ext == ".csv") {
- format = "csv";
- } else if (ext == ".txt") {
+ if (output_path.empty()) {
format = "txt";
- } else if (ext == ".zip") {
- format = "zip";
} else {
- // 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;
- return 1;
+ // Auto-detect from extension
+ std::string ext = get_extension(output_path);
+ if (ext == ".csv") {
+ format = "csv";
+ } else if (ext == ".txt") {
+ format = "txt";
+ } else if (ext == ".zip") {
+ format = "zip";
+ } else {
+ // 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;
+ return 1;
+ }
+ std::cout << "Auto-detected format: " << format << std::endl;
}
- std::cout << "Auto-detected format: " << format << std::endl;
}
std::ifstream f(input_path);
@@ -113,7 +116,10 @@ int main(int argc, char* argv[]) {
// Call into our library
jres::write_output(solved_data, output_path, format);
- std::cout << "Successfully generated " << format << " output: " << output_path << std::endl;
+
+ if (!output_path.empty()) {
+ std::cout << "Successfully generated " << format << " output: " << output_path << std::endl;
+ }
} catch (const cxxopts::exceptions::exception& e) {
std::cerr << "Error parsing options: " << e.what() << std::endl;
diff --git a/cmd/solver/cli.cpp b/cmd/solver/cli.cpp
index 1b70a2c..8ecc393 100644
--- a/cmd/solver/cli.cpp
+++ b/cmd/solver/cli.cpp
@@ -29,7 +29,7 @@ int main(int argc, char **argv)
cxxopts::Options options("solver", "JRES endurance race solver.");
options.add_options("General")
- ("i,input", "Path to the race data .json file. Reads from stdin if not provided.", cxxopts::value())
+ ("i,input", "Path to the race data .json file. (Required)", cxxopts::value())
("o,output", "Optional. Path to save the schedule as a JSON file.", cxxopts::value())
("q,quiet", "Suppress INFO logs and final schedule print-out.", cxxopts::value()->default_value("false"))
("v,version", "Print version information and exit.")
@@ -43,7 +43,6 @@ int main(int argc, char **argv)
("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"));
@@ -61,6 +60,14 @@ int main(int argc, char **argv)
return 0;
}
+ // Input file is now required
+ if (!result.count("input"))
+ {
+ std::cerr << "Error: Input file is required." << std::endl;
+ std::cout << options.help() << std::endl;
+ return 1;
+ }
+
bool quiet = result["quiet"].as();
bool runDiagnostics = result["diagnose"].as();
@@ -72,28 +79,17 @@ int main(int argc, char **argv)
std::string raceDataJsonString;
try
{
- if (result.count("input"))
- {
- std::string inputPath = result["input"].as();
- if (!quiet)
- std::cout << "[App] Loading data from file: " << inputPath << std::endl;
- std::ifstream f(inputPath);
- if (!f.is_open())
- {
- throw std::runtime_error("Could not open input file: " + inputPath);
- }
- std::stringstream buffer;
- buffer << f.rdbuf();
- raceDataJsonString = buffer.str();
- }
- else
+ std::string inputPath = result["input"].as();
+ if (!quiet)
+ std::cout << "[App] Loading data from file: " << inputPath << std::endl;
+ std::ifstream f(inputPath);
+ if (!f.is_open())
{
- if (!quiet)
- std::cout << "[App] Loading data from stdin..." << std::endl;
- std::stringstream buffer;
- buffer << std::cin.rdbuf();
- raceDataJsonString = buffer.str();
+ throw std::runtime_error("Could not open input file: " + inputPath);
}
+ std::stringstream buffer;
+ buffer << f.rdbuf();
+ raceDataJsonString = buffer.str();
}
catch (const std::exception &e)
{
@@ -121,7 +117,6 @@ 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();
diff --git a/include/jres_solver/jres_solver.hpp b/include/jres_solver/jres_solver.hpp
index 0125651..8a0510b 100644
--- a/include/jres_solver/jres_solver.hpp
+++ b/include/jres_solver/jres_solver.hpp
@@ -60,9 +60,7 @@ 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). */
+ /** @brief Weight for coupling driver and spotter roles (integrated mode only). */
double roleCouplingWeight;
/** @brief Weight for adhering to a rotation beat or fairness metric (default: 0.0). */
double rotationBeatWeight;
@@ -124,6 +122,8 @@ struct JresSolverInput {
int consecutiveStints;
/** @brief Minimum rest time in hours required after a shift. */
int minimumRestHours;
+ /** @brief The name of the team member who must drive the first stint. */
+ const char* firstStintDriver;
/** @brief A pointer to an array of team members. */
JresTeamMember* teamMembers;
/** @brief The number of team members. */
diff --git a/src/formatter/formatter_core.cpp b/src/formatter/formatter_core.cpp
index 2942a23..5129a04 100644
--- a/src/formatter/formatter_core.cpp
+++ b/src/formatter/formatter_core.cpp
@@ -289,7 +289,9 @@ std::string generate_full_text_report(
size_t max_activity_width = 0;
for (const auto& item : itinerary.items) {
- max_activity_width = std::max(max_activity_width, item.activity.length());
+ size_t len = item.activity.length();
+ if (item.activity == "Resting") len += 2;
+ max_activity_width = std::max(max_activity_width, len);
}
std::string tz_string = std::string("UTC") + (itinerary.tz_offset >= 0 ? "+" : "") + std::to_string(itinerary.tz_offset);
@@ -297,8 +299,11 @@ std::string generate_full_text_report(
for (const auto& item : itinerary.items) {
double dur = item.end_local.diff_seconds(item.start_local);
+ std::string display_act = item.activity;
+ if (display_act == "Resting") display_act = " " + display_act;
+
f << " " << item.start_local.to_string() << " to " << item.end_local.time_string()
- << " " << std::left << std::setw(max_activity_width) << item.activity
+ << " " << std::left << std::setw(max_activity_width) << display_act
<< " (" << DateTime::format_duration((long long)dur) << ")\n";
}
}
@@ -404,6 +409,15 @@ void jres::write_output(
auto member_itineraries = generate_member_itineraries(sched_vec, data, has_spotters);
+ if (output_file.empty()) {
+ if (format == "txt") {
+ std::cout << generate_full_text_report(sched_vec, driver_stats, spotter_stats, member_itineraries, has_spotters);
+ } else {
+ std::cerr << "Error: Output to stdout is only supported for 'txt' format." << std::endl;
+ }
+ return;
+ }
+
if (format == "csv") {
_write_to_csv_file(sched_vec, output_file, has_spotters);
} else if (format == "txt") {
diff --git a/src/jres_internal_types.cpp b/src/jres_internal_types.cpp
index 4099b6e..f167e14 100644
--- a/src/jres_internal_types.cpp
+++ b/src/jres_internal_types.cpp
@@ -67,6 +67,9 @@ SolverInput from_c_input(const JresSolverInput* c_input) {
input.consecutiveStints = c_input->consecutiveStints;
input.minimumRestHours = c_input->minimumRestHours;
+ if (c_input->firstStintDriver) {
+ input.firstStintDriver = c_input->firstStintDriver;
+ }
for (int i = 0; i < c_input->teamMembers_len; ++i) {
TeamMember member;
diff --git a/src/jres_internal_types.hpp b/src/jres_internal_types.hpp
index 8143539..77d30ba 100644
--- a/src/jres_internal_types.hpp
+++ b/src/jres_internal_types.hpp
@@ -47,6 +47,7 @@ struct SolverInput
{
int consecutiveStints = 1;
int minimumRestHours = 0;
+ std::string firstStintDriver;
std::vector teamMembers;
std::map> availability;
std::vector stints;
diff --git a/src/jres_json_converter.cpp b/src/jres_json_converter.cpp
index b7841f1..81d5ee0 100644
--- a/src/jres_json_converter.cpp
+++ b/src/jres_json_converter.cpp
@@ -76,6 +76,12 @@ JRES_SOLVER_API JresSolverInput* jres_input_from_json(const char* jsonData) {
// Global Constraints
input->consecutiveStints = j.value("consecutiveStints", 1);
input->minimumRestHours = j.value("minimumRestHours", 0);
+
+ if (j.contains("firstStintDriver") && !j["firstStintDriver"].is_null()) {
+ input->firstStintDriver = allocate_and_copy(j["firstStintDriver"]);
+ } else {
+ input->firstStintDriver = nullptr;
+ }
// Team Members
input->teamMembers_len = j["teamMembers"].size();
@@ -182,7 +188,6 @@ JRES_SOLVER_API char* jres_output_to_json(const JresSolverOutput* output) {
options_json["spotterMode"] = to_string(output->options->spotterMode);
options_json["allowNoSpotter"] = output->options->allowNoSpotter;
options_json["optimalityGap"] = output->options->optimalityGap;
- options_json["switchingPenalty"] = output->options->switchingPenalty;
options_json["roleCouplingWeight"] = output->options->roleCouplingWeight;
options_json["rotationBeatWeight"] = output->options->rotationBeatWeight;
j["options"] = options_json;
@@ -236,8 +241,8 @@ JRES_SOLVER_API void free_jres_solver_output(JresSolverOutput* output) {
}
JRES_SOLVER_API void free_jres_solver_input(JresSolverInput* input) {
- if (!input) {
- return;
+ if (input->firstStintDriver) {
+ delete[] input->firstStintDriver;
}
for (int i = 0; i < input->teamMembers_len; ++i) {
diff --git a/src/jres_standard_solver.cpp b/src/jres_standard_solver.cpp
index e1e0515..448c2e1 100644
--- a/src/jres_standard_solver.cpp
+++ b/src/jres_standard_solver.cpp
@@ -140,6 +140,20 @@ jres::internal::SolverOutput JresStandardSolver::solve()
// --- Build Driver Model ---
add_participant_model(*m_highs, m_driverPool, m_driverWorkVars);
+ // --- Hard Constraint: First Stint Driver ---
+ if (!m_input.firstStintDriver.empty()) {
+ bool found = false;
+ if (m_driverWorkVars.count({m_input.firstStintDriver, 0})) {
+ int varIdx = m_driverWorkVars.at({m_input.firstStintDriver, 0});
+ m_highs->changeColBounds(varIdx, 1.0, 1.0);
+ found = true;
+ }
+
+ if (!found) {
+ throw std::runtime_error("First stint driver '" + m_input.firstStintDriver + "' is not a valid driver or is unavailable.");
+ }
+ }
+
// --- Hard Constraint: iRacing Fair Share Rule ---
// Rule: Fair Share = 1/4 of (Total Duration / Num Drivers)
double total_duration_hours = 0.0;
@@ -225,31 +239,6 @@ jres::internal::SolverOutput JresStandardSolver::solve()
m_highs->addRow(1.0, 1.0, (int)indices.size(), indices.data(), values.data());
}
- // --- Switching Penalty ---
- if (m_options.switchingPenalty > 0.0) {
- for (size_t s = 1; s < m_input.stints.size(); ++s) {
- int switchVar = m_highs->getNumCol();
- m_highs->addVar(0.0, 1.0);
- m_highs->changeColIntegrality(switchVar, HighsVarType::kInteger);
- m_highs->changeColCost(switchVar, m_options.switchingPenalty);
-
- // Constraint: switchVar >= driver_s - driver_{s-1} for each driver
- // switchVar - driver_s + driver_{s-1} >= 0
- for (const auto& p : m_driverPool) {
- if (m_driverWorkVars.count({p.name, (int)s}) && m_driverWorkVars.count({p.name, (int)s - 1})) {
- int var_s = m_driverWorkVars.at({p.name, (int)s});
- int var_prev = m_driverWorkVars.at({p.name, (int)s - 1});
-
- if (var_s != var_prev) { // Only add if different variables (blocks changed)
- std::vector idx = {switchVar, var_s, var_prev};
- std::vector val = {1.0, -1.0, 1.0};
- m_highs->addRow(0.0, kHighsInf, 3, idx.data(), val.data());
- }
- }
- }
- }
- }
-
// --- Rotation Beat (Rhythm) Incentive ---
if (m_options.rotationBeatWeight > 1e-6) {
const size_t N = m_driverPool.size();
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 8401bf8..0880f5f 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -32,11 +32,10 @@ add_executable(solver_tests
test_optimization.cpp
test_minimum_rest.cpp
test_fair_share.cpp
- test_switching_penalty.cpp
test_role_coupling.cpp
test_rotation_beat.cpp
test_spotter_balancing.cpp
- test_hard_consecutive_limit.cpp
+ test_first_stint.cpp
)
add_dependencies(solver_tests jres_solver_lib)
diff --git a/test/test_fair_share.cpp b/test/test_fair_share.cpp
index 1e425ec..379e31e 100644
--- a/test/test_fair_share.cpp
+++ b/test/test_fair_share.cpp
@@ -100,7 +100,7 @@ TEST(FairShareTest, EnforcesMinimumRequirement) {
std::vector availabilities;
availabilities.push_back(mavA);
- JresSolverInput input;
+ JresSolverInput input = {};
input.consecutiveStints = 20;
input.minimumRestHours = 0;
input.stints = stints.data();
@@ -165,7 +165,7 @@ TEST(FairShareTest, SuccessScenario) {
members.push_back(create_driver("DriverA"));
members.push_back(create_driver("DriverB"));
- JresSolverInput input;
+ JresSolverInput input = {};
input.consecutiveStints = 1;
input.minimumRestHours = 0;
input.stints = stints.data();
diff --git a/test/test_first_stint.cpp b/test/test_first_stint.cpp
new file mode 100644
index 0000000..b70cc8f
--- /dev/null
+++ b/test/test_first_stint.cpp
@@ -0,0 +1,90 @@
+/**
+ * @file test/test_first_stint.cpp
+ * @brief Tests for the firstStintDriver hard constraint.
+ */
+
+#include "gtest/gtest.h"
+#include "jres_solver/jres_solver.hpp"
+#include "nlohmann/json.hpp"
+#include
+#include
+
+using json = nlohmann::json;
+
+TEST(FirstStintTest, EnforceFirstStintDriver) {
+ // Scenario: 2 Drivers, 2 Stints.
+ // Driver A: Preferred for Stint 1
+ // Driver B: Preferred for Stint 2, and set as firstStintDriver.
+ //
+ // Normally, Driver A would take Stint 1 due to preference.
+ // But since Driver B is forced as firstStintDriver, Driver B MUST take Stint 1.
+
+ json j;
+ j["consecutiveStints"] = 1;
+ j["minimumRestHours"] = 0;
+ j["teamMembers"] = {
+ {{"name", "Driver A"}, {"isDriver", true}, {"isSpotter", false}},
+ {{"name", "Driver B"}, {"isDriver", true}, {"isSpotter", false}}
+ };
+ j["stints"] = {
+ {{"id", 1}, {"startTime", "2026-01-17T00:00:00.000Z"}, {"endTime", "2026-01-17T01:00:00.000Z"}},
+ {{"id", 2}, {"startTime", "2026-01-17T01:00:00.000Z"}, {"endTime", "2026-01-17T02:00:00.000Z"}}
+ };
+ j["availability"] = {
+ {"Driver A", {{"2026-01-17T00:00:00.000Z", "Preferred"}, {"2026-01-17T01:00:00.000Z", "Available"}}},
+ {"Driver B", {{"2026-01-17T00:00:00.000Z", "Available"}, {"2026-01-17T01:00:00.000Z", "Preferred"}}}
+ };
+ j["firstStintDriver"] = "Driver B";
+
+ JresSolverOptions options = {};
+ options.timeLimit = 5;
+ options.spotterMode = JRES_SPOTTER_MODE_NONE;
+ options.allowNoSpotter = true;
+ options.optimalityGap = 0.0;
+
+ JresSolverInput* input = jres_input_from_json(j.dump().c_str());
+ ASSERT_NE(input, nullptr);
+ EXPECT_STREQ(input->firstStintDriver, "Driver B");
+
+ JresSolverOutput* output = solve_race_schedule(input, &options);
+ ASSERT_NE(output, nullptr);
+ ASSERT_EQ(output->schedule_len, 2);
+
+ // Expect Driver B then Driver A (or B if cost doesn't matter, but B MUST be first)
+ EXPECT_STREQ(output->schedule[0].driver, "Driver B") << "Stint 1 should be Driver B (Forced)";
+
+ free_jres_solver_input(input);
+ free_jres_solver_output(output);
+}
+
+TEST(FirstStintTest, InvalidFirstStintDriverThrows) {
+ json j;
+ j["consecutiveStints"] = 1;
+ j["minimumRestHours"] = 0;
+ j["teamMembers"] = {
+ {{"name", "Driver A"}, {"isDriver", true}, {"isSpotter", false}}
+ };
+ j["stints"] = {
+ {{"id", 1}, {"startTime", "2026-01-17T00:00:00.000Z"}, {"endTime", "2026-01-17T01:00:00.000Z"}}
+ };
+ j["availability"] = {
+ {"Driver A", {{"2026-01-17T00:00:00.000Z", "Available"}}}
+ };
+ j["firstStintDriver"] = "NonExistent";
+
+ JresSolverOptions options = {};
+ options.timeLimit = 5;
+ options.spotterMode = JRES_SPOTTER_MODE_NONE;
+ options.allowNoSpotter = true;
+
+ JresSolverInput* input = jres_input_from_json(j.dump().c_str());
+ ASSERT_NE(input, nullptr);
+
+ JresSolverOutput* output = solve_race_schedule(input, &options);
+ ASSERT_NE(output, nullptr);
+ EXPECT_GT(output->diagnosis_len, 0);
+ EXPECT_TRUE(std::string(output->diagnosis[0]).find("is not a valid driver") != std::string::npos);
+
+ free_jres_solver_output(output);
+ free_jres_solver_input(input);
+}
diff --git a/test/test_hard_consecutive_limit.cpp b/test/test_hard_consecutive_limit.cpp
index a9c3cae..778eca4 100644
--- a/test/test_hard_consecutive_limit.cpp
+++ b/test/test_hard_consecutive_limit.cpp
@@ -49,8 +49,7 @@ TEST(ConstraintTest, EnforceConsecutiveStintsHardLimit) {
options.timeLimit = 10;
options.spotterMode = JRES_SPOTTER_MODE_NONE; // Focus on drivers
options.allowNoSpotter = true;
- options.optimalityGap = 0.0;
- options.switchingPenalty = 0.0;
+ options.optimalityGap = 0.0;
JresSolverOutput* output = solve_race_schedule(input, &options);
ASSERT_NE(output, nullptr);
diff --git a/test/test_spotter_balancing.cpp b/test/test_spotter_balancing.cpp
index 928c46a..73308cd 100644
--- a/test/test_spotter_balancing.cpp
+++ b/test/test_spotter_balancing.cpp
@@ -38,7 +38,6 @@ TEST(SpotterBalancingTest, IntegratedBalancing) {
options.spotterMode = JRES_SPOTTER_MODE_INTEGRATED;
options.allowNoSpotter = false;
options.optimalityGap = 0.0;
- options.switchingPenalty = 0.0;
options.rotationBeatWeight = 0.0;
JresSolverOutput* output = solve_race_schedule(input, &options);
@@ -86,7 +85,6 @@ TEST(SpotterBalancingTest, SequentialBalancing) {
options.spotterMode = JRES_SPOTTER_MODE_SEQUENTIAL;
options.allowNoSpotter = false;
options.optimalityGap = 0.0;
- options.switchingPenalty = 0.0;
options.rotationBeatWeight = 0.0;
JresSolverOutput* output = solve_race_schedule(input, &options);
diff --git a/test/test_switching_penalty.cpp b/test/test_switching_penalty.cpp
deleted file mode 100644
index 11feecc..0000000
--- a/test/test_switching_penalty.cpp
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @file test/test_switching_penalty.cpp
- * @brief Tests for driver switching penalty enforcement.
- */
-
-#include "gtest/gtest.h"
-#include "jres_solver/jres_solver.hpp"
-#include "nlohmann/json.hpp"
-#include
-#include
-#include
-
-using json = nlohmann::json;
-
-TEST(SwitchingPenaltyTest, ForceMinimumSwitches) {
- // Scenario: 3 Stints, 2 Drivers.
- // Both Available for all.
- // Fair Share forces both to drive at least 1 stint (total 3h, fair share ~0.375h).
- // Without penalty, fairness balancing (kCostFairness) might favor Alternating (A, B, A) or (A, A, B).
- // With HIGH Switching Penalty, we force (A, A, B) or (B, B, A) -> 1 Switch.
- // (A, B, A) -> 2 Switches.
-
- json j;
- j["success"] = true;
- j["teamMembers"] = {
- {{"name", "Driver A"}, {"isDriver", true}, {"isSpotter", false}, {"maxStints", 3}, {"minimumRestHours", 0}},
- {{"name", "Driver B"}, {"isDriver", true}, {"isSpotter", false}, {"maxStints", 3}, {"minimumRestHours", 0}}
- };
- j["stints"] = {
- {{"id", 1}, {"startTime", "2026-01-17T00:00:00.000Z"}, {"endTime", "2026-01-17T01:00:00.000Z"}},
- {{"id", 2}, {"startTime", "2026-01-17T01:00:00.000Z"}, {"endTime", "2026-01-17T02:00:00.000Z"}},
- {{"id", 3}, {"startTime", "2026-01-17T02:00:00.000Z"}, {"endTime", "2026-01-17T03:00:00.000Z"}}
- };
- j["availability"] = json::object(); // All available
- j["firstStintDriver"] = nullptr;
-
- JresSolverOptions options = {};
- options.timeLimit = 5;
- options.spotterMode = JRES_SPOTTER_MODE_NONE;
- options.allowNoSpotter = true;
- options.optimalityGap = 0.0;
- options.switchingPenalty = 1000.0; // High penalty
-
- std::string json_str = j.dump();
- JresSolverInput* input = jres_input_from_json(json_str.c_str());
- ASSERT_NE(input, nullptr);
-
- JresSolverOutput* output = solve_race_schedule(input, &options);
- ASSERT_NE(output, nullptr);
- ASSERT_EQ(output->schedule_len, 3);
-
- // Calculate switches
- int switches = 0;
- for (int i = 1; i < output->schedule_len; ++i) {
- if (std::string(output->schedule[i].driver) != std::string(output->schedule[i-1].driver)) {
- switches++;
- }
- }
-
- // Since Fair Share forces both to drive, min switches = 1 (e.g. AAB).
- // However, with strict consecutiveStints=1 (default), AAB is invalid (A cannot drive 2 consecutive).
- // So valid schedule is ABA (2 switches).
- // If penalty works, we should not see A-B-A (2 switches) -> Wait, ABA IS the minimum now.
- EXPECT_EQ(switches, 2) << "Should minimize switches to 2 (e.g. ABA) given strict consecutiveStints=1";
-
- // Verify both drivers drove (Fair Share check)
- std::set drivers;
- for (int i = 0; i < output->schedule_len; ++i) {
- drivers.insert(output->schedule[i].driver);
- }
- EXPECT_EQ(drivers.size(), 2) << "Both drivers must drive due to Fair Share rule";
-
- free_jres_solver_input(input);
- free_jres_solver_output(output);
-}
diff --git a/test_formatter_stdout.sh b/test_formatter_stdout.sh
new file mode 100755
index 0000000..7d89893
--- /dev/null
+++ b/test_formatter_stdout.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+# Test the formatter's ability to print to stdout
+
+# Path to the formatter executable
+FORMATTER="./build/jres_formatter"
+INPUT_FILE="data/24h_race.json"
+
+# First, ensure we have a solution file to format.
+# We'll run the solver on the input file and pipe it to a temporary file.
+SOLVER="./build/jres_solver"
+SOLUTION_FILE="/tmp/test_solution.json"
+
+echo "Running solver to generate solution..."
+$SOLVER -i $INPUT_FILE -s sequential -o $SOLUTION_FILE
+
+if [ ! -f $SOLUTION_FILE ]; then
+ echo "Error: Solver failed to generate solution file."
+ exit 1
+fi
+
+echo "Running formatter without -o flag..."
+# Run the formatter and capture stdout
+OUTPUT=$($FORMATTER -i $SOLUTION_FILE)
+
+# Check if the output contains expected strings from the summary report
+if [[ "$OUTPUT" == *"--- DRIVER SUMMARY ---"* ]] && [[ "$OUTPUT" == *"--- SCHEDULE ---"* ]]; then
+ echo "Success: Formatter output to stdout detected."
+else
+ echo "Error: Formatter did not print expected summary to stdout."
+ echo "Output was:"
+ echo "$OUTPUT"
+ exit 1
+fi
+
+# Clean up
+rm $SOLUTION_FILE
+exit 0