diff --git a/CITATION.cff b/CITATION.cff index be34d1fa..93ed647b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,5 +12,5 @@ authors: repository-code: 'https://github.com/physycom/DynamicalSystemFramework' url: 'https://physycom.github.io/DynamicalSystemFramework/' license: CC-BY-NC-SA-4.0 -version: 5.3.1 -date-released: '2026-03-06' +version: 5.3.2 +date-released: '2026-04-08' diff --git a/src/dsf/bindings.cpp b/src/dsf/bindings.cpp index b2397b1d..41556a0c 100644 --- a/src/dsf/bindings.cpp +++ b/src/dsf/bindings.cpp @@ -749,6 +749,7 @@ PYBIND11_MODULE(dsf_cpp, m) { pybind11::arg("save_average_stats") = false, pybind11::arg("save_street_data") = false, pybind11::arg("save_travel_data") = false, + pybind11::arg("save_agent_data") = false, "Configure data saving during simulation.\n\n" "Args:\n" " saving_interval: Interval in time steps between data saves\n" @@ -756,7 +757,8 @@ PYBIND11_MODULE(dsf_cpp, m) { "flow)\n" " save_street_data: Whether to save per-street data (density, speed, coil " "counts)\n" - " save_travel_data: Whether to save travel data (distance, travel time)") + " save_travel_data: Whether to save travel data (distance, travel time)\n" + " save_agent_data: Whether to save per-agent edge traversal data") .def( "summary", [](dsf::mobility::FirstOrderDynamics& self) { diff --git a/src/dsf/dsf.hpp b/src/dsf/dsf.hpp index f339db36..6c3f6c8d 100644 --- a/src/dsf/dsf.hpp +++ b/src/dsf/dsf.hpp @@ -9,7 +9,7 @@ static constexpr uint8_t DSF_VERSION_MAJOR = 5; static constexpr uint8_t DSF_VERSION_MINOR = 3; -static constexpr uint8_t DSF_VERSION_PATCH = 1; +static constexpr uint8_t DSF_VERSION_PATCH = 2; static auto const DSF_VERSION = std::format("{}.{}.{}", DSF_VERSION_MAJOR, DSF_VERSION_MINOR, DSF_VERSION_PATCH); diff --git a/src/dsf/mobility/FirstOrderDynamics.cpp b/src/dsf/mobility/FirstOrderDynamics.cpp index 5f1e0c2c..454c7964 100644 --- a/src/dsf/mobility/FirstOrderDynamics.cpp +++ b/src/dsf/mobility/FirstOrderDynamics.cpp @@ -893,6 +893,23 @@ namespace dsf::mobility { spdlog::info("Initialized travel_data table in the database."); } + void FirstOrderDynamics::m_initAgentDataTable() const { + if (!this->database()) { + throw std::runtime_error( + "No database connected. Call connectDataBase() before saving data."); + } + // Create table if it doesn't exist + this->database()->exec( + "CREATE TABLE IF NOT EXISTS agent_data (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "simulation_id INTEGER NOT NULL, " + "agent_id INTEGER NOT NULL, " + "edge_id INTEGER NOT NULL, " + "time_step_in INTEGER NOT NULL, " + "time_step_out INTEGER NOT NULL)"); + + spdlog::info("Initialized agent_data table in the database."); + } void FirstOrderDynamics::m_dumpSimInfo() const { // Dump simulation info (parameters) to the database, if connected if (!this->database()) { @@ -914,7 +931,8 @@ namespace dsf::mobility { "force_priorities BOOLEAN, " "save_avg_stats BOOLEAN, " "save_road_data BOOLEAN, " - "save_travel_data BOOLEAN)"); + "save_travel_data BOOLEAN, " + "save_agent_data BOOLEAN)"); createTableStmt.exec(); // Insert simulation parameters into the simulations table SQLite::Statement insertSimStmt( @@ -922,8 +940,9 @@ namespace dsf::mobility { "INSERT INTO simulations (id, name, speed_function, weight_function, " "weight_threshold, error_probability, passage_probability, " "mean_travel_distance_m, mean_travel_time_s, stagnant_tolerance_factor, " - "force_priorities, save_avg_stats, save_road_data, save_travel_data) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + "force_priorities, save_avg_stats, save_road_data, save_travel_data, " + "save_agent_data) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); insertSimStmt.bind(1, static_cast(this->id())); insertSimStmt.bind(2, this->name()); insertSimStmt.bind(3, this->m_speedFunctionDescription); @@ -968,6 +987,7 @@ namespace dsf::mobility { insertSimStmt.bind(12, this->m_bSaveAverageStats); insertSimStmt.bind(13, this->m_bSaveStreetData); insertSimStmt.bind(14, this->m_bSaveTravelData); + insertSimStmt.bind(15, this->m_bSaveAgentData); insertSimStmt.exec(); } void FirstOrderDynamics::m_dumpNetwork() const { @@ -1199,11 +1219,13 @@ namespace dsf::mobility { void FirstOrderDynamics::saveData(std::time_t const savingInterval, bool const saveAverageStats, bool const saveStreetData, - bool const saveTravelData) { + bool const saveTravelData, + bool const saveAgentData) { m_savingInterval = savingInterval; m_bSaveAverageStats = saveAverageStats; m_bSaveStreetData = saveStreetData; m_bSaveTravelData = saveTravelData; + m_bSaveAgentData = saveAgentData; // Initialize the required tables if (saveStreetData) { @@ -1215,17 +1237,21 @@ namespace dsf::mobility { if (saveTravelData) { m_initTravelDataTable(); } - + if (saveAgentData) { + m_initAgentDataTable(); + Street::acquireAgentData(); + } this->m_dumpSimInfo(); this->m_dumpNetwork(); spdlog::info( "Data saving configured: interval={}s, avg_stats={}, street_data={}, " - "travel_data={}", + "travel_data={}, agent_data={}", savingInterval, saveAverageStats, saveStreetData, - saveTravelData); + saveTravelData, + saveAgentData); } void FirstOrderDynamics::setDestinationNodes( @@ -1601,6 +1627,25 @@ namespace dsf::mobility { m_travelDTs.clear(); } + if (m_bSaveAgentData) { + auto agentData = Street::agentData(); + SQLite::Statement insertStmt(*this->database(), + "INSERT INTO agent_data (simulation_id, " + "agent_id, edge_id, time_step_in, time_step_out)" + "VALUES (?, ?, ?, ?, ?)"); + for (auto const& [edge_id, data] : agentData) { + for (auto const& [agent_id, ts_in, ts_out] : data) { + insertStmt.bind(1, simulationId); + insertStmt.bind(2, static_cast(agent_id)); + insertStmt.bind(3, static_cast(edge_id)); + insertStmt.bind(4, static_cast(ts_in)); + insertStmt.bind(5, static_cast(ts_out)); + insertStmt.exec(); + insertStmt.reset(); + } + } + } + if (m_bSaveAverageStats) { // Average Stats Table auto const validEdges = nValidEdges.load(); auto const edgeCount = static_cast(numEdges); @@ -1652,6 +1697,7 @@ namespace dsf::mobility { m_bSaveStreetData = false; m_bSaveTravelData = false; m_bSaveAverageStats = false; + m_bSaveAgentData = false; } } diff --git a/src/dsf/mobility/FirstOrderDynamics.hpp b/src/dsf/mobility/FirstOrderDynamics.hpp index 7f68eaee..15d3ef32 100644 --- a/src/dsf/mobility/FirstOrderDynamics.hpp +++ b/src/dsf/mobility/FirstOrderDynamics.hpp @@ -89,6 +89,7 @@ namespace dsf::mobility { bool m_bSaveStreetData{false}; bool m_bSaveTravelData{false}; bool m_bSaveAverageStats{false}; + bool m_bSaveAgentData{false}; private: /// @brief Kill an agent @@ -167,6 +168,15 @@ namespace dsf::mobility { /// - distance_m: The distance travelled by the agent in meters /// - travel_time_s: The travel time of the agent in seconds void m_initTravelDataTable() const; + /// @brief Initialize the agent data table. + /// This table contains the agent data of the agents. Columns are: + /// - id: The entry id (auto-incremented) + /// - simulation_id: The simulation id + /// - agent_id: The id of the agent + /// - edge_id: The id of the edge + /// - time_step_in: The time step of the data entry + /// - time_step_out: The time step of the data entry + void m_initAgentDataTable() const; /// @brief Dump simulation metadata into the database. /// @details Ensures the `simulations` table exists and inserts one row with the @@ -282,10 +292,12 @@ namespace dsf::mobility { /// @param saveAverageStats If true, saves the average stats of the simulation (default is false) /// @param saveStreetData If true, saves the street data (default is false) /// @param saveTravelData If true, saves the travel data of the agents (default is false) + /// @param saveAgentData If true, saves the individual data of the agents (default is false) void saveData(std::time_t const savingInterval, bool const saveAverageStats = false, bool const saveStreetData = false, - bool const saveTravelData = false); + bool const saveTravelData = false, + bool const saveAgentData = false); /// @brief Update the paths of the itineraries based on the given weight function /// @param throw_on_empty If true, throws an exception if an itinerary has an empty path (default is true) diff --git a/src/dsf/mobility/Street.cpp b/src/dsf/mobility/Street.cpp index d35cce61..59df0ba9 100644 --- a/src/dsf/mobility/Street.cpp +++ b/src/dsf/mobility/Street.cpp @@ -5,6 +5,11 @@ #include namespace dsf::mobility { + std::optional< + tbb::concurrent_unordered_map>>> + Street::m_agentData = std::nullopt; + void Street::m_updateLaneMapping(int const nLanes) { m_laneMapping.clear(); switch (nLanes) { @@ -152,9 +157,13 @@ namespace dsf::mobility { assert(!m_exitQueues[index].empty()); auto pAgent{m_exitQueues[index].extract_front()}; // Keep track of average speed - m_avgSpeeds.push_back(m_length / - (currentTime - m_agentsInsertionTimes[pAgent->id()])); + auto const insertionTime{m_agentsInsertionTimes[pAgent->id()]}; + m_avgSpeeds.push_back(m_length / (currentTime - insertionTime)); m_agentsInsertionTimes.erase(pAgent->id()); + if (m_agentData.has_value()) { + m_agentData->operator[](m_id).emplace_back( + pAgent->id(), insertionTime, currentTime); + } if (m_counter.has_value() && m_counterPosition == CounterPosition::EXIT) { ++(*m_counter); diff --git a/src/dsf/mobility/Street.hpp b/src/dsf/mobility/Street.hpp index 28098794..7559d89a 100644 --- a/src/dsf/mobility/Street.hpp +++ b/src/dsf/mobility/Street.hpp @@ -29,6 +29,8 @@ #include #include +#include + namespace dsf::mobility { class AgentComparator { @@ -58,6 +60,10 @@ namespace dsf::mobility { std::optional m_counter; CounterPosition m_counterPosition{CounterPosition::EXIT}; double m_stationaryWeight{1.0}; + static std::optional>>> + m_agentData; /// @brief Update the street's lane mapping /// @param nLanes The street's number of lanes @@ -87,6 +93,11 @@ namespace dsf::mobility { Street(Street const&) = delete; bool operator==(Street const& other) const; + static inline void acquireAgentData() { + m_agentData = tbb::concurrent_unordered_map< + Id, + std::vector>>(); + }; /// @brief Set the street's lane mapping /// @param laneMapping The street's lane mapping void setLaneMapping(std::vector const& laneMapping); @@ -154,6 +165,13 @@ namespace dsf::mobility { movingAgents() { return m_movingAgents; } + static inline auto agentData() { + if (!m_agentData.has_value()) { + throw std::runtime_error( + "agentData not initialized. Please call acquireAgentData() first."); + } + return std::move(*m_agentData); + } /// @brief Get the number of of moving agents, i.e. agents not yet enqueued /// @return std::size_t The number of moving agents std::size_t nMovingAgents() const; diff --git a/test/mobility/Test_dynamics.cpp b/test/mobility/Test_dynamics.cpp index d7e166c2..ace1207f 100644 --- a/test/mobility/Test_dynamics.cpp +++ b/test/mobility/Test_dynamics.cpp @@ -1305,6 +1305,50 @@ TEST_CASE("FirstOrderDynamics") { std::filesystem::remove(testDbPath); } + WHEN("We connect a database and configure saveData with agent data") { + dynamics.connectDataBase(testDbPath); + // Configure saving: interval=1, saveAgentData=true + dynamics.saveData(1, false, false, false, true); + + // Evolve a few times to generate edge crossing events + for (int iter = 0; iter < 50; ++iter) { + dynamics.evolve(true); + } + + THEN("The agent_data table is created with correct data") { + SQLite::Database db(testDbPath, SQLite::OPEN_READONLY); + SQLite::Statement query(db, "SELECT COUNT(*) FROM agent_data"); + REQUIRE(query.executeStep()); + CHECK(query.getColumn(0).getInt() >= 1); + + SQLite::Statement schema(db, "PRAGMA table_info(agent_data)"); + std::set columns; + while (schema.executeStep()) { + columns.insert(schema.getColumn(1).getString()); + } + CHECK(columns.count("id") == 1); + CHECK(columns.count("simulation_id") == 1); + CHECK(columns.count("agent_id") == 1); + CHECK(columns.count("edge_id") == 1); + CHECK(columns.count("time_step_in") == 1); + CHECK(columns.count("time_step_out") == 1); + + SQLite::Statement rows( + db, + "SELECT simulation_id, agent_id, edge_id, time_step_in, time_step_out " + "FROM agent_data"); + while (rows.executeStep()) { + CHECK(rows.getColumn(0).getInt() >= 0); + CHECK(rows.getColumn(1).getInt() >= 0); + CHECK(rows.getColumn(2).getInt() >= 0); + CHECK(rows.getColumn(3).getInt64() >= 0); + CHECK(rows.getColumn(4).getInt64() >= rows.getColumn(3).getInt64()); + } + } + + std::filesystem::remove(testDbPath); + } + WHEN("We connect a database and configure saveData with average stats") { dynamics.connectDataBase(testDbPath); // Configure saving: interval=1, saveAverageStats=true @@ -1339,7 +1383,7 @@ TEST_CASE("FirstOrderDynamics") { WHEN("We configure saveData with all options enabled") { dynamics.connectDataBase(testDbPath); // Configure saving: interval=1, all data types enabled - dynamics.saveData(1, true, true, true); + dynamics.saveData(1, true, true, true, true); // Add agents and evolve until some reach destination dynamics.addAgents(10, AgentInsertionMethod::RANDOM); @@ -1411,6 +1455,23 @@ TEST_CASE("FirstOrderDynamics") { CHECK(travelColumns.count("distance_m") == 1); CHECK(travelColumns.count("travel_time_s") == 1); + // Check agent_data table + SQLite::Statement agentQuery(db, "SELECT COUNT(*) FROM agent_data"); + REQUIRE(agentQuery.executeStep()); + CHECK(agentQuery.getColumn(0).getInt() >= 1); + + SQLite::Statement agentSchema(db, "PRAGMA table_info(agent_data)"); + std::set agentColumns; + while (agentSchema.executeStep()) { + agentColumns.insert(agentSchema.getColumn(1).getString()); + } + CHECK(agentColumns.count("id") == 1); + CHECK(agentColumns.count("simulation_id") == 1); + CHECK(agentColumns.count("agent_id") == 1); + CHECK(agentColumns.count("edge_id") == 1); + CHECK(agentColumns.count("time_step_in") == 1); + CHECK(agentColumns.count("time_step_out") == 1); + // Check simulations table SQLite::Statement simQuery( db, @@ -1436,6 +1497,7 @@ TEST_CASE("FirstOrderDynamics") { CHECK(simColumns.count("save_avg_stats") == 1); CHECK(simColumns.count("save_road_data") == 1); CHECK(simColumns.count("save_travel_data") == 1); + CHECK(simColumns.count("save_agent_data") == 1); // Check edges table exists SQLite::Statement edgesQuery(