diff --git a/.github/conda_pgm_env.yml b/.github/conda_pgm_env.yml index 1845c03ec8..6c3be6c6cf 100644 --- a/.github/conda_pgm_env.yml +++ b/.github/conda_pgm_env.yml @@ -5,7 +5,7 @@ name: conda-pgm-env dependencies: # build env - - python=3.12 + - python=3.14 - pip - scikit-build-core # build deps @@ -16,6 +16,7 @@ dependencies: - numpy - cmake - ninja + - cli11 # test deps - pytest - pytest-cov diff --git a/.github/workflows/build-test-release.yml b/.github/workflows/build-test-release.yml index 6736af685b..64a4bea5ee 100644 --- a/.github/workflows/build-test-release.yml +++ b/.github/workflows/build-test-release.yml @@ -275,7 +275,12 @@ jobs: cmake --install build/ - name: Build python - run: python -m pip install . --no-build-isolation --no-deps -C wheel.cmake=false + run: | + python conda_build_preparation.py + python -m pip install . --no-build-isolation --no-deps -C wheel.cmake=false - name: Test - run: pytest + run: | + pytest + power-grid-model --help + power-grid-model --version diff --git a/CMakeLists.txt b/CMakeLists.txt index 711b87706f..543707553a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,7 @@ find_package(Boost REQUIRED) find_package(Eigen3 CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) find_package(msgpack-cxx REQUIRED) +find_package(CLI11 CONFIG REQUIRED) if(NOT WIN32) # thread diff --git a/conda_build_preparation.py b/conda_build_preparation.py new file mode 100644 index 0000000000..7a6d1497dc --- /dev/null +++ b/conda_build_preparation.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +# This script modifies the pyproject.toml file to remove specific sections +# [project.entry-points."cmake.root"] and [project.scripts] +# and deletes the run_pgm_cli.py file from the source directory. +# It is intended to be run as part of the conda build preparation process. +# So that conda environment will not be confused with PyPI style Python shim and entry points. + +import re +from pathlib import Path + +# Read the root pyproject.toml +pyproject_path = Path(__file__).parent / "pyproject.toml" +content = pyproject_path.read_text() + +# Remove [project.entry-points."cmake.root"] section +content = re.sub(r'\n\[project\.entry-points\."cmake\.root"\].*?(?=\n\[|\Z)', "", content, flags=re.DOTALL) + +# Remove [project.scripts] section +content = re.sub(r"\n\[project\.scripts\].*?(?=\n\[|\Z)", "", content, flags=re.DOTALL) + +# Write back to pyproject.toml +pyproject_path.write_text(content) + +# Remove run_pgm_cli.py file +run_pgm_cli_path = ( + Path(__file__).parent / "src" / "power_grid_model" / "_core" / "power_grid_model_c" / "run_pgm_cli.py" +) +run_pgm_cli_path.unlink(missing_ok=True) diff --git a/power_grid_model_c/CMakeLists.txt b/power_grid_model_c/CMakeLists.txt index 698351326f..fe815c8c21 100644 --- a/power_grid_model_c/CMakeLists.txt +++ b/power_grid_model_c/CMakeLists.txt @@ -5,3 +5,5 @@ add_subdirectory("power_grid_model") add_subdirectory("power_grid_model_c") add_subdirectory("power_grid_model_cpp") +add_subdirectory("power_grid_model_cli_backend") +add_subdirectory("power_grid_model_cli") diff --git a/power_grid_model_c/power_grid_model_cli/CMakeLists.txt b/power_grid_model_c/power_grid_model_cli/CMakeLists.txt new file mode 100644 index 0000000000..fe8caa348b --- /dev/null +++ b/power_grid_model_c/power_grid_model_cli/CMakeLists.txt @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +add_executable(power_grid_model_cli main.cpp) + +target_link_libraries(power_grid_model_cli PRIVATE power_grid_model_cli_backend) + +set_property(TARGET power_grid_model_cli PROPERTY INSTALL_RPATH_USE_LINK_PATH FALSE) +set_property(TARGET power_grid_model_cli PROPERTY OUTPUT_NAME "power-grid-model") +if(APPLE) + set_property(TARGET power_grid_model_cli PROPERTY INSTALL_RPATH "@loader_path/../${CMAKE_INSTALL_LIBDIR}") +elseif(UNIX) # Linux, BSD (not Windows/macOS) + set_property(TARGET power_grid_model_cli PROPERTY INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}") +endif() + +install( + TARGETS power_grid_model_cli + EXPORT power_grid_modelTargets + COMPONENT power_grid_model +) diff --git a/power_grid_model_c/power_grid_model_cli/main.cpp b/power_grid_model_c/power_grid_model_cli/main.cpp new file mode 100644 index 0000000000..fa355ee576 --- /dev/null +++ b/power_grid_model_c/power_grid_model_cli/main.cpp @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Contributors to the Power Grid Model project +// +// SPDX-License-Identifier: MPL-2.0 + +#include + +int main(int argc, char** argv) { return PGM_cli_main(argc, argv, nullptr, nullptr, nullptr); } diff --git a/power_grid_model_c/power_grid_model_cli_backend/CMakeLists.txt b/power_grid_model_c/power_grid_model_cli_backend/CMakeLists.txt new file mode 100644 index 0000000000..d7fb81424b --- /dev/null +++ b/power_grid_model_c/power_grid_model_cli_backend/CMakeLists.txt @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +add_library( + power_grid_model_cli_backend + SHARED + "src/cli_main.cpp" + "src/cli_options.cpp" + "src/pgm_calculation.cpp" +) + +target_include_directories( + power_grid_model_cli_backend + PUBLIC + $ + $ + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src +) + +file( + GLOB_RECURSE pgm_cli_backend_public_headers + "${CMAKE_CURRENT_SOURCE_DIR}/include/*.h" +) + +target_link_libraries(power_grid_model_cli_backend PRIVATE power_grid_model_c) +target_link_libraries(power_grid_model_cli_backend PRIVATE power_grid_model_cpp) +target_link_libraries(power_grid_model_cli_backend PRIVATE CLI11::CLI11) + +target_compile_definitions( + power_grid_model_cli_backend + PRIVATE + PGM_CLI_BACKEND_EXPORTS + PGM_ENABLE_EXPERIMENTAL +) + +target_sources( + power_grid_model_cli_backend + PUBLIC + FILE_SET pgm_cli_backend_public_headers + TYPE HEADERS + BASE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/include/" + FILES "${pgm_cli_backend_public_headers}" +) + +set_target_properties( + power_grid_model_cli_backend + PROPERTIES VERSION ${PGM_VERSION} SOVERSION ${PGM_VERSION_MAJOR} +) + +set_property(TARGET power_grid_model_cli_backend PROPERTY INSTALL_RPATH_USE_LINK_PATH FALSE) +if(APPLE) + set_property(TARGET power_grid_model_cli_backend PROPERTY INSTALL_RPATH "@loader_path") +elseif(UNIX) # Linux, BSD (not Windows/macOS) + set_property(TARGET power_grid_model_cli_backend PROPERTY INSTALL_RPATH "$ORIGIN") +endif() + +if(NOT PGM_MSVC_REPRODUCIBLE_BUILD) + set_target_properties( + power_grid_model_cli_backend + PROPERTIES + INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE + INTERPROCEDURAL_OPTIMIZATION_RELWITHDEBINFO TRUE + ) +endif() + +install( + TARGETS power_grid_model_cli_backend + EXPORT power_grid_modelTargets + COMPONENT power_grid_model + FILE_SET pgm_cli_backend_public_headers +) \ No newline at end of file diff --git a/power_grid_model_c/power_grid_model_cli_backend/include/power_grid_model_cli_backend/cli_main.h b/power_grid_model_c/power_grid_model_cli_backend/include/power_grid_model_cli_backend/cli_main.h new file mode 100644 index 0000000000..73d5191997 --- /dev/null +++ b/power_grid_model_c/power_grid_model_cli_backend/include/power_grid_model_cli_backend/cli_main.h @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Contributors to the Power Grid Model project +// +// SPDX-License-Identifier: MPL-2.0 + +#pragma once +#ifndef POWER_GRID_MODEL_CLI_BACKEND_CLI_MAIN_H +#define POWER_GRID_MODEL_CLI_BACKEND_CLI_MAIN_H + +#ifdef _WIN32 +#define PGM_CLI_HELPER_DLL_IMPORT __declspec(dllimport) +#define PGM_CLI_HELPER_DLL_EXPORT __declspec(dllexport) +#define PGM_CLI_HELPER_DLL_LOCAL +#else +#if __GNUC__ >= 4 +#define PGM_CLI_HELPER_DLL_IMPORT __attribute__((visibility("default"))) +#define PGM_CLI_HELPER_DLL_EXPORT __attribute__((visibility("default"))) +#define PGM_CLI_HELPER_DLL_LOCAL __attribute__((visibility("hidden"))) +#else +#define PGM_CLI_HELPER_DLL_IMPORT +#define PGM_CLI_HELPER_DLL_EXPORT +#define PGM_CLI_HELPER_DLL_LOCAL +#endif +#endif + +#ifdef PGM_CLI_BACKEND_EXPORTS +#define PGM_CLI_API PGM_CLI_HELPER_DLL_EXPORT +#else +#define PGM_CLI_API PGM_CLI_HELPER_DLL_IMPORT +#endif +#define PGM_CLI_LOCAL PGM_CLI_HELPER_DLL_LOCAL + +#ifdef __cplusplus +#define PGM_CLI_NOEXCEPT noexcept +#else +#define PGM_CLI_NOEXCEPT +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// NOLINTNEXTLINE(modernize-use-using) +typedef void (*PGM_CLIMessageCallback)(char const* msg, void* user_data); + +/** + * @brief CLI entry point for the Power Grid Model command line interface. + * + * @param argc Number of command line arguments. + * @param argv Command line arguments. + * @param cout_callback Callback used for standard output. When null, std::cout is used. + * @param cerr_callback Callback used for standard error. When null, std::cerr is used. + * @param user_data Opaque user data forwarded to the callbacks. + * @return Process exit code. + */ +PGM_CLI_API int PGM_cli_main(int argc, char** argv, PGM_CLIMessageCallback cout_callback, + PGM_CLIMessageCallback cerr_callback, void* user_data) PGM_CLI_NOEXCEPT; + +#ifdef __cplusplus +} +#endif + +#endif // POWER_GRID_MODEL_CLI_BACKEND_CLI_MAIN_H diff --git a/power_grid_model_c/power_grid_model_cli_backend/src/cli_functions.hpp b/power_grid_model_c/power_grid_model_cli_backend/src/cli_functions.hpp new file mode 100644 index 0000000000..aac7c49eda --- /dev/null +++ b/power_grid_model_c/power_grid_model_cli_backend/src/cli_functions.hpp @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Contributors to the Power Grid Model project +// +// SPDX-License-Identifier: MPL-2.0 + +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace power_grid_model_cpp { + +struct CLIResult { + int exit_code; + bool should_exit; + std::string stdout_message; + std::string stderr_message; + + operator bool() const { return should_exit || exit_code != 0; } +}; + +// NOLINTNEXTLINE(clang-analyzer-optin.performance.Padding) +struct ClIOptions { // NOSONAR(S1820) + std::filesystem::path input_file; + std::vector batch_update_file; + std::filesystem::path output_file; + PGM_SerializationFormat input_serialization_format{PGM_json}; + std::vector batch_update_serialization_format; + bool is_batch{false}; + + double system_frequency{50.0}; + + PGM_CalculationType calculation_type{PGM_power_flow}; + PGM_CalculationMethod calculation_method{PGM_default_method}; + bool symmetric_calculation{static_cast(PGM_symmetric)}; + double error_tolerance{1e-8}; + Idx max_iterations{20}; + Idx threading{-1}; + PGM_ShortCircuitVoltageScaling short_circuit_voltage_scaling{PGM_short_circuit_voltage_scaling_maximum}; + PGM_TapChangingStrategy tap_changing_strategy{PGM_tap_changing_strategy_disabled}; + + bool use_msgpack_output_serialization{false}; + Idx output_json_indent{2}; + bool use_compact_serialization{false}; + + MetaDataset const* output_dataset{nullptr}; + std::map> output_component_attribute_filters; + + bool verbose{false}; + + friend std::ostream& operator<<(std::ostream& os, ClIOptions const& options); +}; + +CLIResult parse_cli_options(int argc, char** argv, ClIOptions& options); + +void pgm_calculation(ClIOptions const& cli_options); + +} // namespace power_grid_model_cpp diff --git a/power_grid_model_c/power_grid_model_cli_backend/src/cli_main.cpp b/power_grid_model_c/power_grid_model_cli_backend/src/cli_main.cpp new file mode 100644 index 0000000000..96edd4d562 --- /dev/null +++ b/power_grid_model_c/power_grid_model_cli_backend/src/cli_main.cpp @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Contributors to the Power Grid Model project +// +// SPDX-License-Identifier: MPL-2.0 + +#include "power_grid_model_cli_backend/cli_main.h" + +#include "cli_functions.hpp" + +#include + +#include +#include +#include +#include +#include + +using namespace power_grid_model_cpp; + +namespace { + +void write_output(PGM_CLIMessageCallback callback, // NOSONAR(S5205) + void* user_data, // NOSONAR(S5008) + std::ostream& stream, std::string const& message) { + if (callback != nullptr) { + callback(message.c_str(), user_data); + } else { + stream << message; + } +} + +} // namespace + +int PGM_cli_main(int argc, char** argv, // CLI argument + PGM_CLIMessageCallback cout_callback, PGM_CLIMessageCallback cerr_callback, // NOSONAR(S5205) + void* user_data) noexcept { // NOSONAR(S5008) + static std::mutex cli_mutex; + std::scoped_lock const lock{cli_mutex}; + + ClIOptions cli_options; + if (auto const parse_result = parse_cli_options(argc, argv, cli_options); parse_result) { + write_output(cout_callback, user_data, std::cout, parse_result.stdout_message); + write_output(cerr_callback, user_data, std::cerr, parse_result.stderr_message); + return parse_result.exit_code; + } + + if (cli_options.verbose) { + std::ostringstream output_stream; + output_stream << cli_options << '\n'; + write_output(cout_callback, user_data, std::cout, output_stream.str()); + } + + try { + pgm_calculation(cli_options); + } catch (PowerGridError const& e) { + write_output(cerr_callback, user_data, std::cerr, std::string{"PowerGridError: "} + e.what() + '\n'); + return static_cast(e.error_code()); + } catch (std::exception const& e) { + write_output(cerr_callback, user_data, std::cerr, std::string{"Exception: "} + e.what() + '\n'); + return 1; + } catch (...) { + write_output(cerr_callback, user_data, std::cerr, "Unknown exception caught.\n"); + return 1; + } + + return 0; +} diff --git a/power_grid_model_c/power_grid_model_cli_backend/src/cli_options.cpp b/power_grid_model_c/power_grid_model_cli_backend/src/cli_options.cpp new file mode 100644 index 0000000000..d187564221 --- /dev/null +++ b/power_grid_model_c/power_grid_model_cli_backend/src/cli_options.cpp @@ -0,0 +1,272 @@ +// SPDX-FileCopyrightText: Contributors to the Power Grid Model project +// +// SPDX-License-Identifier: MPL-2.0 + +#include "cli_functions.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace power_grid_model_cpp { + +using EnumMap = std::map>; + +struct CLIPostCallback { + // NOLINTBEGIN(cppcoreguidelines-avoid-const-or-ref-data-members) + ClIOptions& options; + CLI::Option& msgpack_flag; + CLI::Option& compact_flag; + std::vector const& output_components; + std::vector const& output_attributes; + // NOLINTEND(cppcoreguidelines-avoid-const-or-ref-data-members) + + void operator()() { + set_default_values(); + set_output_dataset(); + add_component_output_filter(); + add_attribute_output_filter(); + } + + static PGM_SerializationFormat get_serialization_format(std::string const& argument_type, + std::filesystem::path const& path) { + std::ifstream file{path, std::ios::binary}; + if (!file.is_open()) { + throw CLI::ValidationError(argument_type, "Unable to open file: " + path.string()); + } + uint8_t header; + file.read(reinterpret_cast(&header), 1); + if (!file) { + throw CLI::ValidationError(argument_type, "Unable to read from file: " + path.string()); + } + bool const is_msgpack = (header >= 0x80 && header <= 0x8f) || header == 0xde || header == 0xdf; + return is_msgpack ? PGM_msgpack : PGM_json; + } + + void set_default_values() { + options.input_serialization_format = get_serialization_format("input", options.input_file); + options.is_batch = !options.batch_update_file.empty(); + options.batch_update_serialization_format.resize(options.batch_update_file.size()); + std::ranges::transform(options.batch_update_file, options.batch_update_serialization_format.begin(), + [](auto const& path) { return get_serialization_format("batch-update", path); }); + if (msgpack_flag.empty() && (options.input_serialization_format == PGM_msgpack || + std::ranges::any_of(options.batch_update_serialization_format, + [](auto format) { return format == PGM_msgpack; }))) { + options.use_msgpack_output_serialization = true; + } + if (compact_flag.empty() && options.use_msgpack_output_serialization) { + options.use_compact_serialization = true; + } + } + + void set_output_dataset() { + std::string const dataset_name = get_output_type(options.calculation_type, options.symmetric_calculation); + options.output_dataset = MetaData::get_dataset_by_name(dataset_name); + } + + void add_component_output_filter() { + std::string const dataset_name = get_output_type(options.calculation_type, options.symmetric_calculation); + for (auto const& comp_name : output_components) { + try { + auto const* const component = MetaData::get_component_by_name(dataset_name, comp_name); + options.output_component_attribute_filters[component] = {}; + } catch (PowerGridError const&) { + std::string error_msg = "Component '"; + error_msg += comp_name; + error_msg += "' not found in dataset '"; + error_msg += dataset_name; + error_msg += "'."; + throw CLI::ValidationError("output-component", error_msg); + } + } + } + + void add_attribute_output_filter() { + std::string const dataset_name = get_output_type(options.calculation_type, options.symmetric_calculation); + for (auto const& attr_full_name : output_attributes) { + auto dot_pos = attr_full_name.find('.'); + if (dot_pos == std::string::npos || dot_pos == 0 || dot_pos == attr_full_name.size() - 1) { + throw CLI::ValidationError("output-attribute", "Attribute '" + attr_full_name + + "' is not in the format 'component.attribute'."); + } + auto comp_name = attr_full_name.substr(0, dot_pos); + auto attr_name = attr_full_name.substr(dot_pos + 1); + MetaComponent const* component = nullptr; + try { + component = MetaData::get_component_by_name(dataset_name, comp_name); + } catch (PowerGridError const&) { + std::string error_msg = "Component '"; + error_msg += comp_name; + error_msg += "' not found in dataset '"; + error_msg += dataset_name; + error_msg += "'."; + throw CLI::ValidationError("output-attribute", error_msg); + } + MetaAttribute const* attribute = nullptr; + try { + attribute = MetaData::get_attribute_by_name(dataset_name, comp_name, attr_name); + } catch (PowerGridError const&) { + std::string error_msg = "Attribute '"; + error_msg += attr_name; + error_msg += "' not found in component '"; + error_msg += comp_name; + error_msg += "' of dataset '"; + error_msg += dataset_name; + error_msg += "'."; + throw CLI::ValidationError("output-attribute", error_msg); + } + options.output_component_attribute_filters[component].insert(attribute); + } + } +}; + +CLIResult parse_cli_options(int argc, char** argv, ClIOptions& options) { + std::string const version_str = std::string("Power Grid Model CLI\n Version: ") + PGM_version(); + CLI::App app{version_str}; + + CLI::Validator const existing_parent_dir_validator{ + [](std::string const& input) -> std::string { + std::filesystem::path const p{input}; + if (auto const parent = p.has_parent_path() ? p.parent_path() : std::filesystem::path{"."}; + parent.empty() || !std::filesystem::exists(parent) || !std::filesystem::is_directory(parent)) { + return "The parent directory of the specified path does not exist or is not a directory."; + } + return ""; + }, + "ExistingParentDirectory"}; + + app.set_version_flag("-v,--version", PGM_version()); + app.add_option("-i,--input", options.input_file, "Input file path")->required()->check(CLI::ExistingFile); + app.add_option("-b,--batch-update", options.batch_update_file, + "Batch update file path. Can be specified multiple times.\n" + "If multiple files are specified, the core will intepret them as the cartesian product of all " + "combinations of all scenarios in the list of batch datasets.") + ->check(CLI::ExistingFile); + app.add_option("-o,--output", options.output_file, "Output file path") + ->required() + ->check(existing_parent_dir_validator); + app.add_option("-f,--system-frequency", options.system_frequency, "System frequency in Hz, default is 50.0 Hz."); + app.add_option("-c,--calculation-type", options.calculation_type, "Calculation type") + ->transform(CLI::CheckedTransformer( + EnumMap{ + {"power_flow", PGM_power_flow}, + {"short_circuit", PGM_short_circuit}, + {"state_estimation", PGM_state_estimation}, + }, + CLI::ignore_case)); + app.add_option("-m,--calculation-method", options.calculation_method, "Calculation method") + ->transform(CLI::CheckedTransformer( + EnumMap{ + {"default", PGM_default_method}, + {"newton_raphson", PGM_newton_raphson}, + {"iterative_linear", PGM_iterative_linear}, + {"iterative_current", PGM_iterative_current}, + {"linear_current", PGM_linear_current}, + {"iec60909", PGM_iec60909}, + }, + CLI::ignore_case)); + app.add_flag("-s,--symmetric-calculation,!-a,!--asymmetric-calculation", options.symmetric_calculation, + "Use symmetric calculation (1) or not (0)"); + app.add_option("-e,--error-tolerance", options.error_tolerance, "Error tolerance for iterative calculations"); + app.add_option("-x,--max-iterations", options.max_iterations, + "Maximum number of iterations for iterative calculations"); + app.add_option("-t,--threading", options.threading, "Number of threads to use (-1 for automatic selection)"); + app.add_option("--short-circuit-voltage-scaling", options.short_circuit_voltage_scaling, + "Short circuit voltage scaling") + ->transform(CLI::CheckedTransformer( + EnumMap{ + {"minimum", PGM_short_circuit_voltage_scaling_minimum}, + {"maximum", PGM_short_circuit_voltage_scaling_maximum}, + }, + CLI::ignore_case)); + app.add_option("--tap-changing-strategy", options.tap_changing_strategy, "Tap changing strategy") + ->transform(CLI::CheckedTransformer( + EnumMap{ + {"disabled", PGM_tap_changing_strategy_disabled}, + {"any", PGM_tap_changing_strategy_any_valid_tap}, + {"min_voltage", PGM_tap_changing_strategy_min_voltage_tap}, + {"max_voltage", PGM_tap_changing_strategy_max_voltage_tap}, + {"fast_any", PGM_tap_changing_strategy_fast_any_tap}, + }, + CLI::ignore_case)); + auto& msgpack_flag = + *app.add_flag("--msgpack,--use-msgpack-output-serialization,!--json,!--use-json-output-serialization", + options.use_msgpack_output_serialization, "Use MessagePack output serialization"); + app.add_option("--indent,--output-json-indent", options.output_json_indent, + "Number of spaces to indent JSON output"); + auto& compact_flag = + *app.add_flag("--compact,--use-compact-serialization,!--no-compact,!--no-compact-serialization", + options.use_compact_serialization, "Use compact serialization (no extra whitespace)"); + std::vector output_components; + app.add_option("--oc,--output-component", output_components, + "Filter output to only include specified components (can be specified multiple times)"); + std::vector output_attributes; + app.add_option("--oa,--output-attribute", output_attributes, + "Filter output to only include specified attributes, in the format `component.attribute` (can be " + "specified multiple times)"); + app.add_flag("--verbose", options.verbose, "Enable verbose output"); + + app.callback(CLIPostCallback{.options = options, + .msgpack_flag = msgpack_flag, + .compact_flag = compact_flag, + .output_components = output_components, + .output_attributes = output_attributes}); + + try { + app.parse(argc, argv); + } catch (CLI::ParseError const& e) { + std::ostringstream stdout_stream; + std::ostringstream stderr_stream; + int const exit_code = app.exit(e, stdout_stream, stderr_stream); + return {.exit_code = exit_code, + .should_exit = true, + .stdout_message = stdout_stream.str(), + .stderr_message = stderr_stream.str()}; + } + + return {.exit_code = 0, .should_exit = false, .stdout_message = {}, .stderr_message = {}}; +} + +std::ostream& operator<<(std::ostream& os, ClIOptions const& options) { // NOSONAR(S2807) + os << "Run PGM with following CLI Options:\n"; + os << "Input file: " << options.input_file << "\n"; + os << "Batch update file: \n"; + for (auto const& file : options.batch_update_file) { + os << '\t' << file << "\n"; + } + os << "Output file: " << options.output_file << "\n"; + + os << "Calculation type: " << options.calculation_type << "\n"; + os << "Calculation method: " << options.calculation_method << "\n"; + os << "Symmetric calculation: " << options.symmetric_calculation << "\n"; + os << "Error tolerance: " << options.error_tolerance << "\n"; + os << "Max iterations: " << options.max_iterations << "\n"; + os << "Threading: " << options.threading << "\n"; + os << "Short circuit voltage scaling: " << options.short_circuit_voltage_scaling << "\n"; + os << "Tap changing strategy: " << options.tap_changing_strategy << "\n"; + + os << "Use msgpack output serialization: " << options.use_msgpack_output_serialization << "\n"; + os << "Output JSON indent: " << options.output_json_indent << "\n"; + os << "Use compact serialization: " << options.use_compact_serialization << "\n"; + os << "Verbose: " << options.verbose << "\n"; + return os; +} + +} // namespace power_grid_model_cpp diff --git a/power_grid_model_c/power_grid_model_cli_backend/src/pgm_calculation.cpp b/power_grid_model_c/power_grid_model_cli_backend/src/pgm_calculation.cpp new file mode 100644 index 0000000000..4fe4867eff --- /dev/null +++ b/power_grid_model_c/power_grid_model_cli_backend/src/pgm_calculation.cpp @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Contributors to the Power Grid Model project +// +// SPDX-License-Identifier: MPL-2.0 + +#include "cli_functions.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace power_grid_model_cpp { + +struct BatchDatasets { + explicit BatchDatasets(ClIOptions const& cli_options) { + if (!cli_options.is_batch) { + return; + } + for (auto const& [batch_file, format] : + std::views::zip(cli_options.batch_update_file, cli_options.batch_update_serialization_format)) { + datasets.emplace_back(load_dataset(batch_file, format)); + dataset_consts.emplace_back(datasets.back().dataset); + } + assert(!datasets.empty()); + for (auto it = dataset_consts.begin(); it != dataset_consts.end() - 1; ++it) { + it->set_next_cartesian_product_dimension(*(it + 1)); + } + batch_size = std::transform_reduce(datasets.begin(), datasets.end(), Idx{1}, std::multiplies{}, + [](OwningDataset const& ds) { return ds.dataset.get_info().batch_size(); }); + } + + DatasetConst const& head() const { + assert(!dataset_consts.empty()); + return dataset_consts.front(); + } + + Idx batch_size{1}; + std::vector datasets; + std::vector dataset_consts; +}; + +void pgm_calculation(ClIOptions const& cli_options) { + OwningDataset const input_dataset = load_dataset(cli_options.input_file, cli_options.input_serialization_format); + + BatchDatasets const batch_datasets{cli_options}; + + // NOLINTNEXTLINE(misc-const-correctness) + OwningDataset result_dataset{ + input_dataset, cli_options.calculation_type, cli_options.symmetric_calculation, + cli_options.is_batch, batch_datasets.batch_size, cli_options.output_component_attribute_filters}; + Model model{cli_options.system_frequency, input_dataset.dataset}; + Options calc_options{}; + calc_options.set_calculation_type(cli_options.calculation_type); + calc_options.set_calculation_method(cli_options.calculation_method); + calc_options.set_symmetric(static_cast(cli_options.symmetric_calculation)); + calc_options.set_err_tol(cli_options.error_tolerance); + calc_options.set_max_iter(cli_options.max_iterations); + calc_options.set_threading(cli_options.threading); + calc_options.set_short_circuit_voltage_scaling(cli_options.short_circuit_voltage_scaling); + calc_options.set_tap_changing_strategy(cli_options.tap_changing_strategy); + + if (cli_options.is_batch) { + model.calculate(calc_options, result_dataset.dataset, batch_datasets.head()); + } else { + model.calculate(calc_options, result_dataset.dataset); + } + + save_dataset(cli_options.output_file, result_dataset.dataset, + cli_options.use_msgpack_output_serialization ? PGM_msgpack : PGM_json, + cli_options.use_compact_serialization ? 1 : 0, cli_options.output_json_indent); +} + +} // namespace power_grid_model_cpp diff --git a/power_grid_model_c/power_grid_model_cpp/include/power_grid_model_cpp/dataset.hpp b/power_grid_model_c/power_grid_model_cpp/include/power_grid_model_cpp/dataset.hpp index e7c45ed7cc..a3a1dd8b8b 100644 --- a/power_grid_model_c/power_grid_model_cpp/include/power_grid_model_cpp/dataset.hpp +++ b/power_grid_model_c/power_grid_model_cpp/include/power_grid_model_cpp/dataset.hpp @@ -253,6 +253,10 @@ class AttributeBuffer { RawDataPtr get() { return pgm_type_func_selector(MetaData::attribute_ctype(attribute_), PtrGetter{*this}); } + MetaAttribute const* get_attribute() const { return attribute_; } + + template std::vector const& get_data_vector() const { return std::get>(buffer_); } + private: MetaAttribute const* attribute_{nullptr}; VariantType buffer_; diff --git a/pyproject.toml b/pyproject.toml index 03e7474384..97c7e0e0f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,9 @@ Discussion = "https://github.com/orgs/PowerGridModel/discussions" [project.entry-points."cmake.root"] power_grid_model = "power_grid_model._core.power_grid_model_c" +[project.scripts] +power-grid-model = "power_grid_model._core.power_grid_model_c.run_pgm_cli:main" + [tool.scikit-build] logging.level = "INFO" diff --git a/src/power_grid_model/_core/power_grid_model_c/run_pgm_cli.py b/src/power_grid_model/_core/power_grid_model_c/run_pgm_cli.py new file mode 100644 index 0000000000..6bebde59cb --- /dev/null +++ b/src/power_grid_model/_core/power_grid_model_c/run_pgm_cli.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +import ctypes +import os +import platform +import sys +from pathlib import Path + +from power_grid_model._core.power_grid_model_c.get_pgm_dll_path import get_pgm_dll_path + + +def get_pgm_cli_path() -> Path: + """ + Returns the path to the PGM CLI backend shared library. + """ + if os.environ.get("CONDA_PREFIX"): + raise ImportError( + "PGM CLI Python shim should not be called inside conda environment. Please call the executable directly." + ) + + dll_path = get_pgm_dll_path() + cli_dll_file: Path + platform_name = platform.uname().system + if platform_name == "Windows": + cli_dll_file = Path("power_grid_model_cli_backend.dll") + elif platform_name == "Darwin": + cli_dll_file = Path("libpower_grid_model_cli_backend.dylib") + elif platform_name == "Linux": + cli_dll_file = Path("libpower_grid_model_cli_backend.so") + else: + raise NotImplementedError(f"Unsupported platform: {platform_name}") + + return dll_path.parent / cli_dll_file + + +def _load_cli_backend() -> ctypes.CDLL: + cli_path = get_pgm_cli_path() + return ctypes.CDLL(str(cli_path)) + + +def _build_argv() -> tuple[int, ctypes.Array[ctypes.c_char_p]]: + argv = [arg.encode() for arg in sys.argv] + c_argv = (ctypes.c_char_p * len(argv))(*argv) + return len(argv), c_argv + + +def main() -> int: + backend = _load_cli_backend() + backend.PGM_cli_main.argtypes = [ + ctypes.c_int, + ctypes.POINTER(ctypes.c_char_p), + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ] + backend.PGM_cli_main.restype = ctypes.c_int + + argc, argv = _build_argv() + return backend.PGM_cli_main(argc, argv, None, None, None) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6e983b5a03..a7cb29b345 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,3 +10,6 @@ add_subdirectory("native_api_tests") add_subdirectory("cpp_unit_tests") add_subdirectory("cpp_validation_tests") add_subdirectory("benchmark_cpp") +add_subdirectory("cpp_cli_tests") + +add_test(PGMCLI ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/power-grid-model --version) diff --git a/tests/cpp_cli_tests/CMakeLists.txt b/tests/cpp_cli_tests/CMakeLists.txt new file mode 100644 index 0000000000..1af7026d2a --- /dev/null +++ b/tests/cpp_cli_tests/CMakeLists.txt @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +set(PROJECT_SOURCES "test_entry_point.cpp" "test_cli.cpp") + +add_executable(power_grid_model_cli_tests ${PROJECT_SOURCES}) + +target_link_libraries( + power_grid_model_cli_tests + PRIVATE + power_grid_model_cli_backend + power_grid_model_cpp + doctest::doctest + nlohmann_json + nlohmann_json::nlohmann_json +) + +doctest_discover_tests(power_grid_model_cli_tests) diff --git a/tests/cpp_cli_tests/test_cli.cpp b/tests/cpp_cli_tests/test_cli.cpp new file mode 100644 index 0000000000..235426233d --- /dev/null +++ b/tests/cpp_cli_tests/test_cli.cpp @@ -0,0 +1,590 @@ +// SPDX-FileCopyrightText: Contributors to the Power Grid Model project +// +// SPDX-License-Identifier: MPL-2.0 + +#define PGM_ENABLE_EXPERIMENTAL + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace power_grid_model_cpp { + +namespace { +namespace fs = std::filesystem; +using std::numbers::sqrt3; + +struct OutputCapture { + std::string stdout_message; + std::string stderr_message; +}; + +struct CLIArguments { + std::vector storage; + std::vector argv; + int argc{0}; +}; + +void capture_stdout(char const* msg, void* user_data) { + auto* capture = static_cast(user_data); + capture->stdout_message += msg; +} + +void capture_stderr(char const* msg, void* user_data) { + auto* capture = static_cast(user_data); + capture->stderr_message += msg; +} + +// namespace for hardcode json +namespace { + +constexpr std::string_view input_json = R"json({ + "version": "1.0", + "type": "input", + "is_batch": false, + "attributes": {}, + "data": { + "sym_load": [ + {"id": 2, "node": 0, "status": 1, "type": 0, "p_specified": 0, "q_specified": 0} + ], + "source": [ + {"id": 1, "node": 0, "status": 1, "u_ref": 1, "sk": 1e20} + ], + "node": [ + {"id": 0, "u_rated": 10e3} + ] + } +})json"; + +constexpr std::string_view batch_u_ref_json = R"json({ + "version": "1.0", + "type": "update", + "is_batch": true, + "attributes": {}, + "data": [ + { + "source": [ + {"u_ref": 0.9} + ] + }, + { + "source": [ + {"u_ref": 1.0} + ] + }, + { + "source": [ + {"u_ref": 1.1} + ] + } + ] +})json"; + +constexpr std::string_view batch_p_json = R"json({ + "version": "1.0", + "type": "update", + "is_batch": true, + "attributes": { "sym_load": ["p_specified"] }, + "data": [ + { + "sym_load": [ + [1e6] + ] + }, + { + "sym_load": [ + [2e6] + ] + }, + { + "sym_load": [ + [3e6] + ] + }, + { + "sym_load": [ + [4e6] + ] + } + ] +})json"; + +constexpr std::string_view batch_q_json = R"json({ + "version": "1.0", + "type": "update", + "is_batch": true, + "attributes": {}, + "data": [ + { + "sym_load": [ + {"q_specified": 0.1e6} + ] + }, + { + "sym_load": [ + {"q_specified": 0.2e6} + ] + }, + { + "sym_load": [ + {"q_specified": 0.3e6} + ] + }, + { + "sym_load": [ + {"q_specified": 0.4e6} + ] + }, + { + "sym_load": [ + {"q_specified": 0.5e6} + ] + } + ] +})json"; + +} // namespace + +fs::path tmp_path() { + // Get the system temp directory + fs::path const tmpdir = fs::temp_directory_path(); + // Return the path + return tmpdir / "pgm_cli_test"; +} + +fs::path input_path() { return tmp_path() / "input.json"; } +fs::path batch_u_ref_path() { return tmp_path() / "batch_u_ref.json"; } +fs::path batch_p_path() { return tmp_path() / "batch_p.json"; } +fs::path batch_q_path() { return tmp_path() / "batch_q.json"; } +fs::path batch_p_path_msgpack() { return tmp_path() / "batch_p.pgmb"; } +fs::path output_path(PGM_SerializationFormat format) { + return format == PGM_json ? tmp_path() / "output.json" : tmp_path() / "output.pgmb"; +} + +void clear_and_create_tmp_path() { + fs::path const cli_test_dir = tmp_path(); + + // Remove the dir if it exists (including contents) + if (fs::exists(cli_test_dir)) { + fs::remove_all(cli_test_dir); + } + + // Create the empty directory + if (!fs::create_directory(cli_test_dir)) { + throw std::runtime_error("Failed to create cli_test temp directory"); + } +} + +void save_data(std::string_view json_data, fs::path const& path, PGM_SerializationFormat format) { + if (std::ofstream ofs(path, std::ios::binary); ofs) { + if (format == PGM_json) { + ofs << json_data; + } else { + nlohmann::json const j = nlohmann::json::parse(json_data); + std::string msgpack_buffer; + nlohmann::json::to_msgpack(j, msgpack_buffer); + ofs << msgpack_buffer; + } + } else { + throw std::runtime_error("Failed to open file for writing: " + path.string()); + } + // try to read the file, discard results + load_dataset(path, format, true); +} + +void prepare_data() { + clear_and_create_tmp_path(); + save_data(input_json, input_path(), PGM_json); + save_data(batch_u_ref_json, batch_u_ref_path(), PGM_json); + save_data(batch_p_json, batch_p_path(), PGM_json); + save_data(batch_p_json, batch_p_path_msgpack(), PGM_msgpack); + save_data(batch_q_json, batch_q_path(), PGM_json); +} + +std::vector get_i_source_ref(bool is_batch) { + if (!is_batch) { + return {0.0}; + } + // 3-D batch update + double const u_rated = 10e3; + std::vector const u_ref{0.9, 1.0, 1.1}; + std::vector const p_specified{1e6, 2e6, 3e6, 4e6}; + std::vector const q_specified{0.1e6, 0.2e6, 0.3e6, 0.4e6, 0.5e6}; + Idx const size_u_ref = std::ssize(u_ref); + Idx const size_p_specified = std::ssize(p_specified); + Idx const size_q_specified = std::ssize(q_specified); + Idx const total_batch_size = size_u_ref * size_p_specified * size_q_specified; + + // calculate source current manually + std::vector i_source_ref(total_batch_size); + for (Idx i = 0; i < size_u_ref; ++i) { + for (Idx j = 0; j < size_p_specified; ++j) { + for (Idx k = 0; k < size_q_specified; ++k) { + Idx const index = i * size_p_specified * size_q_specified + j * size_q_specified + k; + double const s = std::abs(std::complex{p_specified[j], q_specified[k]}); + i_source_ref[index] = s / (sqrt3 * u_rated * u_ref[i]); + } + } + } + return i_source_ref; +} + +struct BufferRef { + PGM_SymmetryType symmetric{PGM_symmetric}; + bool use_attribute_buffer{false}; + Buffer const* row_buffer; + AttributeBuffer const* attribute_buffer; + + void check_i_source(std::vector const& i_source_ref) const { + Idx const batch_size = static_cast(i_source_ref.size()); + for (Idx idx = 0; idx < batch_size; ++idx) { + double const i_calculated = [this, idx]() { + if (use_attribute_buffer) { + if (symmetric == PGM_symmetric) { + auto const& data_vector = attribute_buffer->get_data_vector(); + return data_vector.at(idx); + } + // else: use attribute buffer with asymmetric data + auto const& data_vector = attribute_buffer->get_data_vector>(); + auto const& val_array = data_vector.at(idx); + CHECK(val_array[0] == doctest::Approx(val_array[1])); + CHECK(val_array[0] == doctest::Approx(val_array[2])); + return val_array[0]; + } + // else: use row buffer + if (symmetric == PGM_symmetric) { + double value{}; + row_buffer->get_value(PGM_def_sym_output_source_i, &value, idx, 0); + return value; + } + // else: use row buffer with asymmetric data + std::array val_array{}; + row_buffer->get_value(PGM_def_asym_output_source_i, val_array.data(), idx, 0); + CHECK(val_array[0] == doctest::Approx(val_array[1])); + CHECK(val_array[0] == doctest::Approx(val_array[2])); + return val_array[0]; + }(); + CHECK(i_calculated == doctest::Approx(i_source_ref.at(idx))); + } + } +}; + +struct CLITestCase { + bool is_batch{false}; + bool batch_p_msgpack{false}; + bool has_frequency{false}; + bool has_calculation_type{false}; + bool has_calculation_method{false}; + std::optional symmetry{}; + bool has_error_tolerance{false}; + bool has_max_iterations{false}; + bool has_threading{false}; + std::optional output_serialization{}; + std::optional output_json_indent{}; + std::optional output_compact_serialization{}; + bool component_filter{false}; + bool attribute_filter{false}; + std::string unknown_component{}; + std::string unknown_attribute{}; + std::string expected_error_substring{}; + + PGM_SerializationFormat get_output_format() const { + if (output_serialization.has_value()) { + return output_serialization.value(); + } + if (is_batch && batch_p_msgpack) { + return PGM_msgpack; + } + return PGM_json; + } + bool has_output_filter() const { return component_filter || attribute_filter; } + PGM_SymmetryType get_symmetry() const { + if (symmetry.has_value()) { + return symmetry.value(); + } + return PGM_symmetric; + } + bool output_columnar() const { + if (output_compact_serialization.has_value()) { + return output_compact_serialization.value(); + } + return get_output_format() == PGM_msgpack; + } + + CLIArguments build_argv() const { + CLIArguments args; + auto& argv = args.storage; + argv.emplace_back("power-grid-model"); + argv.emplace_back("-i"); + argv.emplace_back(input_path().string()); + if (is_batch) { + argv.emplace_back("-b"); + argv.emplace_back(batch_u_ref_path().string()); + argv.emplace_back("-b"); + argv.emplace_back((batch_p_msgpack ? batch_p_path_msgpack() : batch_p_path()).string()); + argv.emplace_back("-b"); + argv.emplace_back(batch_q_path().string()); + } + argv.emplace_back("-o"); + argv.emplace_back(output_path(get_output_format()).string()); + if (has_frequency) { + argv.emplace_back("--system-frequency"); + argv.emplace_back("50.0"); + } + if (has_calculation_type) { + argv.emplace_back("--calculation-type"); + argv.emplace_back("power_flow"); + } + if (has_calculation_method) { + argv.emplace_back("--calculation-method"); + argv.emplace_back("newton_raphson"); + } + if (symmetry.has_value()) { + argv.emplace_back(symmetry.value() == PGM_symmetric ? "-s" : "-a"); + } + if (has_error_tolerance) { + argv.emplace_back("--error-tolerance"); + argv.emplace_back("1e-8"); + } + if (has_max_iterations) { + argv.emplace_back("--max-iterations"); + argv.emplace_back("20"); + } + if (has_threading) { + argv.emplace_back("--threading"); + argv.emplace_back("-1"); + } + if (output_serialization.has_value()) { + if (output_serialization.value() == PGM_msgpack) { + argv.emplace_back("--msgpack"); + } else { + argv.emplace_back("--json"); + } + } + if (output_json_indent.has_value()) { + argv.emplace_back("--indent"); + argv.emplace_back(std::to_string(output_json_indent.value())); + } + if (output_compact_serialization.has_value()) { + if (output_compact_serialization.value()) { + argv.emplace_back("--compact"); + } else { + argv.emplace_back("--no-compact"); + } + } + if (component_filter && unknown_component.empty()) { + argv.emplace_back("--oc"); + argv.emplace_back("source"); + } + if (!unknown_component.empty()) { + argv.emplace_back("--oc"); + argv.emplace_back(unknown_component); + } + if (attribute_filter && unknown_attribute.empty()) { + argv.emplace_back("--oa"); + argv.emplace_back("source.i"); + } + if (!unknown_attribute.empty()) { + argv.emplace_back("--oa"); + argv.emplace_back(unknown_attribute); + } + argv.emplace_back("--verbose"); + args.argv.reserve(argv.size()); + for (auto& arg : argv) { + args.argv.push_back(arg.data()); + } + args.argc = static_cast(args.argv.size()); + return args; + } + + BufferRef get_source_buffer(OwningDataset const& dataset) const { + auto const& owning_memory = dataset.storage; + auto const& info = dataset.dataset.get_info(); + Idx const source_idx = info.component_idx("source"); + auto const* const row_buffer = [this, &owning_memory, &info, source_idx]() { + if (has_output_filter()) { + REQUIRE(info.n_components() == 1); + REQUIRE(source_idx == 0); + } + return &owning_memory.buffers[source_idx]; + }(); + auto const* const attribute_buffer = [this, &owning_memory, row_buffer, + source_idx]() -> AttributeBuffer const* { + if (output_columnar()) { + REQUIRE(row_buffer->get() == nullptr); + if (attribute_filter) { + REQUIRE(owning_memory.attribute_buffers[source_idx].size() == 1); + return owning_memory.attribute_buffers[source_idx].data(); + } + // else: search for 'i' attribute buffer + for (auto const& attr_buf : owning_memory.attribute_buffers[source_idx]) { + if (MetaData::attribute_name(attr_buf.get_attribute()) == "i") { + return &attr_buf; + } + } + DOCTEST_FAIL("Attribute 'i' buffer not found"); + } + // when no filter, buffer should not be nullptr, and return nullptr for attribute buffer + REQUIRE(row_buffer->get() != nullptr); + return nullptr; + }(); + return BufferRef{.symmetric = get_symmetry(), + .use_attribute_buffer = output_columnar(), + .row_buffer = row_buffer, + .attribute_buffer = attribute_buffer}; + } + + void check_results() const { + fs::path const out_path = output_path(get_output_format()); + OwningDataset const output_owning_dataset = load_dataset(out_path, get_output_format(), true); + auto const i_source_ref = get_i_source_ref(is_batch); + Idx const batch_size = output_owning_dataset.dataset.get_info().batch_size(); + REQUIRE(batch_size == std::ssize(i_source_ref)); + REQUIRE(is_batch == output_owning_dataset.dataset.get_info().is_batch()); + auto const buffer_ref = get_source_buffer(output_owning_dataset); + buffer_ref.check_i_source(i_source_ref); + } + + void run_command_and_check() const { + prepare_data(); + CLIArguments args = build_argv(); + OutputCapture capture; + INFO("CLI argc: ", args.argc); + int const ret = PGM_cli_main(args.argc, args.argv.data(), &capture_stdout, &capture_stderr, &capture); + INFO("CLI stdout content: ", capture.stdout_message); + INFO("CLI stderr content: ", capture.stderr_message); + if (!expected_error_substring.empty()) { + // Error case: expecting non-zero exit code + CHECK(ret != 0); + std::string const full_output = capture.stdout_message + capture.stderr_message; + CHECK(full_output.find(expected_error_substring) != std::string::npos); + } else { + // Success case: expecting zero exit code + REQUIRE(ret == 0); + check_results(); + } + } +}; + +} // namespace + +TEST_CASE("Test CLI version") { + prepare_data(); + CLIArguments args = [] { + CLIArguments cli_args; + auto& argv = cli_args.storage; + argv = {"power-grid-model", "--version"}; + cli_args.argv.reserve(argv.size()); + for (auto& arg : argv) { + cli_args.argv.push_back(arg.data()); + } + cli_args.argc = static_cast(cli_args.argv.size()); + return cli_args; + }(); + OutputCapture capture; + int const ret = PGM_cli_main(args.argc, args.argv.data(), &capture_stdout, &capture_stderr, &capture); + INFO("CLI stdout content: ", capture.stdout_message); + REQUIRE(ret == 0); + // Extract the first line + std::string const first_line = capture.stdout_message.substr(0, capture.stdout_message.find('\n')); + CHECK(first_line == PGM_version()); +} + +TEST_CASE("Test run CLI") { + std::vector const test_cases = { + // basic non-batch, symmetric, json + CLITestCase{}, + // explicit single-attribute defaults + CLITestCase{.is_batch = true}, + CLITestCase{.is_batch = true, .batch_p_msgpack = true}, + CLITestCase{.has_frequency = true}, + CLITestCase{.has_calculation_type = true}, + CLITestCase{.has_calculation_method = true}, + CLITestCase{.symmetry = PGM_asymmetric}, + CLITestCase{.has_error_tolerance = true}, + CLITestCase{.has_max_iterations = true}, + CLITestCase{.has_threading = true}, + CLITestCase{.output_serialization = PGM_msgpack}, + CLITestCase{.output_json_indent = 4}, + CLITestCase{.output_compact_serialization = true}, + CLITestCase{.component_filter = true}, + CLITestCase{.attribute_filter = true}, + // batch, asymmetric, msgpack + CLITestCase{.is_batch = true, .symmetry = PGM_asymmetric, .output_serialization = PGM_msgpack}, + // batch, symmetric, json, with all options set + CLITestCase{.is_batch = true, + .batch_p_msgpack = true, + .has_frequency = true, + .has_calculation_type = true, + .has_calculation_method = true, + .symmetry = PGM_symmetric, + .has_error_tolerance = true, + .has_max_iterations = true, + .has_threading = true, + .output_serialization = PGM_json, + .output_json_indent = 4, + .output_compact_serialization = true, + .component_filter = true, + .attribute_filter = true}, + // batch, asymmetric, msgpack, with component and attribute filter + CLITestCase{.is_batch = true, + .symmetry = PGM_asymmetric, + .output_serialization = PGM_msgpack, + .component_filter = true, + .attribute_filter = true}, + }; + for (auto const& test_case : test_cases) { + auto args = test_case.build_argv(); + SUBCASE(args.storage.front().c_str()) { test_case.run_command_and_check(); } + } +} + +TEST_CASE("Test CLI error handling") { + std::vector const error_test_cases = { + // Error case: Unknown component + CLITestCase{.unknown_component = "unknown_component", + .expected_error_substring = "Component 'unknown_component' not found in dataset"}, + // Error case: Unknown attribute with unknown component + CLITestCase{.unknown_attribute = "unknown_component.some_attribute", + .expected_error_substring = "Component 'unknown_component' not found in dataset"}, + // Error case: Unknown attribute with known component + CLITestCase{.unknown_attribute = "source.unknown_attribute", + .expected_error_substring = "Attribute 'unknown_attribute' not found in component 'source'"}, + // Error case: Malformed attribute (missing dot) + CLITestCase{.unknown_attribute = "invalid_format_no_dot", + .expected_error_substring = "not in the format 'component.attribute'"}, + // Error case: Malformed attribute (empty component) + CLITestCase{.unknown_attribute = ".attribute", + .expected_error_substring = "not in the format 'component.attribute'"}, + // Error case: Malformed attribute (empty attribute) + CLITestCase{.unknown_attribute = "component.", + .expected_error_substring = "not in the format 'component.attribute'"}, + }; + for (auto const& error_test_case : error_test_cases) { + SUBCASE(error_test_case.expected_error_substring.c_str()) { error_test_case.run_command_and_check(); } + } +} + +} // namespace power_grid_model_cpp diff --git a/tests/cpp_cli_tests/test_entry_point.cpp b/tests/cpp_cli_tests/test_entry_point.cpp new file mode 100644 index 0000000000..020ee88a48 --- /dev/null +++ b/tests/cpp_cli_tests/test_entry_point.cpp @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Contributors to the Power Grid Model project +// +// SPDX-License-Identifier: MPL-2.0 + +// main cpp file + +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN + +#include diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000000..9d3c3eb269 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + + +import subprocess + +from power_grid_model import __version__ + + +def test_cli_version(): + result = subprocess.run(["power-grid-model", "--version"], capture_output=True, text=True, check=True) + assert __version__ in result.stdout