[PR] Implement ConfigManager, DatabaseManager and ExamResultGenerator node #1

Merged
wessel merged 12 commits from 1-grade-generator/exam_result_generator into 1-grade-generator/master 2025-09-25 13:42:03 +02:00
26 changed files with 1233 additions and 104 deletions

View File

@@ -1,70 +1,69 @@
# Self-contained environment: ros2-assignments
# Exported on: Tue Sep 23 09:41:57 AM CEST 2025
# Exported on: Tue Sep 23 10:20:42 AM CEST 2025
# Original environment from: /home/wessel/.config/fish/environments/configs/ros2-assignments
# ROS2 development environment (requires distrobox)
# Environment: ros2-assignments
# First check if running inside distrobox
if not test -f /run/.containerenv; and test -z "$CONTAINER_ID"
echo (set_color red)"This ROS2 environment should only be run inside a distrobox container"(set_color normal)
return 1
end
# Check if a previous initialization has occurred
if test -n "$__ENV_INITIALIZED"
echo (set_color yellow)"Environment already initialized"(set_color normal)
return 0
end
# Mark as initialized
# Mark as initialized (only after distrobox check passes)
set -gx __ENV_INITIALIZED "1"
set -gx CURRENT_ENV "ros2-assignments"
# Check if running inside distrobox
if test -f /run/.containerenv; or test -n "$CONTAINER_ID"
# Source ROS2 setup files using bass
if type -q bass
bass source /opt/ros/jazzy/setup.bash
if test -f ./install/setup.bash
bass source ./install/setup.bash
end
else
echo (set_color red)"Error: bass is required for ROS2 environment. Install with: fisher install edc/bass"(set_color normal)
return 1
# Source ROS2 setup files using bass
if type -q bass
bass source /opt/ros/jazzy/setup.bash
if test -f ./install/setup.bash
bass source ./install/setup.bash
end
# Set environment variable for the prompt prefix
set -gx ROS2_ACTIVE 1
# Save the original prompt function if it exists
# Only save if we don't already have a backup or if current prompt is not from an environment
if not functions -q __env_orig_prompt
if functions -q fish_prompt
functions -c fish_prompt __env_orig_prompt
else
function __env_orig_prompt
echo -n (whoami)'@'(prompt_hostname)' '(set_color $fish_color_cwd)(prompt_pwd)(set_color normal)'> '
end
end
else
# If we already have a backup, we're switching environments
# No need to create a new backup
end
# Define new prompt with ROS2 prefix
function fish_prompt
echo -n (set_color magenta)'(ros2-assignments)'(set_color normal)' '
__env_orig_prompt
end
# ROS2 aliases and functions
alias cb="colcon build"
alias cbs="colcon build --symlink-install"
alias cbt="colcon build --packages-select"
alias ct="colcon test"
alias ctr="colcon test-result"
echo (set_color green)"Activated ROS2 environment: ros2-assignments"(set_color normal)
else
echo (set_color red)"This ROS2 environment should only be run inside a distrobox container"(set_color normal)
echo (set_color red)"Error: bass is required for ROS2 environment. Install with: fisher install edc/bass"(set_color normal)
return 1
end
# Set environment variable for the prompt prefix
set -gx ROS2_ACTIVE 1
# Save the original prompt function if it exists
# Only save if we don't already have a backup or if current prompt is not from an environment
if not functions -q __env_orig_prompt
if functions -q fish_prompt
functions -c fish_prompt __env_orig_prompt
else
function __env_orig_prompt
echo -n (whoami)'@'(prompt_hostname)' '(set_color $fish_color_cwd)(prompt_pwd)(set_color normal)'> '
end
end
else
# If we already have a backup, we're switching environments
# No need to create a new backup
end
# Define new prompt with ROS2 prefix
function fish_prompt
echo -n (set_color magenta)'(ros2-assignments)'(set_color normal)' '
__env_orig_prompt
end
# ROS2 aliases and functions
alias cb="colcon build"
alias cbs="colcon build --symlink-install"
alias cbt="colcon build --packages-select"
alias ct="colcon test"
alias ctr="colcon test-result"
echo (set_color green)"Activated ROS2 environment: ros2-assignments"(set_color normal)
# Custom deactivation function
function __env_custom_deactivate
# Remove ROS2-specific variables and aliases

134
doc/ConfigManager.md Normal file
View File

@@ -0,0 +1,134 @@
# ConfigManager (`assignments::one::ConfigManager`)
## Overview
The `ConfigManager` class is used to be able to store configuration values in a `TOML` file making it
possible to change project settings without the need of recompiling the codebase.
The [`toml++`](https://marzer.github.io/tomlplusplus/) library is used to parse TOML configuration
files and provides type-safe access to configuration parameters. It is automatically installed
using the `FetchContent_Declare` macro inside of the `CMakeLists.txt`.
## Implementation Details
### Dependencies
- **toml++**: Modern header-only C++17 TOML parser and serializer
- **rclcpp**: ROS2 C++ client library for logging
- **filesystem**: C++17 filesystem library for file operations
- **DatabaseConfig**: Custom data structure for database configuration
### Key Components
#### Constructor
```cpp
ConfigManager(rclcpp::Logger logger)
```
- Initializes the configuration manager making use of a ROS2 logger
- Calls `find_config_file()` and `load_config()` for automatic setup
- Automatically attempts to locate and load configuration from a list of pre-defined paths.
#### Configuration Loading
**`bool load_config(const std::string& config_file_path)`**
> Returns `true` on successful load, `false` on failure
- Uses `toml::parse_file()`
- Loads TOML configuration from specified file path
- Validates file existence before attempting to parse
- Error handling for:
- File not found errors
- TOML parsing errors
- General I/O exceptions
- Updates internal `loaded_` state flag
**`std::string find_config_file() const`**
> Returns first found configuration file path
> Returns empty string if no configuration file is found
- Automatically searches for configuration files in predefined locations
- Search paths (in order):
```cpp
std::vector<std::string> default_config_paths_ = {
"config.toml",
"./src/config.toml",
"../config.toml",
"../../config.toml",
"../../../config.toml",
"../../../../config.toml",
"/etc/ros2_grade_calculator/config.toml"
};
```
#### Configuration Access
**`std::optional<DatabaseConfig> get_database_config() const`**
> Returns `std::optional<DatabaseConfig>` for safe null handling
> Returns `std::nullopt` if:
> - Configuration is not loaded
> - Database section is missing
> - Parsing fails due to invalid format
- Calls `parse_database_config()` for actual parsing
**`bool is_loaded() const`**
> Returns `true` if configuration has been successfully loaded and parsed
- Getter for configuration load status
- Used by other components to verify configuration availability
### Configuration Parsing
**`DatabaseConfig parse_database_config(const toml::table& config) const`**
- parser for database configuration section
- Extracts all database-related parameters with defaults
#### Default Values and Fallbacks
- **host**: `"localhost"` - Local database server
- **port**: `5432` - Standard PostgreSQL port
- **dbname**: `"grades"` - Application-specific database
- **user**: `"postgres"` - Default PostgreSQL user
- **password**: `"postgres"` - Default PostgreSQL password
- **timeout**: `30` seconds - connection timeout
- **ssl**: `false` - Disabled by default for development
- **min_connections**: `1` - Minimal connection pool
- **max_connections**: `10` - connection pool limit
### Logging
- Uses ROS2 logger with `[CFG]` prefix for configuration operations
- Includes file paths in log messages
### Usage Examples
#### Automatic Configuration Loading
```cpp
// ConfigManager automatically finds and loads configuration
ConfigManager config_manager(node->get_logger());
if (config_manager.is_loaded()) {
auto db_config = config_manager.get_database_config();
if (db_config.has_value()) {
// Use database configuration
DatabaseManager db_manager(db_config.value());
}
}
```
### Configuration File Format
Example complete TOML configuration:
```toml
# Database connection settings
[database]
host = "localhost"
port = 5432
dbname = "grades"
user = "postgres"
password = "postgres"
timeout = 30
ssl = false
[database.pool]
min_connections = 1
max_connections = 10
```

125
doc/DatabaseManager.md Normal file
View File

@@ -0,0 +1,125 @@
# DatabaseManager (`assignments::one::DatabaseManager`)
## Overview
The `DatabaseManager` class is a PostgreSQL database interface for the ROS2 grade calculator.
It handles all database operations including connection management, table creation and data insertion.
## Implementation Details
### Dependencies
- **pqxx**: Modern C++ PostgreSQL library for database connectivity
- **rclcpp**: ROS2 C++ client library for logging
- **ConfigManager**: Configuration manager for database settings
### Key Components
#### Constructor
```cpp
DatabaseManager(rclcpp::Logger logger)
```
- Initializes the database manager with ROS2 logging
- Creates a ConfigManager instance for configuration handling
- Automatically calls `init_database()` to establish connection and setup
#### Connection Management
**`bool connect(const std::string& connection_string)`**
> Returns `true` on successful connection, `false` on failure
- Establishes connection to PostgreSQL database using connection information from the config TOML
- Connection string format: `"host=localhost port=5432 dbname=grades user=postgres password=postgres"`
**`bool is_connected() const`**
> Returns `true` if connection exists and is open
- Check for active database connection status
#### Database Initialization
**`void init_database()`**
- Database setup process
- Loads configuration from ConfigManager
- Establishes connection using configuration settings
- Creates necessary tables and inserts sample data
- Error handling for configuration and connection failures
**`void create_tables()`**
- Creates all required database tables using SQL queries from `SQLQueries.hpp`
- Tables created:
- `enrollments`: Student course enrollments
- `exam_results`: Individual exam scores
- `course_results`: Final course grades and statistics
- Uses transactions for atomic table creation
**`void insert_sample_data()`**
- Inserts predefined sample student data
### Data Operations
#### Student Course Management
**`std::vector<StudentCourse> queue_pending_combinations()`**
> Returns vector of StudentCourse objects for processing queue
- Gets all student-course combinations that need exam results generated
- Executes complex SQL query to find missing exam results
**`bool enroll_student_into_course(const StudentCourse& sc)`**
> Returns `true` on successful enrollment, `false` on failure
- Enrolls a student into a specific course
#### Exam Result Processing
**`bool store_exam_result(const std::string& student_name, const std::string& course_name, int grade)`**
- Stores individual exam results in the database
- Parameters:
- `student_name`: Name of the student
- `course_name`: Name of the course
- `grade`: Exam score (10-100)
**`bool store_final_course_result(const StudentCourse& sc, int exam_count, int final_grade)`**
- Stores calculated final course results
- Parameters:
- `sc`: StudentCourse object containing student and course names
- `exam_count`: Number of exams taken
- `final_grade`: Calculated final grade
- Used by grade calculation nodes for final result storage
#### Grade Retrieval
**`int get_final_course_grade(const StudentCourse& sc)`**
> Returns:
> - `> 0`: Valid final grade (rounded average)
> - `-1`: No exams taken or no results found
- Gets final calculated grade for a student-course combination
- Performs average calculation with proper rounding
- Used by nodes to check if final grading is complete
### Logging
- Uses ROS2 logger with `[DBS]` prefix for database operations
- Different log levels:
- `INFO`: Successful operations, connection status
- `ERROR`: SQL errors, connection failures, configuration issues
- `WARN`: Non-critical issues, missing configurations
### Usage Examples
#### Basic Database Setup
```cpp
// Create DatabaseManager with ROS2 logger
DatabaseManager db_manager(node->get_logger());
// Check connection status
if (db_manager.is_connected()) {
// Database ready for operations
bool success = db_manager.store_exam_result("Wessel", "ROS2", 85);
if (success) {
RCLCPP_INFO(logger, "Exam result stored successfully");
}
}
```

View File

@@ -0,0 +1,16 @@
# Interface Overview
**Publishers**
- Topic: `exam_results`
- Message Type: `g2_2025_interfaces::msg::Exam`
- Publishes generated exam results to downstream nodes
**Subscribers**
- Topic: `student_course_management`
- Message Type: `g2_2025_interfaces::msg::Student`
- Handles requests to add/remove student-course combinations
**Timers**
- Interval: 2 seconds
- Function: `generate_random_result()`
- Generates and publishes exam results at regular intervals

View File

@@ -0,0 +1,38 @@
# ExamResultGenerator (`assignments::one::exam_result_generator`)
## Overview
The `ExamResultGenerator` is the core node responsible for simulating exam result generation.
It maintains a queue of student-course combinations that need exam results, generates random
grades, and publishes them to the system.
#### Implementation Details
**Constructor**
```cpp
ExamResultGenerator()
```
- Initializes ROS2 node with name `exam_result_generator`
- Sets up random number generation infrastructure
- Creates DatabaseManager instance with node logger
- Establishes ROS2 publishers, subscribers, and timers
- Loads initial pending combinations from database
**Core Functions**
**`void queue_pending_combinations()`**
- Gets all student-course combinations needing exam results
- Populates operations queue from database
- Called at initialization and when database state changes
**`void generate_random_result()`**
- Main exam result generation executed by timer
- Selects random student-course combination from queue
- Stores result in database and publishes to ROS2 topic
**`void student_management_callback(const g2_2025_interfaces::msg::Student::SharedPtr msg)`**
- Toggles combinations in/out of processing queue
- Updates database with new enrollments
**`void add_student_course_combination(const StudentCourse& sc)`**
- Enrolls student into course via database
- Adds combination to processing queue

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
version: '3'
services:
postgres-db:
image: postgres:15
container_name: postgres-db
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
- POSTGRES_DB=grades
ports:
- "5432:5432"

12
src/config.toml Normal file
View File

@@ -0,0 +1,12 @@
[database]
host = "localhost"
port = 5432
dbname = "grades"
user = "postgres"
password = "postgres"
timeout = 30
ssl = false
[database.pool]
min_connections = 1
max_connections = 10

View File

@@ -5,21 +5,53 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
# external packages
include(FetchContent)
FetchContent_Declare(
tomlplusplus
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
GIT_TAG v3.4.0
)
FetchContent_MakeAvailable(tomlplusplus)
# find dependencies
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(g2_2025_interfaces REQUIRED)
add_executable(exam_result_generator src/exam_result_generator/main.cpp)
add_executable(final_grade_determinator src/final_grade_determinator/main.cpp)
add_executable(grade_calculator src/grade_calculator/main.cpp)
add_executable(retake_grade_determinator src/retake_grade_determinator/main.cpp)
add_executable(retake_scheduler src/retake_scheduler/main.cpp)
add_executable(exam_result_generator
src/exam_result_generator/Main.cpp
src/database/DatabaseManager.cpp
src/config/ConfigManager.cpp
src/exam_result_generator/nodes/ExamResultGenerator.cpp
)
target_include_directories(exam_result_generator PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/src/exam_result_generator
)
ament_target_dependencies(exam_result_generator rclcpp g2_2025_interfaces)
target_link_libraries(exam_result_generator pqxx pq tomlplusplus::tomlplusplus)
add_executable(final_grade_determinator
src/final_grade_determinator/main.cpp
)
ament_target_dependencies(final_grade_determinator rclcpp g2_2025_interfaces)
add_executable(grade_calculator
src/grade_calculator/main.cpp
)
ament_target_dependencies(grade_calculator rclcpp g2_2025_interfaces)
add_executable(retake_grade_determinator
src/retake_grade_determinator/main.cpp
)
ament_target_dependencies(retake_grade_determinator rclcpp g2_2025_interfaces)
add_executable(retake_scheduler
src/retake_scheduler/main.cpp
)
ament_target_dependencies(retake_scheduler rclcpp g2_2025_interfaces)
install (

View File

@@ -0,0 +1,112 @@
#include "ConfigManager.hpp"
#include <iostream>
#include <fstream>
namespace assignments::one {
ConfigManager::ConfigManager(rclcpp::Logger logger)
: logger_(logger), loaded_(false)
{
// Try to auto-load config from default location
std::string config_path = find_config_file();
if (!config_path.empty()) {
load_config(config_path);
}
}
bool ConfigManager::load_config(const std::string& config_file_path) {
try {
RCLCPP_INFO(logger_, "[CFG] '%s': loading configuration", config_file_path.c_str());
if (!std::filesystem::exists(config_file_path)) {
RCLCPP_ERROR(logger_, "[CFG] '%s': file does not exist", config_file_path.c_str());
return false;
}
config_ = toml::parse_file(config_file_path);
loaded_ = true;
RCLCPP_INFO(logger_, "[CFG] '%s': configuration loaded", config_file_path.c_str());
return true;
} catch (const toml::parse_error& e) {
RCLCPP_ERROR(logger_, "[CFG] '%s': failed to parse: %s", config_file_path.c_str(), e.what());
loaded_ = false;
return false;
} catch (const std::exception& e) {
RCLCPP_ERROR(logger_, "[CFG] '%s': failed to load: %s", config_file_path.c_str(), e.what());
loaded_ = false;
return false;
}
}
std::optional<DatabaseConfig> ConfigManager::get_database_config() const {
if (!loaded_ || !config_.has_value()) {
RCLCPP_ERROR(logger_, "[CFG] database configuration not loaded");
return std::nullopt;
}
try {
return parse_database_config(config_.value());
} catch (const std::exception& e) {
RCLCPP_ERROR(logger_, "[CFG] database configuration failed to parse: %s", e.what());
return std::nullopt;
}
}
bool ConfigManager::is_loaded() const {
return loaded_;
}
std::string ConfigManager::find_config_file() const {
// Look for config file in several locations
for (const auto& path : default_config_paths_) {
if (std::filesystem::exists(path)) {
RCLCPP_INFO(logger_, "[CFG] '%s': found configuration file", path.c_str());
return path;
}
}
RCLCPP_WARN(logger_, "[CFG] no configuration file found at default locations");
return "";
}
DatabaseConfig ConfigManager::parse_database_config(const toml::table& config) const {
DatabaseConfig db_config;
// Parse database section
auto database_section = config["database"];
if (!database_section) {
throw std::runtime_error("missing [database] section in configuration");
}
db_config.host = database_section["host"].value_or<std::string>("localhost");
db_config.port = database_section["port"].value_or<int>(5432);
db_config.dbname = database_section["dbname"].value_or<std::string>("grades");
db_config.user = database_section["user"].value_or<std::string>("postgres");
db_config.password = database_section["password"].value_or<std::string>("postgres");
db_config.timeout = database_section["timeout"].value_or<int>(30);
db_config.ssl = database_section["ssl"].value_or<bool>(false);
// Parse pool section if present
auto pool_section = database_section["pool"];
if (pool_section) {
db_config.min_connections = pool_section["min_connections"].value_or<int>(1);
db_config.max_connections = pool_section["max_connections"].value_or<int>(10);
} else {
db_config.min_connections = 1;
db_config.max_connections = 10;
}
RCLCPP_INFO(logger_, "[CFG] database config parsed - %s:%s@%s:%d",
db_config.user.c_str(),
db_config.dbname.c_str(),
db_config.host.c_str(),
db_config.port
);
return db_config;
}
} // namespace assignments::one

View File

@@ -0,0 +1,52 @@
/* ConfigManager.hpp
* Configuration management for the exam result generator
* Uses toml++ library to parse TOML configuration files
*
* Reviewed by: <x>
* Changelog:
* [23-09-2025] Wessel T: Created configuration manager class for TOML config loading
*/
#pragma once
#include <string>
#include <optional>
#include <filesystem>
#include <toml++/toml.h>
#include "rclcpp/rclcpp.hpp"
#include "DatabaseConfig.hpp"
namespace assignments::one {
class ConfigManager {
public:
explicit ConfigManager(rclcpp::Logger logger);
~ConfigManager() = default;
bool load_config(const std::string& config_file_path);
std::optional<DatabaseConfig> get_database_config() const;
bool is_loaded() const;
private:
rclcpp::Logger logger_;
std::optional<toml::table> config_;
bool loaded_ { false };
std::vector<std::string> default_config_paths_ = {
"config.toml",
"./src/config.toml",
"../config.toml",
"../../config.toml",
"../../../config.toml",
"../../../../config.toml",
"/etc/ros2_grade_calculator/config.toml"
};
std::string find_config_file() const;
DatabaseConfig parse_database_config(const toml::table& config) const;
};
} // namespace assignments::one

View File

@@ -0,0 +1,46 @@
/* DatabaseConfig.hpp
* Database configuration structure for the exam result generator
*
* Reviewed by: <x>
* Changelog:
* [23-09-2025] Wessel T: Create initial configuration structure
*/
#pragma once
#include <string>
namespace assignments::one {
struct DatabaseConfig {
std::string host;
int port;
std::string dbname;
std::string user;
std::string password;
int timeout;
bool ssl;
// Pool settings
int min_connections;
int max_connections;
std::string to_connection_string() const {
std::string conn_str =
"host=" + host +
" port=" + std::to_string(port) +
" dbname=" + dbname +
" user=" + user +
" password=" + password +
" connect_timeout=" + std::to_string(timeout);
if (ssl) {
conn_str += " sslmode=require";
} else {
conn_str += " sslmode=disable";
}
return conn_str;
}
};
} // namespace assignments::one

View File

@@ -0,0 +1,238 @@
#include "DatabaseManager.hpp"
#include <pqxx/pqxx>
#include "rclcpp/rclcpp.hpp"
#include "SQLQueries.hpp"
#include "config/ConfigManager.hpp"
namespace assignments::one {
DatabaseManager::DatabaseManager(rclcpp::Logger logger) : logger_(logger) {
config_manager_ = std::make_unique<ConfigManager>(logger_);
init_database();
}
bool DatabaseManager::is_connected() const {
return conn_ && conn_->is_open();
}
bool DatabaseManager::connect(const std::string& connection_string) {
try {
RCLCPP_INFO(logger_, "[DBS] connecting to PostgreSQL database...");
conn_ = std::make_unique<pqxx::connection>(connection_string);
if (conn_->is_open()) {
RCLCPP_INFO(logger_, "[DBS] successfully connected to database (%s)", conn_->dbname());
return true;
} else {
RCLCPP_ERROR(logger_, "[DBS] failed to open database connection");
return false;
}
} catch (const pqxx::sql_error &e) {
RCLCPP_ERROR(logger_, "[DBS] sql error: %s", e.what());
return false;
} catch (const std::exception &e) {
RCLCPP_ERROR(logger_, "[DBS] connection error: %s", e.what());
return false;
}
}
void DatabaseManager::init_database() {
if (!config_manager_ || !config_manager_->is_loaded()) {
RCLCPP_ERROR(logger_, "[DBS] configuration not loaded, cannot initialize database");
return;
}
auto db_config = config_manager_->get_database_config();
if (!db_config.has_value()) {
RCLCPP_ERROR(logger_, "[DBS] failed to get database configuration");
return;
}
std::string connection_string = db_config->to_connection_string();
if (connect(connection_string)) {
create_tables();
insert_sample_data();
}
}
std::vector<StudentCourse> DatabaseManager::queue_pending_combinations() {
std::vector<StudentCourse> combinations;
if (!is_connected()) {
return combinations;
}
try {
pqxx::work txn(*conn_);
auto result = txn.exec(SQL_SELECT_MISSING_RESULTS);
for (const auto& row : result) {
StudentCourse sc;
sc.student_name = row[0].as<std::string>();
sc.course_name = row[1].as<std::string>();
combinations.push_back(sc);
}
RCLCPP_INFO(logger_, "[DBS] queued %zu pending combinations", combinations.size());
} catch (const pqxx::sql_error &e) {
RCLCPP_ERROR(logger_, "[DBS] queueing combinations failed: %s", e.what());
} catch (const std::exception &e) {
RCLCPP_ERROR(logger_, "[DBS] database error: %s", e.what());
}
return combinations;
}
bool DatabaseManager::enroll_student_into_course(const StudentCourse& sc) {
if (!is_connected()) return false;
try {
pqxx::work txn(*conn_);
txn.exec_params(SQL_INSERT_STUDENT_ENROLLMENT, sc.student_name, sc.course_name);
txn.commit();
return true;
} catch (const pqxx::sql_error &e) {
RCLCPP_ERROR(logger_, "[DBS] enroll student into course failed: %s", e.what());
return false;
} catch (const std::exception &e) {
RCLCPP_ERROR(logger_, "[DBS] database error: %s", e.what());
return false;
}
}
bool DatabaseManager::store_exam_result(const std::string& student_name, const std::string& course_name, int grade) {
if (!is_connected()) return false;
try {
pqxx::work txn(*conn_);
txn.exec_params(
SQL_INSERT_EXAM_RESULT,
student_name, course_name, grade
);
txn.commit();
return true;
} catch (const pqxx::sql_error &e) {
RCLCPP_ERROR(logger_, "[DBS] store result failed: %s", e.what());
return false;
} catch (const std::exception &e) {
RCLCPP_ERROR(logger_, "[DBS] database error: %s", e.what());
return false;
}
}
bool DatabaseManager::store_final_course_result(
const StudentCourse& sc,
int exam_count,
int final_grade
) {
if (!is_connected()) return false;
try {
pqxx::work txn(*conn_);
txn.exec_params(
SQL_INSERT_FINAL_COURSE_RESULT,
sc.student_name, sc.course_name, exam_count, final_grade
);
txn.commit();
return true;
} catch (const pqxx::sql_error &e) {
RCLCPP_ERROR(logger_, "[DBS] store final course result failed: %s", e.what());
return false;
} catch (const std::exception &e) {
RCLCPP_ERROR(logger_, "[DBS] database error: %s", e.what());
return false;
}
}
void DatabaseManager::create_tables() {
if (!conn_ || !conn_->is_open()) return;
try {
pqxx::work txn(*conn_);
txn.exec(SQL_CREATE_ENROLLMENTS_TABLE);
txn.exec(SQL_CREATE_EXAM_RESULTS);
txn.exec(SQL_CREATE_COURSE_RESULTS);
txn.commit();
RCLCPP_INFO(logger_, "[DBS] database tables ready");
} catch (const pqxx::sql_error &e) {
RCLCPP_ERROR(logger_, "[DBS] CREATE TABLE failed: %s", e.what());
} catch (const std::exception &e) {
RCLCPP_ERROR(logger_, "[DBS] database error: %s", e.what());
}
}
void DatabaseManager::insert_sample_data() {
if (!is_connected()) return;
try {
pqxx::work txn(*conn_);
// Check if we already have data
auto result = txn.exec(SQL_SELECT_STUDENT_LIST);
int count = result[0][0].as<int>();
if (count == 0) {
for (const auto& [student, course] : sample_students_data_) {
txn.exec_params(SQL_INSERT_STUDENT_ENROLLMENT, student, course);
}
txn.commit();
RCLCPP_INFO(logger_, "[DBS] inserted sample student enrollment data");
}
} catch (const pqxx::sql_error &e) {
RCLCPP_ERROR(logger_, "[DBS] insert sample data failed: %s", e.what());
} catch (const std::exception &e) {
RCLCPP_ERROR(logger_, "[DBS] database error: %s", e.what());
}
}
int DatabaseManager::get_final_course_grade(const StudentCourse& sc) {
if (!is_connected()) return -1;
try {
pqxx::work txn(*conn_);
auto result = txn.exec_params(SQL_SELECT_STUDENT_COURSE_RESULTS, sc.student_name, sc.course_name);
if (result.size() == 1) {
int exam_count = result[0][0].as<int>();
if (exam_count == 0) {
return -1; // No exams taken
}
int avg_grade = static_cast<int>(result[0][1].as<double>() + 0.5);
return avg_grade;
} else {
return -1; // No results found
}
} catch (const pqxx::sql_error &e) {
RCLCPP_ERROR(logger_, "[DBS] get final course grade failed: %s", e.what());
return -1;
} catch (const std::exception &e) {
RCLCPP_ERROR(logger_, "[DBS] database error: %s", e.what());
return -1;
}
}
} // namespace assignments::one

View File

@@ -0,0 +1,56 @@
/* DatabaseManager.hpp
* Database manager for the exam result generator
*
* Reviewed by: <x>
* Changelog:
* [23-09-2025] Wessel T: Created database manager class for all DB operations
*/
#pragma once
#include <memory>
#include <vector>
#include <string>
#include <pqxx/pqxx>
#include "rclcpp/rclcpp.hpp"
#include "StudentCourse.hpp"
#include "config/ConfigManager.hpp"
namespace assignments::one {
class DatabaseManager {
public:
explicit DatabaseManager(rclcpp::Logger logger);
~DatabaseManager() = default;
bool connect(const std::string& connection_string);
bool is_connected() const;
// Table operations
void init_database();
void create_tables();
void insert_sample_data();
// Data operations
std::vector<StudentCourse> queue_pending_combinations();
bool store_exam_result(const std::string& student_name, const std::string& course_name, int grade);
bool enroll_student_into_course(const StudentCourse& sc);
bool store_final_course_result(const StudentCourse& sc, int exam_count, int final_grade);
int get_final_course_grade(const StudentCourse& sc);
private:
rclcpp::Logger logger_;
std::unique_ptr<pqxx::connection> conn_;
std::unique_ptr<ConfigManager> config_manager_;
std::vector<std::pair<std::string, std::string>> sample_students_data_ = {
{"Wessel", "ROS2"},
{"Vincent", "ROS2"},
{"Mohammed", "Differentieren"},
{"Tilmann", "Differentieren"},
};
};
} // namespace assignments::one

View File

@@ -0,0 +1,82 @@
/* SQLQueries.hpp
* SQL query definitions for the exam result generator
*
* Reviewed by: <x>
* Changelog:
* [23-09-2025] Wessel T: Created initial database state
*/
#pragma once
static const std::string SQL_CREATE_ENROLLMENTS_TABLE = R"(
CREATE TABLE IF NOT EXISTS student_enrollments (
id SERIAL PRIMARY KEY,
student_name VARCHAR(100) NOT NULL,
course_name VARCHAR(100) NOT NULL,
is_retake BOOLEAN DEFAULT FALSE,
enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
)";
// UNIQUE(student_name, course_name)
static const std::string SQL_CREATE_EXAM_RESULTS = R"(
CREATE TABLE IF NOT EXISTS exam_results (
id SERIAL PRIMARY KEY,
student_name VARCHAR(100) NOT NULL,
course_name VARCHAR(100) NOT NULL,
exam_grade INTEGER NOT NULL,
is_retake BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
)";
// UNIQUE(student_name, course_name)
static const std::string SQL_CREATE_COURSE_RESULTS = R"(
CREATE TABLE IF NOT EXISTS final_course_results (
id SERIAL PRIMARY KEY,
student_name VARCHAR(100) NOT NULL,
course_name VARCHAR(100) NOT NULL,
exam_count INTEGER NOT NULL,
final_grade INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(student_name, course_name)
);
)";
static const std::string SQL_SELECT_MISSING_RESULTS = R"(
SELECT se.student_name, se.course_name
FROM student_enrollments se
LEFT JOIN final_course_results fcr
ON se.student_name = fcr.student_name
AND se.course_name = fcr.course_name
WHERE fcr.id IS NULL
)";
static const std::string SQL_SELECT_STUDENT_COURSE_RESULTS = R"(
SELECT COUNT(*), AVG(exam_grade)
FROM exam_results
WHERE student_name = $1 AND course_name = $2
)";
static const std::string SQL_INSERT_EXAM_RESULT = R"(
INSERT INTO exam_results (student_name, course_name, exam_grade)
VALUES ($1, $2, $3)
)";
// ON CONFLICT (student_name, course_name)
// DO UPDATE SET exam_grade = EXCLUDED.exam_grade, created_at = CURRENT_TIMESTAMP
static const std::string SQL_INSERT_STUDENT_ENROLLMENT = R"(
INSERT INTO student_enrollments (student_name, course_name)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
)";
static const std::string SQL_SELECT_STUDENT_LIST = R"(
SELECT COUNT(*) FROM student_enrollments
)";
static const std::string SQL_INSERT_FINAL_COURSE_RESULT = R"(
INSERT INTO final_course_results (student_name, course_name, exam_count, final_grade)
VALUES ($1, $2, $3, $4)
)";
// ON CONFLICT (student_name, course_name)
// DO UPDATE SET exam_count = EXCLUDED.exam_count, final_grade = EXCLUDED.final_grade, created_at = CURRENT_TIMESTAMP

View File

@@ -0,0 +1,23 @@
/* StudentCourse.hpp
* Data structure for student-course combinations
*
* Reviewed by: <x>
* Changelog:
* [23-09-2025] Wessel T: Created initial structure
*/
#pragma once
#include <string>
namespace assignments::one {
struct StudentCourse {
std::string student_name;
std::string course_name;
bool operator==(const StudentCourse& other) const {
return student_name == other.student_name && course_name == other.course_name;
}
};
} // namespace assignments::one

View File

@@ -0,0 +1,21 @@
/* main.cpp
* Entry point for the exam result generator node
*
* Reviewed by: <x>
* Changelog:
* [23-09-2025] Wessel T: Simplified main.cpp to entry point only
*/
#include "rclcpp/rclcpp.hpp"
#include "nodes/ExamResultGenerator.hpp"
int main(int argc, char *argv[]) {
rclcpp::init(argc, argv);
auto node = std::make_shared<assignments::one::exam_result_generator::ExamResultGenerator>();
rclcpp::spin(node);
rclcpp::shutdown();
return 0;
}

View File

@@ -1,40 +0,0 @@
/* node_template.cpp
* Action server node template for ROS2
*
* Node description:
* Template action server that demonstrates action server implementation
* with goal handling, feedback publishing, and cancellation support
*
* Reviewed by: <x>
* Changelog:
* [04-09-2025] Wessel T: Implement template
*/
#include <cstdlib>
#include "rclcpp/rclcpp.hpp"
namespace lessons::zero::tmp {
class NodeTemplate : public rclcpp::Node {
public:
NodeTemplate()
: Node("node_template")
{
}
private:
};
} // namespace lessons::zero::template
int main(int argc,char *argv[]) {
rclcpp::init(argc,argv);
auto node = std::make_shared<lessons::zero::tmp::NodeTemplate>();
rclcpp::spin(node);
rclcpp::shutdown();
return 0;
}

View File

@@ -0,0 +1,112 @@
#include "ExamResultGenerator.hpp"
namespace assignments::one::exam_result_generator {
ExamResultGenerator::ExamResultGenerator()
: Node("exam_result_generator"),
gen_(rd_()),
grade_dist_(10, 100)
{
db_manager_ = std::make_unique<DatabaseManager>(this->get_logger());
// Load initial student/course combinations from database
queue_pending_combinations();
// Create publisher for exam results
exam_publisher_ = this->create_publisher<g2_2025_interfaces::msg::Exam>("exam_results", 10);
// Create subscriber for adding/removing student/course combinations
student_subscriber_ = this->create_subscription<g2_2025_interfaces::msg::Student>(
"student_course_management", 10,
std::bind(
&ExamResultGenerator::student_management_callback,
this,
std::placeholders::_1
)
);
// Create timer to generate random exam results every 2 seconds
timer_ = this->create_wall_timer(
std::chrono::seconds(2),
std::bind(&ExamResultGenerator::generate_random_result, this)
);
RCLCPP_INFO(this->get_logger(),
"exam_result_generator started, %zu pending operations",
operations_queue_.size()
);
}
void ExamResultGenerator::queue_pending_combinations() {
if (!db_manager_ || !db_manager_->is_connected()) return;
operations_queue_ = db_manager_->queue_pending_combinations();
}
void ExamResultGenerator::generate_random_result() {
if (operations_queue_.empty()) {
RCLCPP_WARN_THROTTLE(this->get_logger(), *this->get_clock(), 10000, "queue is empty");
return;
}
if (!db_manager_ || !db_manager_->is_connected()) {
RCLCPP_WARN(this->get_logger(), "no database connection");
return;
}
// Select random student/course combination
std::uniform_int_distribution<> index_dist(0, operations_queue_.size() - 1);
int random_index = index_dist(gen_);
auto selected = operations_queue_[random_index];
int grade = grade_dist_(gen_);
db_manager_->store_exam_result(selected.student_name, selected.course_name, grade);
// Publish exam result
auto exam_msg = g2_2025_interfaces::msg::Exam();
exam_msg.course_name = selected.course_name;
exam_msg.result = grade;
exam_publisher_->publish(exam_msg);
RCLCPP_INFO(this->get_logger(),
"generated grade: (%d) %s in %s",
grade, selected.student_name.c_str(), selected.course_name.c_str()
);
}
void ExamResultGenerator::student_management_callback(const g2_2025_interfaces::msg::Student::SharedPtr msg) {
StudentCourse sc;
sc.student_name = msg->student_name;
sc.course_name = msg->course_name;
auto it = std::find(operations_queue_.begin(), operations_queue_.end(), sc);
if (it != operations_queue_.end()) {
operations_queue_.erase(it);
RCLCPP_INFO(this->get_logger(), "removed from queue: %s - %s",
sc.student_name.c_str(), sc.course_name.c_str()
);
} else {
add_student_course_combination(sc);
RCLCPP_INFO(this->get_logger(), "added to queue: %s - %s",
sc.student_name.c_str(), sc.course_name.c_str()
);
}
}
void ExamResultGenerator::add_student_course_combination(const StudentCourse& sc) {
if (!db_manager_ || !db_manager_->is_connected()) {
return;
}
if (db_manager_->enroll_student_into_course(sc)) {
auto it = std::find(operations_queue_.begin(), operations_queue_.end(), sc);
if (it == operations_queue_.end()) {
operations_queue_.push_back(sc);
}
}
}
} // namespace assignments::one::exam_result_generator

View File

@@ -0,0 +1,51 @@
/* nodes/ExamResultGenerator.hpp
* Exam result generator node for the first assignment of the course EE at InHolland.
*
* Collects student/course combinations from database where exam results need to be generated.
* Randomly generates and broadcasts exam marks (10-100) every 2 seconds.
* Can receive messages to add/remove student/course combinations.
*
* Reviewed by: <x>
* Changelog:
* [23-09-2025] Wessel T: Initial Implementation
*/
#pragma once
#include <memory>
#include <string>
#include <random>
#include <vector>
#include "rclcpp/rclcpp.hpp"
#include "g2_2025_interfaces/msg/exam.hpp"
#include "g2_2025_interfaces/msg/student.hpp"
#include "database/DatabaseManager.hpp"
#include "database/StudentCourse.hpp"
namespace assignments::one::exam_result_generator {
class ExamResultGenerator : public rclcpp::Node {
public:
ExamResultGenerator();
private:
rclcpp::TimerBase::SharedPtr timer_;
rclcpp::Publisher<g2_2025_interfaces::msg::Exam>::SharedPtr exam_publisher_;
rclcpp::Subscription<g2_2025_interfaces::msg::Student>::SharedPtr student_subscriber_;
std::unique_ptr<DatabaseManager> db_manager_;
std::random_device rd_;
std::mt19937 gen_;
std::uniform_int_distribution<> grade_dist_;
std::vector<StudentCourse> operations_queue_;
void queue_pending_combinations();
void generate_random_result();
void student_management_callback(const g2_2025_interfaces::msg::Student::SharedPtr msg);
void add_student_course_combination(const StudentCourse& sc);
};
} // namespace lessons::zero::tmp

View File

@@ -8,12 +8,14 @@ endif()
# find dependencies
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
find_package(builtin_interfaces REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/Student.msg"
"msg/Exam.msg"
"srv/Exam.srv"
"srv/Exams.srv"
"action/Retake.action"
DEPENDENCIES builtin_interfaces
)
ament_export_dependencies(rosidl_default_runtime)

View File

@@ -1,5 +1,5 @@
string student_name
string lecture_name
string course_name
---
---
float32 result

View File

@@ -1,2 +1,3 @@
string lecture_name
float32 result
string course_name
int32 result
builtin_interfaces/Time timestamp

View File

@@ -1,2 +1,3 @@
string student_name
string lecture_name
string course_name
builtin_interfaces/Time timestamp

View File

@@ -10,7 +10,9 @@
<buildtool_depend>ament_cmake</buildtool_depend>
<build_depend>rosidl_default_generators</build_depend>
<build_depend>builtin_interfaces</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<exec_depend>builtin_interfaces</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<test_depend>ament_lint_auto</test_depend>

View File

@@ -1,5 +0,0 @@
# Request
string student_name
string lecture_name
---
float32 result

View File

@@ -0,0 +1,7 @@
# Request
string student_name
string course_name
int32[] exam_grades
---
# Response
int32 result # Final result