diff --git a/.gitignore b/.gitignore index 9d02694..b5bad30 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ build/ # Mac specific files .DS_Store + +# Python +.venv/ diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 3c81500..f09040a 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -14,6 +14,7 @@ FetchContent_MakeAvailable(magic_enum) add_library(engine src/core/trajectory_db.cpp src/io/parquet_loader.cpp + src/io/workload_export.cpp src/pipeline/config.cpp src/pipeline/pipeline.cpp src/selection/rdp.cpp diff --git a/engine/configs/geolife.json b/engine/configs/geolife.json index a6aa57b..cabd7c5 100644 --- a/engine/configs/geolife.json +++ b/engine/configs/geolife.json @@ -1,5 +1,7 @@ { "data_path": "data/geolife.parquet", + "export_results": true, + "export_dir": "data/output/geolife", "seed": 42, "budget_shares": [0.0025, 0.005, 0.01, 0.02], "split": { diff --git a/engine/include/backend/engine/core/projection.hpp b/engine/include/backend/engine/core/projection.hpp index 36dc339..4b6ec3e 100644 --- a/engine/include/backend/engine/core/projection.hpp +++ b/engine/include/backend/engine/core/projection.hpp @@ -67,4 +67,20 @@ constexpr core::LocalPoint to_local_metres(double lat, double lon) noexcept { }; } +struct LatLon { + double lat; + double lon; +}; + +constexpr LatLon to_lat_lon(double x, double y) noexcept { + double dlat = y / detail::coeffs.lat1; + double dlon = x / detail::coeffs.lon1; + for (int i = 0; i < 5; ++i) { + dlon = x / (detail::coeffs.lon1 + detail::coeffs.lon_lat_cross * dlat); + dlat = (y - detail::coeffs.lon2 * (dlon * dlon)) / + (detail::coeffs.lat1 + detail::coeffs.lat2 * dlat); + } + return {.lat = origin_lat + dlat, .lon = origin_lon + dlon}; +} + } // namespace core \ No newline at end of file diff --git a/engine/include/backend/engine/evaluator/evaluator.hpp b/engine/include/backend/engine/evaluator/evaluator.hpp index c328d5c..72c4f2b 100644 --- a/engine/include/backend/engine/evaluator/evaluator.hpp +++ b/engine/include/backend/engine/evaluator/evaluator.hpp @@ -24,13 +24,18 @@ struct Scoretable { WhereAtScores where_at; }; +struct EvaluationResult { + Scoretable scores; + std::vector knn; + std::vector> range; +}; + class Evaluator { public: Evaluator(const core::TrajectoryDb &original, const core::TrajectoryDb &simplified, Workloads workloads = {}, WhereAtScoringConfig where_at_config = {}); - // Runs every evaluation - Scoretable evaluate() const; + EvaluationResult evaluate() const; // Each runs only its own evaluation KnnScores get_knn_scores() const; diff --git a/engine/include/backend/engine/evaluator/knn.hpp b/engine/include/backend/engine/evaluator/knn.hpp index b921f69..0486cd2 100644 --- a/engine/include/backend/engine/evaluator/knn.hpp +++ b/engine/include/backend/engine/evaluator/knn.hpp @@ -4,6 +4,7 @@ #include "backend/engine/query/knn.hpp" #include +#include #include #include @@ -15,8 +16,19 @@ struct KnnScores { std::size_t query_count = 0; }; -// Each score is averaged over the queries where it is defined, NaN if it is defined for none. -KnnScores evaluate_knn(const core::TrajectoryDb &compressed, const query::KnnWorkload &workload); +struct KnnAnswer { + std::int32_t query_trajectory_id; + std::vector neighbors; +}; + +struct KnnEvaluation { + KnnScores scores; + std::vector answers; +}; + +// Executes each query once and returns both scores and answers in workload entry order. +KnnEvaluation evaluate_knn(const core::TrajectoryDb &compressed, + const query::KnnWorkload &workload); } // namespace evaluator diff --git a/engine/include/backend/engine/evaluator/range.hpp b/engine/include/backend/engine/evaluator/range.hpp index cacaadc..d06b34f 100644 --- a/engine/include/backend/engine/evaluator/range.hpp +++ b/engine/include/backend/engine/evaluator/range.hpp @@ -4,14 +4,20 @@ #include "backend/engine/query/range_query.hpp" #include +#include +#include #include #include namespace evaluator { -// Recall averaged over the queries where it is defined, NaN if it is defined for none. -double evaluate_range(const core::TrajectoryDb &compressed, - std::span queries); +struct RangeEvaluation { + double recall = std::numeric_limits::quiet_NaN(); + std::vector> answers; +}; + +RangeEvaluation evaluate_range(const core::TrajectoryDb &compressed, + std::span queries); } // namespace evaluator diff --git a/engine/include/backend/engine/io/workload_export.hpp b/engine/include/backend/engine/io/workload_export.hpp new file mode 100644 index 0000000..9f29ac4 --- /dev/null +++ b/engine/include/backend/engine/io/workload_export.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "backend/engine/core/trajectory_db.hpp" +#include "backend/engine/evaluator/evaluator.hpp" +#include "backend/engine/query/knn.hpp" +#include "backend/engine/query/range_query.hpp" + +#include +#include +#include + +namespace io { + +void export_workloads(const std::filesystem::path &dir, const core::TrajectoryDb &database, + std::span range, const query::KnnWorkload &knn); + +void export_results(const std::filesystem::path &dir, std::string_view name, + const evaluator::EvaluationResult &evaluation); + +} // namespace io diff --git a/engine/include/backend/engine/pipeline/config.hpp b/engine/include/backend/engine/pipeline/config.hpp index 970f298..7b7c1c6 100644 --- a/engine/include/backend/engine/pipeline/config.hpp +++ b/engine/include/backend/engine/pipeline/config.hpp @@ -15,6 +15,8 @@ namespace pipeline { struct Config { std::filesystem::path data_path; + bool export_results = false; + std::filesystem::path export_dir = "output/queries"; std::uint64_t seed = 42; split::SplitConfig split; std::vector budget_shares{0.0025, 0.005, 0.01, 0.02}; diff --git a/engine/src/evaluator/evaluator.cpp b/engine/src/evaluator/evaluator.cpp index 61da791..c1ed296 100644 --- a/engine/src/evaluator/evaluator.cpp +++ b/engine/src/evaluator/evaluator.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace evaluator { @@ -19,20 +20,22 @@ Evaluator::Evaluator(const core::TrajectoryDb &original, const core::TrajectoryD } } -Scoretable Evaluator::evaluate() const { - return Scoretable{ - .knn = get_knn_scores(), - .range_recall = get_range_recall(), - .where_at = get_where_at_scores(), - }; +EvaluationResult Evaluator::evaluate() const { + auto knn = m_workloads.knn ? evaluate_knn(m_simplified, *m_workloads.knn) : KnnEvaluation{}; + auto range = evaluate_range(m_simplified, m_workloads.range); + return {.scores = {.knn = knn.scores, + .range_recall = range.recall, + .where_at = get_where_at_scores()}, + .knn = std::move(knn.answers), + .range = std::move(range.answers)}; } KnnScores Evaluator::get_knn_scores() const { - return m_workloads.knn ? evaluate_knn(m_simplified, *m_workloads.knn) : KnnScores{}; + return m_workloads.knn ? evaluate_knn(m_simplified, *m_workloads.knn).scores : KnnScores{}; } double Evaluator::get_range_recall() const { - return evaluate_range(m_simplified, m_workloads.range); + return evaluate_range(m_simplified, m_workloads.range).recall; } WhereAtScores Evaluator::get_where_at_scores() const { diff --git a/engine/src/evaluator/knn.cpp b/engine/src/evaluator/knn.cpp index fb1d25e..7fbbec6 100644 --- a/engine/src/evaluator/knn.cpp +++ b/engine/src/evaluator/knn.cpp @@ -89,7 +89,10 @@ double recall_at_k(const std::vector &original, namespace evaluator { -KnnScores evaluate_knn(const core::TrajectoryDb &compressed, const query::KnnWorkload &workload) { +KnnEvaluation evaluate_knn(const core::TrajectoryDb &compressed, + const query::KnnWorkload &workload) { + KnnEvaluation result; + result.answers.resize(workload.entries.size()); double recall_sum = 0.0; std::size_t recall_scored = 0; double topk_tau_sum = 0.0; @@ -100,6 +103,11 @@ KnnScores evaluate_knn(const core::TrajectoryDb &compressed, const query::KnnWor const auto &query = workload.entries[i]; const auto trajectory = workload.queries.trajectory(query.query_index); const auto simplified = query::knn(compressed, trajectory, workload.k); + auto &answer = result.answers[i]; + answer.query_trajectory_id = trajectory.trajectory_id; + answer.neighbors.reserve(simplified.size()); + for (const auto index : simplified) + answer.neighbors.push_back(compressed.trajectory(index).trajectory_id); recalls[i] = knn::recall_at_k(query.reference, simplified); topk_taus[i] = knn::kendalls_tau_topk(query.reference, simplified, workload.p); }); @@ -116,13 +124,13 @@ KnnScores evaluate_knn(const core::TrajectoryDb &compressed, const query::KnnWor } } - KnnScores scores; + auto &scores = result.scores; scores.query_count = workload.entries.size(); if (recall_scored != 0) scores.recall_at_k = recall_sum / static_cast(recall_scored); if (topk_tau_scored != 0) scores.kendalls_tau_topk = topk_tau_sum / static_cast(topk_tau_scored); - return scores; + return result; } } // namespace evaluator diff --git a/engine/src/evaluator/range.cpp b/engine/src/evaluator/range.cpp index 98f231c..e1e10de 100644 --- a/engine/src/evaluator/range.cpp +++ b/engine/src/evaluator/range.cpp @@ -9,14 +9,20 @@ namespace evaluator { -double evaluate_range(const core::TrajectoryDb &compressed, - std::span queries) { +RangeEvaluation evaluate_range(const core::TrajectoryDb &compressed, + std::span queries) { + RangeEvaluation result; + result.answers.resize(queries.size()); double sum = 0.0; std::size_t scored = 0; std::vector recalls(queries.size()); core::parallel_for(queries.size(), [&](std::size_t i) { const auto &range_query = queries[i]; const auto simplified = query::range(compressed, range_query.box); + auto &answer = result.answers[i]; + answer.reserve(simplified.size()); + for (const auto index : simplified) + answer.push_back(compressed.trajectory(index).trajectory_id); recalls[i] = range::recall(range_query.reference, simplified); }); for (const double recall : recalls) { @@ -25,9 +31,9 @@ double evaluate_range(const core::TrajectoryDb &compressed, ++scored; } } - if (scored == 0) - return std::numeric_limits::quiet_NaN(); - return sum / static_cast(scored); + if (scored != 0) + result.recall = sum / static_cast(scored); + return result; } } // namespace evaluator diff --git a/engine/src/io/workload_export.cpp b/engine/src/io/workload_export.cpp new file mode 100644 index 0000000..47ae206 --- /dev/null +++ b/engine/src/io/workload_export.cpp @@ -0,0 +1,151 @@ +#include "backend/engine/io/workload_export.hpp" + +#include "backend/engine/core/projection.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +namespace io { + +namespace { + +using json = nlohmann::json; + +std::vector trajectory_ids(const core::TrajectoryDb &database, + std::span indices) { + std::vector ids; + ids.reserve(indices.size()); + for (const auto i : indices) + ids.push_back(database.trajectory(i).trajectory_id); + return ids; +} + +void write_file(const json &data, const std::filesystem::path &path) { + if (path.has_parent_path()) + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path); + if (!out) + throw std::runtime_error("cannot open " + path.string()); + out << data.dump() << '\n'; + out.close(); + if (!out) + throw std::runtime_error("cannot write " + path.string()); +} + +void check_arrow(const arrow::Status &status) { + if (!status.ok()) + throw std::runtime_error(status.ToString()); +} + +template T unwrap(arrow::Result result) { + check_arrow(result.status()); + return std::move(result).ValueOrDie(); +} + +void write_parquet(const std::shared_ptr &table, const std::filesystem::path &path) { + if (path.has_parent_path()) + std::filesystem::create_directories(path.parent_path()); + const auto out = unwrap(arrow::io::FileOutputStream::Open(path.string())); + check_arrow(parquet::arrow::WriteTable(*table, arrow::default_memory_pool(), out)); + check_arrow(out->Close()); +} + +json knn_json(std::span answers) { + json rows = json::array(); + for (const auto &answer : answers) + rows.push_back({{"id", answer.query_trajectory_id}, {"neighbours", answer.neighbors}}); + return rows; +} + +void write_knn_parquet(std::span answers, + const std::filesystem::path &path) { + arrow::Int32Builder query_ids, neighbor_ids; + arrow::Int64Builder ranks; + for (const auto &answer : answers) { + for (std::size_t i = 0; i < answer.neighbors.size(); ++i) { + check_arrow(query_ids.Append(answer.query_trajectory_id)); + check_arrow(ranks.Append(static_cast(i) + 1)); + check_arrow(neighbor_ids.Append(answer.neighbors[i])); + } + } + const auto schema = arrow::schema({arrow::field("query_trajectory_id", arrow::int32()), + arrow::field("rank", arrow::int64()), + arrow::field("neighbor_trajectory_id", arrow::int32())}); + write_parquet(arrow::Table::Make(schema, {unwrap(query_ids.Finish()), unwrap(ranks.Finish()), + unwrap(neighbor_ids.Finish())}), + path); +} + +void write_range_parquet(std::span> answers, + const std::filesystem::path &path) { + arrow::Int64Builder query_indices; + arrow::Int32Builder trajectory_ids; + for (std::size_t i = 0; i < answers.size(); ++i) { + for (const auto id : answers[i]) { + check_arrow(query_indices.Append(static_cast(i))); + check_arrow(trajectory_ids.Append(id)); + } + } + const auto schema = arrow::schema({arrow::field("query_index", arrow::int64()), + arrow::field("trajectory_id", arrow::int32())}); + write_parquet(arrow::Table::Make( + schema, {unwrap(query_indices.Finish()), unwrap(trajectory_ids.Finish())}), + path); +} + +} // namespace + +void export_workloads(const std::filesystem::path &dir, const core::TrajectoryDb &database, + std::span range, const query::KnnWorkload &knn) { + json range_json = {{"type", "FeatureCollection"}, {"features", json::array()}}; + std::vector> range_answers; + range_answers.reserve(range.size()); + for (std::size_t n = 0; n < range.size(); ++n) { + const auto &[box, reference] = range[n]; + range_answers.push_back(trajectory_ids(database, reference)); + const std::pair corners[] = {{box.x_min, box.y_min}, + {box.x_max, box.y_min}, + {box.x_max, box.y_max}, + {box.x_min, box.y_max}, + {box.x_min, box.y_min}}; + json ring = json::array(); + for (const auto [x, y] : corners) { + const auto corner = core::to_lat_lon(x, y); + ring.push_back({corner.lon, corner.lat}); + } + range_json["features"].push_back( + {{"type", "Feature"}, + {"properties", + {{"query", n}, + {"t_min", box.t_min}, + {"t_max", box.t_max}, + {"ids", range_answers.back()}}}, + {"geometry", {{"type", "Polygon"}, {"coordinates", json::array({ring})}}}}); + } + write_file(range_json, dir / "range.geojson"); + + write_range_parquet(range_answers, dir / "range_original.parquet"); + std::vector answers; + answers.reserve(knn.entries.size()); + for (const auto &entry : knn.entries) + answers.push_back({knn.queries.trajectory(entry.query_index).trajectory_id, + trajectory_ids(database, entry.reference)}); + write_file({{"k", knn.k}, {"queries", knn_json(answers)}}, dir / "knn_queries.json"); + write_knn_parquet(answers, dir / "knn_original.parquet"); +} + +void export_results(const std::filesystem::path &dir, std::string_view name, + const evaluator::EvaluationResult &evaluation) { + write_file(evaluation.range, dir / ("range_" + std::string(name) + ".json")); + write_file(knn_json(evaluation.knn), dir / ("knn_" + std::string(name) + ".json")); + write_range_parquet(evaluation.range, dir / ("range_" + std::string(name) + ".parquet")); + write_knn_parquet(evaluation.knn, dir / ("knn_" + std::string(name) + ".parquet")); +} + +} // namespace io diff --git a/engine/src/pipeline/config.cpp b/engine/src/pipeline/config.cpp index 7edad0e..ab667c3 100644 --- a/engine/src/pipeline/config.cpp +++ b/engine/src/pipeline/config.cpp @@ -86,14 +86,18 @@ void parse_where_at_scoring_config(Config &config, const json &scoring) { Config parse_config(const json &data) { validate_keys(data, - {"data_path", "seed", "budget_shares", "algorithms", "split", "knn", "range", - "where_at", "where_at_scoring"}, + {"data_path", "export_results", "export_dir", "seed", "budget_shares", + "algorithms", "split", "knn", "range", "where_at", "where_at_scoring"}, "root"); Config config; if (!data.contains("data_path")) throw std::invalid_argument("config: data_path is required"); config.data_path = data["data_path"].get(); + config.export_results = data.value("export_results", config.export_results); + config.export_dir = data.value("export_dir", config.export_dir.string()); + if (config.export_results && config.export_dir.empty()) + throw std::invalid_argument("config: export_dir must not be empty when export is enabled"); // Every other key is optional and falls back to the struct's default config.budget_shares = data.value("budget_shares", config.budget_shares); diff --git a/engine/src/pipeline/pipeline.cpp b/engine/src/pipeline/pipeline.cpp index 24b9695..197e44a 100644 --- a/engine/src/pipeline/pipeline.cpp +++ b/engine/src/pipeline/pipeline.cpp @@ -4,6 +4,7 @@ #include "backend/engine/core/trajectory_db.hpp" #include "backend/engine/evaluator/evaluator.hpp" #include "backend/engine/io/parquet_loader.hpp" +#include "backend/engine/io/workload_export.hpp" #include "backend/engine/query/knn.hpp" #include "backend/engine/query/range_query.hpp" #include "backend/engine/query/where_at_query.hpp" @@ -14,7 +15,9 @@ #include #include +#include #include +#include #include namespace pipeline { @@ -69,6 +72,11 @@ void run(const Config &config) { const auto range = query::generate_range_queries(database, config.range, config.seed); std::cout << "range: " << range.queries.size() << " queries\n"; + if (config.export_results) { + io::export_workloads(config.export_dir, database, range.queries, knn_workload); + std::cout << "workloads written to " << config.export_dir.string() << '\n'; + } + for (const double share : config.budget_shares) { const auto budgets = allocation::create_trajectory_budgets( database, allocation::AllocatorType::SizeProportional, total_budget(database, share)); @@ -83,7 +91,14 @@ void run(const Config &config) { database, compressed, {.knn = &knn_workload, .where_at = where_at.queries, .range = range.queries}, config.where_at_scoring); - print_scores(evaluator.evaluate()); + const auto evaluation = evaluator.evaluate(); + print_scores(evaluation.scores); + + if (config.export_results) { + const std::string name = + std::format("{}_{:g}pct", magic_enum::enum_name(algorithm), share * 100); + io::export_results(config.export_dir, name, evaluation); + } } } } diff --git a/engine/src/query/knn.cpp b/engine/src/query/knn.cpp index 790fc2d..07dbfbb 100644 --- a/engine/src/query/knn.cpp +++ b/engine/src/query/knn.cpp @@ -314,7 +314,7 @@ KnnWorkload generate_knn_queries(const core::TrajectoryDb &original, } std::vector picked(query_pool.size()); - std::ranges::iota(picked.begin(), picked.end(), 0); + std::iota(picked.begin(), picked.end(), 0); if (config.query_count != 0 && config.query_count < picked.size()) { std::mt19937_64 rng(seed); std::ranges::shuffle(picked, rng); diff --git a/engine/tests/config_test.cpp b/engine/tests/config_test.cpp index da924e6..1aaf259 100644 --- a/engine/tests/config_test.cpp +++ b/engine/tests/config_test.cpp @@ -118,3 +118,31 @@ TEST(Config, RejectsUnknownKeysAndNestedSeeds) { std::invalid_argument); } } + +TEST(Config, ExportIsExplicitlyEnabled) { + EXPECT_FALSE(pipeline::load_config( + write_config("export_default.json", R"({"data_path":"data.parquet"})")) + .export_results); + EXPECT_TRUE( + pipeline::load_config(write_config("export_enabled.json", + R"({"data_path":"data.parquet","export_results":true})")) + .export_results); + EXPECT_THROW( + pipeline::load_config(write_config( + "export_invalid.json", R"({"data_path":"data.parquet","export_results":"yes"})")), + std::invalid_argument); +} + +TEST(Config, ExportDirectoryDoesNotEnableExport) { + const auto defaults = pipeline::load_config( + write_config("export_path_default.json", R"({"data_path":"data.parquet"})")); + EXPECT_EQ(defaults.export_dir, "output/queries"); + const auto custom = pipeline::load_config(write_config( + "export_path_custom.json", R"({"data_path":"data.parquet","export_dir":"output/custom"})")); + EXPECT_EQ(custom.export_dir, "output/custom"); + EXPECT_FALSE(custom.export_results); + EXPECT_THROW(pipeline::load_config(write_config( + "export_path_empty.json", + R"({"data_path":"data.parquet","export_results":true,"export_dir":""})")), + std::invalid_argument); +} diff --git a/engine/tests/evaluator_test.cpp b/engine/tests/evaluator_test.cpp index 46dfef5..64ded79 100644 --- a/engine/tests/evaluator_test.cpp +++ b/engine/tests/evaluator_test.cpp @@ -26,7 +26,7 @@ TEST(Evaluator, CollectsBothWhereAtScoresWithoutUnconfiguredQueries) { const auto db = original(); const auto workload = query::generate_where_at_queries(db); evaluator::Evaluator evaluator(db, db, {.where_at = workload.queries}); - const auto scores = evaluator.evaluate(); + const auto scores = evaluator.evaluate().scores; EXPECT_DOUBLE_EQ(scores.where_at.within_radius, 1); EXPECT_DOUBLE_EQ(scores.where_at.average_hit, 1); EXPECT_TRUE(std::isnan(scores.knn.kendalls_tau_topk)); @@ -42,7 +42,7 @@ TEST(Evaluator, DelegatesToWhereAtModule) { const auto expected = evaluator::evaluate_where_at(compressed, workload.queries, config); evaluator::Evaluator evaluator(db, compressed, {.where_at = workload.queries}, config); EXPECT_DOUBLE_EQ(evaluator.get_where_at_scores().within_radius, expected.within_radius); - EXPECT_DOUBLE_EQ(evaluator.evaluate().where_at.average_hit, expected.average_hit); + EXPECT_DOUBLE_EQ(evaluator.evaluate().scores.where_at.average_hit, expected.average_hit); } TEST(Evaluator, ReusesSameWorkloadAcrossDatabases) { @@ -58,7 +58,7 @@ TEST(Evaluator, ReusesSameWorkloadAcrossDatabases) { TEST(Evaluator, NoWorkloadsReturnsUndefinedScores) { const auto db = original(); evaluator::Evaluator evaluator(db, db); - const auto scores = evaluator.evaluate(); + const auto scores = evaluator.evaluate().scores; EXPECT_TRUE(std::isnan(scores.knn.kendalls_tau_topk)); EXPECT_TRUE(std::isnan(scores.knn.recall_at_k)); EXPECT_TRUE(std::isnan(scores.range_recall)); @@ -80,7 +80,7 @@ TEST(Evaluator, AveragesRangeRecallOverQueries) { evaluator::Evaluator simplified(db, compressed, {.range = queries}); EXPECT_DOUBLE_EQ(baseline.get_range_recall(), 1); EXPECT_DOUBLE_EQ(simplified.get_range_recall(), 0.5); - EXPECT_DOUBLE_EQ(simplified.evaluate().range_recall, 0.5); + EXPECT_DOUBLE_EQ(simplified.evaluate().scores.range_recall, 0.5); } TEST(Evaluator, RejectsDifferentTrajectoryCountsAndIds) { @@ -97,7 +97,8 @@ TEST(Evaluator, CollectsKnnAndWhereAtScoresTogether) { const auto knn = query::generate_knn_queries(db, db, {.k = 2, .query_count = 0}); evaluator::Evaluator evaluator(db, db, {.knn = &knn, .where_at = where_at.queries}); - const auto scores = evaluator.evaluate(); + const auto scores = evaluator.evaluate().scores; + EXPECT_DOUBLE_EQ(scores.knn.kendalls_tau_topk, 1); EXPECT_DOUBLE_EQ(scores.knn.recall_at_k, 1); EXPECT_DOUBLE_EQ(scores.knn.kendalls_tau_topk, 1); EXPECT_EQ(scores.knn.query_count, 2u); @@ -112,7 +113,7 @@ TEST(KnnScores, AveragesOverQueriesWithPartialOverlap) { std::vector(8, 39.95), {116.35, 116.35, 116.36, 116.36, 116.37, 116.37, 116.38, 116.38}); const query::KnnWorkload workload{db, {{0, {0, 1}}, {0, {0, 2}}, {0, {2, 3}}}, 2}; - const auto scores = evaluator::evaluate_knn(db, workload); + const auto scores = evaluator::evaluate_knn(db, workload).scores; EXPECT_EQ(scores.query_count, 3u); // the compressed answer is {0, 1}: identical, one swap-in (4/5 of max), disjoint EXPECT_DOUBLE_EQ(scores.kendalls_tau_topk, (1.0 + 0.8 + 0.0) / 3); @@ -131,7 +132,7 @@ TEST(KnnScores, TopkTauScoresPairsOutsideTheOverlap) { TEST(KnnScores, EmptyWorkloadHasZeroCoverageAndUndefinedScores) { const auto db = original(); - const auto scores = evaluator::evaluate_knn(db, {db, {}, 2}); + const auto scores = evaluator::evaluate_knn(db, {db, {}, 2}).scores; EXPECT_EQ(scores.query_count, 0u); EXPECT_TRUE(std::isnan(scores.kendalls_tau_topk)); EXPECT_TRUE(std::isnan(scores.recall_at_k)); diff --git a/visualization/README.md b/visualization/README.md new file mode 100644 index 0000000..31cd9a3 --- /dev/null +++ b/visualization/README.md @@ -0,0 +1,11 @@ +# Heat map + +Run from the repository root: + +```sh +python3 -m venv visualization/.venv +visualization/.venv/bin/pip install -r visualization/requirements.txt +visualization/.venv/bin/python visualization/heatmap.py +``` + +This reads `data/geolife.parquet` and writes `output/geojson/heatmap.geojson.gz`. diff --git a/visualization/heatmap.py b/visualization/heatmap.py new file mode 100644 index 0000000..1c64de2 --- /dev/null +++ b/visualization/heatmap.py @@ -0,0 +1,69 @@ +import argparse +import gzip +import json +import math +from pathlib import Path + +import numpy as np +import pyarrow.parquet as pq + +WEEK = 7 * 24 * 60 * 60 +FIRST_MONDAY = 4 * 24 * 60 * 60 + +parser = argparse.ArgumentParser() +parser.add_argument("--parquet", default="data/geolife.parquet") +parser.add_argument("--out", default="output/geojson") +parser.add_argument("--cell", type=float, default=500.0, help="cell size in metres") +args = parser.parse_args() + +table = pq.read_table(args.parquet, columns=["trajectory_id", "t", "lat", "lon"]) +tid = table["trajectory_id"].to_numpy().astype(np.int64) +t = table["t"].to_numpy() +lat = table["lat"].to_numpy() +lon = table["lon"].to_numpy() + +lat_min, lon_min = lat.min(), lon.min() +lat_step = args.cell / 111_320 +lon_step = lat_step / math.cos(math.radians(np.median(lat))) + +row = ((lat - lat_min) // lat_step).astype(np.int64) +col = ((lon - lon_min) // lon_step).astype(np.int64) +cols = int(col.max()) + 1 +cell = row * cols + col +week = (t - FIRST_MONDAY) // WEEK + + +def counts(key): + keys, points = np.unique(key, return_counts=True) + ids = tid.max() + 1 + _, trajectories = np.unique(np.unique(key * ids + tid) // ids, return_counts=True) + return keys.tolist(), points.tolist(), trajectories.tolist() + + +def feature(c, properties): + south = round(lat_min + (c // cols) * lat_step, 5) + west = round(lon_min + (c % cols) * lon_step, 5) + north = round(south + lat_step, 5) + east = round(west + lon_step, 5) + return { + "type": "Feature", + "properties": properties, + "geometry": {"type": "Polygon", "coordinates": [[ + [west, south], [east, south], [east, north], [west, north], [west, south]]]}, + } + + +features = [feature(c, {"points": p, "trajectories": n}) for c, p, n in zip(*counts(cell))] +total = len(features) + +weeks = int(week.max()) + 1 +for key, p, n in zip(*counts(cell * weeks + week)): + features.append(feature(key // weeks, {"week": int(key % weeks) * WEEK + FIRST_MONDAY, + "points": p, "trajectories": n})) + +out = Path(args.out) +out.mkdir(parents=True, exist_ok=True) +with gzip.open(out / "heatmap.geojson.gz", "wt") as f: + json.dump({"type": "FeatureCollection", "features": features}, f, separators=(",", ":")) +print(f"{total} cells in total and {len(features) - total} cell-weeks written to " + f"{out / 'heatmap.geojson.gz'}") diff --git a/visualization/requirements.txt b/visualization/requirements.txt new file mode 100644 index 0000000..32df5a7 --- /dev/null +++ b/visualization/requirements.txt @@ -0,0 +1,2 @@ +numpy +pyarrow