Skip to content

C++ Input and Output Management Overview

Effective input and output (I/O) management lies at the heart of robust software programs, ensuring seamless interaction between the user and the application. In the realm of C++ programming, where efficiency and precision are paramount, proper handling of input and output operations holds significant importance. A meticulously designed I/O system not only enhances the user experience by facilitating clear communication but also bolsters program reliability by guarding against errors and unexpected behaviours. This documentation provides an overview of the approaches that were followed to handle the inputs provided to and outputs generated by the program, along with some reasoning on these choices.

Input Reading

Initially, we outline the two primary concepts or solutions employed to guarantee proper input management, "boost program options" and "error handling".

Boost Program Options


Programs which perform simulations must be able to have the scenario they are simulating specified by the user. However, the user may sometimes provide invalid values as part of this specification. It's desirable to have a system in place that can handle these invalid values and provide the user with a clear message about which values are invalid.

In the current program, the Boost Program Options library (<boost/program_options.hpp>) is utilised. Boost Program Options is a powerful C++ library that simplifies the handling of program input. It provides a straightforward and intuitive way to manage command-line arguments, read files, and define options for your C++ programs. This way, it allows focusing on the logic of the program rather than dealing with the complexities of input parsing. Use of this library facilitates the easy reading of user inputs and allows us to provide enhanced definitions of the expected input parameters, accompanied by their types, using the options_description object and its add_options() function.

A snippet from the program's code, related to the management of input parameters, is shown below:

/* **************************** SPH-main.cpp **************************** */


// Process to obtain the inputs provided by the user
po::options_description desc("Allowed options");
desc.add_options()("init_condition", po::value<std::string>(),
                    "take an initial condition")("T", po::value<double>(),
                                                "take integration time")(
    "dt", po::value<double>(), "take time-step")(
    "coeffCfl1", po::value<double>(), "take lamda nu")(
    "coeffCfl2", po::value<double>(), "take lamda f")(
    "adaptive_timestep", po::value<bool>(),
    "take flag for adaptive time-step")("h", po::value<double>(),
                                        "take radius of influence")(
    "gas_constant", po::value<double>(), "take gas constant")(
    "density_resting", po::value<double>(), "take resting density")(
    "viscosity", po::value<double>(), "take viscosity")(
    "acceleration_gravity", po::value<double>(), "take acc due to gravity")(
    "coeff_restitution", po::value<double>(), "take coeff of restitution")(
    "left_wall", po::value<double>(), "take left wall position")(
    "right_wall", po::value<double>(), "take right wall position")(
    "bottom_wall", po::value<double>(), "take bottom wall position")(
    "top_wall", po::value<double>(), "take top wall position")(
    "length", po::value<double>(), "take length of the block")(
    "width", po::value<double>(), "take width of the block")(
    "radius", po::value<double>(), "take radius of the droplet")(
    "n", po::value<int>(), "take number of particles")(
    "center_x", po::value<double>(), "take center of the particle mass in x")(
    "center_y", po::value<double>(), "take center of the particle mass in y")(
    "init_x_1", po::value<double>(), "take x_1")(
    "init_y_1", po::value<double>(), "take y_2")(
    "init_x_2", po::value<double>(), "take x_2")(
    "init_y_2", po::value<double>(), "take y_2")(
    "init_x_3", po::value<double>(), "take x_3")(
    "init_y_3", po::value<double>(), "take y_3")(
    "init_x_4", po::value<double>(), "take x_4")(
    "init_y_4", po::value<double>(), "take y_4")(
    "output_frequency", po::value<int>(),
    "take frequency that output will be written to file");

Then, as shown in the code snippet below, the code line po::store(po::parse_config_file(icFile, desc), icVm); uses these definitions (desc) to search for matches between the input parsed from the *.txt files in the exec/input/ directory (icFile in this case), and the expected parameters. These mapped pairs are finally stored in another object provided by the library, called a variables_map (icVm in this case). This approach enhances the flexibility and robustness of the input reading process. Users can specify input parameters in the txt files in any order, provided they are given as key = value pairs.

/* **************************** SPH-main.cpp **************************** */

void retrieveInputsFromFile(const std::string& fileName,
                            const std::string& icCase,
                            const po::options_description& desc,
                            po::variables_map& vm) {
  std::ifstream caseFile;
  std::string errorMessage = "Error opening file: " + fileName;
  if (fileName == icCase + ".txt") {
    errorMessage +=
        " Make sure that the value of the init_condition in the case.txt "
        "file is one of the following: ic-one-particle, ic-two-particles, "
        "ic-three-particles, ic-four-particles, ic-droplet, ic-block-drop.";
  }
  // Try to open the file
  try {
    caseFile.open("../input/" + fileName);
    // Throw an exception if the file cannot be opened
    if (!caseFile.is_open()) {
      throw std::runtime_error(errorMessage);
    }
    po::store(po::parse_config_file(caseFile, desc), vm);
  } catch (std::runtime_error& e) {
    // Handle the exception by printing the error message and exiting the
    // program
    std::cerr << e.what() << std::endl;
    exit(1);
  }
  po::notify(vm);
}

Note that the exit(1) means the program as a whole returns an exit code of 1. An exit code of 0 conventionally means a program has exited successfully, with other values indicating an error. As a result, returning 1 in the case of an error is useful for scripts or other programs that may call this program, as they can check the exit code and take appropriate action if the program has failed.

Error Handling


Another crucial part of reading input by a program is error handling. In order for the program to run without errors, the provided input must conform to the program's specific rules, e.g., in the current case, the constraints imposed by the underlying mathematical models and the physical meaning of each variable. However, in the case of reading input from *.txt files, there is no way to guarantee that the user will adhere to these rules. For example, if the user attempts to set a negative value for the timestep, or a value that is greater than the integration time, the program will crash.

Even though we cannot control the user's actions, we can - and we should always - control our program's response to these actions. A program that simply crashes on unexpected input is not user-friendly, since it does not provide any guidance to the user regarding their wrong input. Error handling is the process of properly handling this "bad" input, so that the program provides information to the user regarding the reason of the error or the correct usage of the program, before it normally exits.

In C++, exceptions provide suitable functionality for input error handling. With exceptions, we can add try/catch blocks to our program. The try part should include the error-prone code, which in the case of handling input could be either the proper opening of the file (e.g., icFile.is_open()) or a condition that checks that the input value adheres to the program's rules (e.g., caseVm["dt"].as<double>() <= 0). In case of non-expected behaviour, a throw statement is used, which throws an exception. There are numerous types of exceptions, such as the runtime_error exception thrown in the code snipped below, accompanied by an intuitive error message. Finally, the exception thrown in the try block, is caught in the catch block. In other words, the catch block performs the "handling" of the error, and this is where our code for the desired behaviour in case of an error should be included. For instance, in the following code snipped, when the runtime_error exception is caught, the program prints the corresponding error message, in order to guide the user regarding the correct usage, and then exits in a controlled manner.

/* **************************** SPH-main.cpp **************************** */

void handleInputErrors(const po::variables_map& caseVm,
                       const po::variables_map& domainVm,
                       const po::variables_map& constantsVm,
                       const po::variables_map& icVm) {
  try {
    // Error handling for the total integration time
    if (caseVm["T"].as<double>() <= 0) {
      throw std::runtime_error(
          "Error: Total integration time must be positive!");
      // Error handling for the time step
    } else if (caseVm["dt"].as<double>() <= 0 or
               caseVm["dt"].as<double>() > caseVm["T"].as<double>()) {
      throw std::runtime_error(
          "Error: Time step must be positive and lower than the total "
          "integration time!");
      // Error handling for the output frequency
    } else if (caseVm["output_frequency"].as<int>() <= 0 or
               caseVm["output_frequency"].as<int>() >
                   ceil(caseVm["T"].as<double>() / caseVm["dt"].as<double>())) {
      throw std::runtime_error(
          "Error: Output frequency must be positive and lower than the total "
          "number of iterations!");
      // Error handling for the CFL coefficients
    } else if (caseVm["coeffCfl1"].as<double>() <= 0 or
               caseVm["coeffCfl1"].as<double>() >= 1 or
               caseVm["coeffCfl2"].as<double>() <= 0 or
               caseVm["coeffCfl2"].as<double>() >= 1) {
      throw std::runtime_error(
          "Error: The CFL coefficients must be positive and less than 1");
      // Error handling for the domain boundaries
    } else if (domainVm["left_wall"].as<double>() >=
                   domainVm["right_wall"].as<double>() ||
               domainVm["bottom_wall"].as<double>() >=
                   domainVm["top_wall"].as<double>()) {
      throw std::runtime_error(
          "Error: Please adjust your domain boundaries so that left_wall < "
          "right wall and bottom_wall < top_wall.");
      // Error handling for the number of particles
    } else if (icVm["n"].as<int>() <= 0) {
      throw std::runtime_error("Error: Number of particles must be positive!");
    }
  } catch (std::runtime_error& e) {
    // Handle the exception by printing the error message and exiting the
    // program
    std::cerr << e.what() << std::endl;
    exit(1);
  }
}

In a different case, we could also decide that we do not want our program to exit at all, but replace the wrong input with the closest appropriate value instead. For instance, in the ic-droplet required a square grid of particles. The functions closest_integer_sqrt() (declared in initial_conditions.h) is used to transform nbParticles to a value with an integer square root. For instance, if a user requested 50 particles, 49 particles would be used instead as this could be formed by a 7x7 grid of particles.

Similarly for the ic-block-drop initial condition case a rectangular grid of particles is required. rectangleN() (also declared in initial_conditions.h) we make sure that the input value is transformed to the closest value that can be used to create a rectangle of the user-provided dimensions. This could be also seen as a type of error handling, since, if left unhandled, the program would either result in an error or wrong output.

In all cases, it is crucial that we provide error handling for the values of all the different input parameters expected by the program. This allows errors in the input to be corrected, or highlight the error to the user before the program ends in an error.

Output Generation

Having briefly outlined how inputs are managed, we will now offer an overview of how the program generates and handles its outputs.

CSV Data Storage


The data generated by the SPH application are ultimately stored in CSV text files. Storing data in widely usable file types, such as CSV (Comma-Separated Values), is crucial for software programs due to its interoperability and accessibility across various platforms and applications. CSV files offer a standardised format that can be easily imported and exported by different software tools, databases, and programming languages. This ensures seamless data exchange and integration between different components of a system, facilitating collaboration and interoperability among developers and users.

Furthermore, CSV files are human-readable and editable using simple text editors, making them ideal for sharing and manipulating data without requiring specialised software. This accessibility simplifies data management tasks and empowers users to work with the data directly, fostering transparency and efficiency in data-driven processes. By prioritising the use of widely usable file types like CSV, software programs can enhance their usability, flexibility, and compatibility across diverse computing environments.

Output Management Essentials


Ensuring that the correct paths exist before attempting to store program output is crucial for maintaining the integrity and reliability of the software. By verifying and creating necessary directories as needed, while also initialising files appropriately, using the correct variable type for the solution, developers can prevent errors and interruptions during the output storage process. Closing files when no longer needed further enhances system resource management, ensuring optimal performance and data integrity within the C++ programming environment.

In this program, we begin the output generation operations by ensuring that the target output location exists. The createDirectory() function, provided below, first checks whether the target folder path exists, and if not, it creates it.

/* **************************** SPH-main.cpp **************************** */


void createDirectory(std::string folderPath) {
  // Check if the target folder already exists
  if (!std::filesystem::exists(folderPath)) {
    // Create target folder
    std::filesystem::create_directories(folderPath);
  }
}

This sets the foundation for the creation of the output files themselves. These files are initialised using the std::ofstream variable type. Such type offers a straightforward and intuitive interface for file operations in C++. For example, it allows for writing data to files using the familiar stream insertion operator << just like you would write to std::cout. Moreover, it comes with a destructor, that takes care of the release of the used resources when appropriate (including if an error occurs elsewhere in the code), eliminating the need for manual handling of such operations.

/* **************************** SPH-main.cpp **************************** */


std::tuple<std::ofstream, std::ofstream, std::ofstream, std::ofstream>
initOutputFiles(const std::string &outputFolder) {
  // Create the output folder if it doesn't exist
  createDirectory(outputFolder);

  // Declare and initialise the output files
  std::ofstream initialPositions(outputFolder + "/initial-positions.csv",
                                 std::ios::out | std::ios::trunc);
  std::ofstream simulationPositions(outputFolder + "/simulation-positions.csv",
                                    std::ios::out | std::ios::trunc);
  std::ofstream finalPositions(outputFolder + "/final-positions.csv",
                               std::ios::out | std::ios::trunc);
  std::ofstream energies(outputFolder + "/energies.csv",
                         std::ios::out | std::ios::trunc);

  initialPositions << std::fixed << std::setprecision(5);
  initialPositions << "Timestamp,Position_X,Position_Y"
                   << "\n";

...

This application exports data to CSV files based on a user-defined intervals in simulation time, specified within the /exec/input/case.txt input file. This approach allows the program to minimise memory usage by writing data in intervals, preventing unnecessary resource consumption and ensuring smooth operation even with large datasets. Additionally, less frequent exporting reduces interruptions for write operations, enhancing program responsiveness and user experience. Ultimately, this user-controlled export frequency strikes a balance between data granularity and resource efficiency, adapting to diverse project needs and system constraints.