diff --git a/bindings/julia/src/Quiver.jl b/bindings/julia/src/Quiver.jl index 3a195bd0..8801e672 100644 --- a/bindings/julia/src/Quiver.jl +++ b/bindings/julia/src/Quiver.jl @@ -26,10 +26,16 @@ include("binary/Binary.jl") export Element, Database, LuaRunner, DatabaseException export ScalarMetadata, GroupMetadata export QUIVER_DATA_TYPE_INTEGER, QUIVER_DATA_TYPE_FLOAT, QUIVER_DATA_TYPE_STRING +export QUIVER_COMPARE_FILE_MATCH, QUIVER_COMPARE_METADATA_MISMATCH, QUIVER_COMPARE_DATA_MISMATCH # Re-export C enum constants for data types const QUIVER_DATA_TYPE_INTEGER = C.QUIVER_DATA_TYPE_INTEGER const QUIVER_DATA_TYPE_FLOAT = C.QUIVER_DATA_TYPE_FLOAT const QUIVER_DATA_TYPE_STRING = C.QUIVER_DATA_TYPE_STRING +# Re-export C enum constants for compare status +const QUIVER_COMPARE_FILE_MATCH = C.QUIVER_COMPARE_FILE_MATCH +const QUIVER_COMPARE_METADATA_MISMATCH = C.QUIVER_COMPARE_METADATA_MISMATCH +const QUIVER_COMPARE_DATA_MISMATCH = C.QUIVER_COMPARE_DATA_MISMATCH + end diff --git a/bindings/julia/src/binary/Binary.jl b/bindings/julia/src/binary/Binary.jl index 825d24fe..cb9dc267 100644 --- a/bindings/julia/src/binary/Binary.jl +++ b/bindings/julia/src/binary/Binary.jl @@ -4,6 +4,7 @@ using ..Quiver: C, check, Element include("metadata.jl") include("file.jl") +include("comparator.jl") include("csv_converter.jl") end diff --git a/bindings/julia/src/binary/comparator.jl b/bindings/julia/src/binary/comparator.jl new file mode 100644 index 00000000..9b0cd6e2 --- /dev/null +++ b/bindings/julia/src/binary/comparator.jl @@ -0,0 +1,44 @@ +function compare_files( + path1::String, path2::String; + absolute_tolerance::Union{Float64, Nothing} = nothing, + relative_tolerance::Union{Float64, Nothing} = nothing, + detailed_report::Union{Bool, Nothing} = nothing, + max_report_lines::Union{Int, Nothing} = nothing, +) + options = C.quiver_compare_options_default() + if absolute_tolerance !== nothing + options.absolute_tolerance = absolute_tolerance + end + if relative_tolerance !== nothing + options.relative_tolerance = relative_tolerance + end + if detailed_report !== nothing + options.detailed_report = detailed_report ? Cint(1) : Cint(0) + end + if max_report_lines !== nothing + options.max_report_lines = Cint(max_report_lines) + end + + out_status = Ref{C.quiver_compare_status_t}(C.QUIVER_COMPARE_FILE_MATCH) + out_report = Ref{Ptr{Cchar}}(C_NULL) + + check(C.quiver_binary_compare_files(path1, path2, Ref(options), out_status, out_report)) + + status = if out_status[] == C.QUIVER_COMPARE_FILE_MATCH + C.QUIVER_COMPARE_FILE_MATCH + elseif out_status[] == C.QUIVER_COMPARE_METADATA_MISMATCH + C.QUIVER_COMPARE_METADATA_MISMATCH + else + C.QUIVER_COMPARE_DATA_MISMATCH + end + + report = if out_report[] != C_NULL + r = unsafe_string(out_report[]) + C.quiver_binary_comparator_free_string(out_report[]) + r + else + nothing + end + + return (; status, report) +end diff --git a/bindings/julia/src/binary/file.jl b/bindings/julia/src/binary/file.jl index 6fc0aa58..4b93f0d7 100644 --- a/bindings/julia/src/binary/file.jl +++ b/bindings/julia/src/binary/file.jl @@ -71,6 +71,27 @@ function write!(file::File; data::Vector{Float64}, dims...) return nothing end +function next_dimensions(file::File, current_dimensions::Vector{Int64}) + out_dims = Ref{Ptr{Int64}}(C_NULL) + out_count = Ref{Csize_t}(0) + + check( + C.quiver_binary_file_next_dimensions( + file.ptr, current_dimensions, length(current_dimensions), + out_dims, out_count, + ), + ) + + count = out_count[] + if count == 0 || out_dims[] == C_NULL + return Int64[] + end + + result = [unsafe_load(out_dims[], i) for i in 1:count] + C.quiver_binary_file_free_int64_array(out_dims[]) + return result +end + function get_metadata(file::File) out_md = Ref{Ptr{C.quiver_binary_metadata}}(C_NULL) check(C.quiver_binary_file_get_metadata(file.ptr, out_md)) diff --git a/bindings/julia/src/c_api.jl b/bindings/julia/src/c_api.jl index 6f5f3705..e3e7e264 100644 --- a/bindings/julia/src/c_api.jl +++ b/bindings/julia/src/c_api.jl @@ -615,6 +615,10 @@ function quiver_binary_file_write(binary_file, dim_names, dim_values, dim_count, @ccall libquiver_c.quiver_binary_file_write(binary_file::Ptr{quiver_binary_file_t}, dim_names::Ptr{Ptr{Cchar}}, dim_values::Ptr{Int64}, dim_count::Csize_t, data::Ptr{Cdouble}, data_count::Csize_t)::quiver_error_t end +function quiver_binary_file_next_dimensions(binary_file, current_dimensions, dim_count, out_dimensions, out_count) + @ccall libquiver_c.quiver_binary_file_next_dimensions(binary_file::Ptr{quiver_binary_file_t}, current_dimensions::Ptr{Int64}, dim_count::Csize_t, out_dimensions::Ptr{Ptr{Int64}}, out_count::Ptr{Csize_t})::quiver_error_t +end + function quiver_binary_file_get_metadata(binary_file, out) @ccall libquiver_c.quiver_binary_file_get_metadata(binary_file::Ptr{quiver_binary_file_t}, out::Ptr{Ptr{quiver_binary_metadata_t}})::quiver_error_t end @@ -623,6 +627,31 @@ function quiver_binary_file_get_file_path(binary_file, out) @ccall libquiver_c.quiver_binary_file_get_file_path(binary_file::Ptr{quiver_binary_file_t}, out::Ptr{Ptr{Cchar}})::quiver_error_t end +@cenum quiver_compare_status_t::UInt32 begin + QUIVER_COMPARE_FILE_MATCH = 0 + QUIVER_COMPARE_METADATA_MISMATCH = 1 + QUIVER_COMPARE_DATA_MISMATCH = 2 +end + +mutable struct quiver_compare_options_t + absolute_tolerance::Cdouble + relative_tolerance::Cdouble + detailed_report::Cint + max_report_lines::Cint +end + +function quiver_compare_options_default() + @ccall libquiver_c.quiver_compare_options_default()::quiver_compare_options_t +end + +function quiver_binary_compare_files(path1, path2, options, out_status, out_report) + @ccall libquiver_c.quiver_binary_compare_files(path1::Ptr{Cchar}, path2::Ptr{Cchar}, options::Ptr{quiver_compare_options_t}, out_status::Ptr{quiver_compare_status_t}, out_report::Ptr{Ptr{Cchar}})::quiver_error_t +end + +function quiver_binary_comparator_free_string(str) + @ccall libquiver_c.quiver_binary_comparator_free_string(str::Ptr{Cchar})::quiver_error_t +end + function quiver_binary_file_free_string(str) @ccall libquiver_c.quiver_binary_file_free_string(str::Ptr{Cchar})::quiver_error_t end @@ -631,6 +660,10 @@ function quiver_binary_file_free_float_array(data) @ccall libquiver_c.quiver_binary_file_free_float_array(data::Ptr{Cdouble})::quiver_error_t end +function quiver_binary_file_free_int64_array(data) + @ccall libquiver_c.quiver_binary_file_free_int64_array(data::Ptr{Int64})::quiver_error_t +end + # ============================================================================ # Binary CSV functions # ============================================================================ diff --git a/bindings/julia/test/test_binary_comparator.jl b/bindings/julia/test/test_binary_comparator.jl new file mode 100644 index 00000000..5a745db1 --- /dev/null +++ b/bindings/julia/test/test_binary_comparator.jl @@ -0,0 +1,249 @@ +module TestBinaryComparator + +using Quiver +using Test + +include("fixture.jl") + +function make_binary_path() + return joinpath(tempdir(), "quiver_julia_comparator_test") +end + +function cleanup_binary(path) + for ext in [".qvr", ".toml", ".csv"] + f = path * ext + isfile(f) && rm(f) + end +end + +function make_simple_metadata() + return Quiver.Binary.Metadata(; + initial_datetime = "2025-01-01T00:00:00", + unit = "MW", + labels = ["val1", "val2"], + dimensions = ["row", "col"], + dimension_sizes = Int64[3, 2], + ) +end + +@testset "Binary Comparator" begin + @testset "compare_files returns file_match for identical files" begin + path1 = make_binary_path() * "_cmp1" + path2 = make_binary_path() * "_cmp2" + try + md = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md) + Quiver.Binary.write!(b1; data = [1.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + b2 = Quiver.Binary.open_file(path2; mode = :write, metadata = md) + Quiver.Binary.write!(b2; data = [1.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b2) + + result = Quiver.Binary.compare_files(path1, path2) + @test result.status == Quiver.QUIVER_COMPARE_FILE_MATCH + @test result.report === nothing + finally + cleanup_binary(path1) + cleanup_binary(path2) + end + end + + @testset "compare_files detects data mismatch" begin + path1 = make_binary_path() * "_cmp1" + path2 = make_binary_path() * "_cmp2" + try + md = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md) + Quiver.Binary.write!(b1; data = [1.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + b2 = Quiver.Binary.open_file(path2; mode = :write, metadata = md) + Quiver.Binary.write!(b2; data = [99.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b2) + + result = Quiver.Binary.compare_files(path1, path2) + @test result.status == Quiver.QUIVER_COMPARE_DATA_MISMATCH + @test result.report === nothing + finally + cleanup_binary(path1) + cleanup_binary(path2) + end + end + + @testset "compare_files detects metadata mismatch" begin + path1 = make_binary_path() * "_cmp1" + path2 = make_binary_path() * "_cmp2" + try + md1 = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md1) + Quiver.Binary.write!(b1; data = [1.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + md2 = Quiver.Binary.Metadata(; + initial_datetime = "2025-01-01T00:00:00", + unit = "GWh", + labels = ["val1", "val2"], + dimensions = ["row", "col"], + dimension_sizes = Int64[3, 2], + ) + b2 = Quiver.Binary.open_file(path2; mode = :write, metadata = md2) + Quiver.Binary.write!(b2; data = [1.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b2) + + result = Quiver.Binary.compare_files(path1, path2) + @test result.status == Quiver.QUIVER_COMPARE_METADATA_MISMATCH + @test result.report === nothing + finally + cleanup_binary(path1) + cleanup_binary(path2) + end + end + + @testset "compare_files with detailed report on match" begin + path1 = make_binary_path() * "_cmp1" + path2 = make_binary_path() * "_cmp2" + try + md = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md) + Quiver.Binary.write!(b1; data = [1.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + b2 = Quiver.Binary.open_file(path2; mode = :write, metadata = md) + Quiver.Binary.write!(b2; data = [1.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b2) + + result = Quiver.Binary.compare_files(path1, path2; detailed_report = true) + @test result.status == Quiver.QUIVER_COMPARE_FILE_MATCH + @test result.report !== nothing + finally + cleanup_binary(path1) + cleanup_binary(path2) + end + end + + @testset "compare_files with detailed report on data mismatch" begin + path1 = make_binary_path() * "_cmp1" + path2 = make_binary_path() * "_cmp2" + try + md = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md) + Quiver.Binary.write!(b1; data = [1.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + b2 = Quiver.Binary.open_file(path2; mode = :write, metadata = md) + Quiver.Binary.write!(b2; data = [99.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b2) + + result = Quiver.Binary.compare_files(path1, path2; detailed_report = true) + @test result.status == Quiver.QUIVER_COMPARE_DATA_MISMATCH + @test result.report !== nothing + @test occursin("val1", result.report) + finally + cleanup_binary(path1) + cleanup_binary(path2) + end + end + + @testset "compare_files non-existent file" begin + path1 = make_binary_path() * "_cmp1" + try + md = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md) + Quiver.Binary.write!(b1; data = [1.0, 2.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + @test_throws Quiver.DatabaseException Quiver.Binary.compare_files(path1, "nonexistent") + finally + cleanup_binary(path1) + end + end + + @testset "compare_files with absolute tolerance matches within threshold" begin + path1 = make_binary_path() * "_cmp1" + path2 = make_binary_path() * "_cmp2" + try + md = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md) + Quiver.Binary.write!(b1; data = [10.0, 20.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + b2 = Quiver.Binary.open_file(path2; mode = :write, metadata = md) + Quiver.Binary.write!(b2; data = [10.05, 20.0], row = 1, col = 1) + Quiver.Binary.close!(b2) + + result = Quiver.Binary.compare_files(path1, path2; absolute_tolerance = 0.1) + @test result.status == Quiver.QUIVER_COMPARE_FILE_MATCH + finally + cleanup_binary(path1) + cleanup_binary(path2) + end + end + + @testset "compare_files with absolute tolerance fails outside threshold" begin + path1 = make_binary_path() * "_cmp1" + path2 = make_binary_path() * "_cmp2" + try + md = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md) + Quiver.Binary.write!(b1; data = [10.0, 20.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + b2 = Quiver.Binary.open_file(path2; mode = :write, metadata = md) + Quiver.Binary.write!(b2; data = [10.05, 20.0], row = 1, col = 1) + Quiver.Binary.close!(b2) + + result = Quiver.Binary.compare_files(path1, path2; absolute_tolerance = 0.01) + @test result.status == Quiver.QUIVER_COMPARE_DATA_MISMATCH + finally + cleanup_binary(path1) + cleanup_binary(path2) + end + end + + @testset "compare_files with relative tolerance matches within threshold" begin + path1 = make_binary_path() * "_cmp1" + path2 = make_binary_path() * "_cmp2" + try + md = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md) + Quiver.Binary.write!(b1; data = [100.0, 20.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + b2 = Quiver.Binary.open_file(path2; mode = :write, metadata = md) + Quiver.Binary.write!(b2; data = [100.05, 20.0], row = 1, col = 1) + Quiver.Binary.close!(b2) + + # diff = 0.05, threshold = 0.0 + 0.001 * 100.05 = 0.10005 + result = Quiver.Binary.compare_files(path1, path2; absolute_tolerance = 0.0, relative_tolerance = 0.001) + @test result.status == Quiver.QUIVER_COMPARE_FILE_MATCH + finally + cleanup_binary(path1) + cleanup_binary(path2) + end + end + + @testset "compare_files with relative tolerance fails outside threshold" begin + path1 = make_binary_path() * "_cmp1" + path2 = make_binary_path() * "_cmp2" + try + md = make_simple_metadata() + b1 = Quiver.Binary.open_file(path1; mode = :write, metadata = md) + Quiver.Binary.write!(b1; data = [100.0, 20.0], row = 1, col = 1) + Quiver.Binary.close!(b1) + + b2 = Quiver.Binary.open_file(path2; mode = :write, metadata = md) + Quiver.Binary.write!(b2; data = [100.2, 20.0], row = 1, col = 1) + Quiver.Binary.close!(b2) + + # diff = 0.2, threshold = 0.0 + 0.001 * 100.2 = 0.1002 + result = Quiver.Binary.compare_files(path1, path2; absolute_tolerance = 0.0, relative_tolerance = 0.001) + @test result.status == Quiver.QUIVER_COMPARE_DATA_MISMATCH + finally + cleanup_binary(path1) + cleanup_binary(path2) + end + end +end + +end diff --git a/bindings/julia/test/test_binary_file.jl b/bindings/julia/test/test_binary_file.jl index d3902dbe..d50be47f 100644 --- a/bindings/julia/test/test_binary_file.jl +++ b/bindings/julia/test/test_binary_file.jl @@ -592,6 +592,99 @@ end cleanup_binary_file(path) end end + + # ========================================================================== + # next_dimensions + # ========================================================================== + + @testset "next_dimensions simple increment" begin + path = make_binary_file_path() + try + md = make_simple_metadata() + file = Quiver.Binary.open_file(path; mode = :write, metadata = md) + # [1, 1] -> [1, 2] + next = Quiver.Binary.next_dimensions(file, Int64[1, 1]) + @test next == Int64[1, 2] + Quiver.Binary.close!(file) + finally + cleanup_binary_file(path) + end + end + + @testset "next_dimensions col rollover" begin + path = make_binary_file_path() + try + md = make_simple_metadata() + file = Quiver.Binary.open_file(path; mode = :write, metadata = md) + # col at max (2), row increments: [1, 2] -> [2, 1] + next = Quiver.Binary.next_dimensions(file, Int64[1, 2]) + @test next == Int64[2, 1] + Quiver.Binary.close!(file) + finally + cleanup_binary_file(path) + end + end + + @testset "next_dimensions wrap around" begin + path = make_binary_file_path() + try + md = make_simple_metadata() + file = Quiver.Binary.open_file(path; mode = :write, metadata = md) + # All at max [3, 2] -> [1, 1] + next = Quiver.Binary.next_dimensions(file, Int64[3, 2]) + @test next == Int64[1, 1] + Quiver.Binary.close!(file) + finally + cleanup_binary_file(path) + end + end + + @testset "next_dimensions time dimension variable month length" begin + path = make_binary_file_path() + try + md = make_time_metadata() + file = Quiver.Binary.open_file(path; mode = :write, metadata = md) + + # Jan has 31 days: [1, 31] -> [2, 1] + next = Quiver.Binary.next_dimensions(file, Int64[1, 31]) + @test next == Int64[2, 1] + + # Feb 2025 has 28 days: [2, 28] -> [3, 1] + next = Quiver.Binary.next_dimensions(file, Int64[2, 28]) + @test next == Int64[3, 1] + + Quiver.Binary.close!(file) + finally + cleanup_binary_file(path) + end + end + + @testset "next_dimensions full iteration covers all positions" begin + path = make_binary_file_path() + try + md = make_simple_metadata() + file = Quiver.Binary.open_file(path; mode = :write, metadata = md) + + current = Int64[1, 1] + positions = [copy(current)] + # 3 rows * 2 cols = 6 positions total; iterate 5 more steps + for _ in 1:5 + current = Quiver.Binary.next_dimensions(file, current) + push!(positions, copy(current)) + end + + @test positions[1] == Int64[1, 1] + @test positions[2] == Int64[1, 2] + @test positions[3] == Int64[2, 1] + @test positions[4] == Int64[2, 2] + @test positions[5] == Int64[3, 1] + @test positions[6] == Int64[3, 2] + + Quiver.Binary.close!(file) + finally + cleanup_binary_file(path) + end + end end end diff --git a/include/quiver/binary/binary_comparator.h b/include/quiver/binary/binary_comparator.h new file mode 100644 index 00000000..a65f7fa9 --- /dev/null +++ b/include/quiver/binary/binary_comparator.h @@ -0,0 +1,49 @@ +#ifndef QUIVER_BINARY_COMPARATOR_H +#define QUIVER_BINARY_COMPARATOR_H + +#include "../export.h" +#include "binary_file.h" + +#include +#include + +namespace quiver { + +enum class CompareStatus { FileMatch, MetadataMismatch, DataMismatch }; + +struct QUIVER_API CompareOptions { + double absolute_tolerance = 1e-6; + double relative_tolerance = 0.0; + bool detailed_report = false; + int max_report_lines = 100; +}; + +struct QUIVER_API CompareResult { + CompareStatus status; + std::string report; +}; + +class QUIVER_API BinaryComparator { +public: + ~BinaryComparator(); + + BinaryComparator(const BinaryComparator&) = delete; + BinaryComparator& operator=(const BinaryComparator&) = delete; + BinaryComparator(BinaryComparator&&) noexcept; + BinaryComparator& operator=(BinaryComparator&&) noexcept; + + static CompareResult + compare(const std::string& file_path1, const std::string& file_path2, const CompareOptions& options = {}); + +private: + struct Impl; + std::unique_ptr impl_; + + BinaryComparator(BinaryFile binary1, BinaryFile binary2, const CompareOptions& options); + + CompareResult run(); +}; + +} // namespace quiver + +#endif // QUIVER_BINARY_COMPARATOR_H diff --git a/include/quiver/binary/binary_file.h b/include/quiver/binary/binary_file.h index 24c42923..0c7195ac 100644 --- a/include/quiver/binary/binary_file.h +++ b/include/quiver/binary/binary_file.h @@ -41,10 +41,16 @@ class QUIVER_API BinaryFile { const BinaryMetadata& get_metadata() const; const std::string& get_file_path() const; + // Dimension iteration + std::vector next_dimensions(const std::vector& current_dimensions); + private: struct Impl; std::unique_ptr impl_; + // Dimension helpers + std::vector dimension_sizes_at_values(const std::vector& dimension_values) const; + // File traversal int64_t calculate_file_position(const std::unordered_map& dims) const; void go_to_position(int64_t position, char mode); @@ -57,10 +63,6 @@ class QUIVER_API BinaryFile { protected: std::iostream& get_io(); - - // Dimension iteration - std::vector next_dimensions(const std::vector& current_dimensions); - std::vector dimension_sizes_at_values(const std::vector& dimension_values) const; }; } // namespace quiver diff --git a/include/quiver/binary/binary_metadata.h b/include/quiver/binary/binary_metadata.h index f1adc823..0c4367b5 100644 --- a/include/quiver/binary/binary_metadata.h +++ b/include/quiver/binary/binary_metadata.h @@ -21,6 +21,8 @@ struct QUIVER_API BinaryMetadata { // int64_t number_of_time_dimensions = 0; + bool operator==(const BinaryMetadata& other) const = default; + BinaryMetadata(); ~BinaryMetadata(); diff --git a/include/quiver/binary/dimension.h b/include/quiver/binary/dimension.h index e3349d71..884015ac 100644 --- a/include/quiver/binary/dimension.h +++ b/include/quiver/binary/dimension.h @@ -16,6 +16,8 @@ struct Dimension { int64_t size; std::optional time; + bool operator==(const Dimension& other) const = default; + bool is_time_dimension() const { return time.has_value(); } }; diff --git a/include/quiver/binary/time_properties.h b/include/quiver/binary/time_properties.h index e4dd2b3e..5394e88c 100644 --- a/include/quiver/binary/time_properties.h +++ b/include/quiver/binary/time_properties.h @@ -26,6 +26,8 @@ struct QUIVER_API TimeProperties { void set_initial_value(int64_t initial_value); void set_parent_dimension_index(int64_t parent_dimension_index); + bool operator==(const TimeProperties& other) const = default; + int64_t datetime_to_int(std::chrono::system_clock::time_point datetime) const; std::chrono::system_clock::time_point add_offset_from_int(std::chrono::system_clock::time_point base_datetime, int64_t value) const; diff --git a/include/quiver/c/binary/binary_comparator.h b/include/quiver/c/binary/binary_comparator.h new file mode 100644 index 00000000..4d4d944a --- /dev/null +++ b/include/quiver/c/binary/binary_comparator.h @@ -0,0 +1,41 @@ +#ifndef QUIVER_C_BINARY_COMPARATOR_H +#define QUIVER_C_BINARY_COMPARATOR_H + +#include "../common.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Compare status codes +typedef enum { + QUIVER_COMPARE_FILE_MATCH = 0, + QUIVER_COMPARE_METADATA_MISMATCH = 1, + QUIVER_COMPARE_DATA_MISMATCH = 2, +} quiver_compare_status_t; + +// Compare options +typedef struct { + double absolute_tolerance; + double relative_tolerance; + int detailed_report; + int max_report_lines; +} quiver_compare_options_t; + +QUIVER_C_API quiver_compare_options_t quiver_compare_options_default(void); + +// Compare +QUIVER_C_API quiver_error_t quiver_binary_compare_files(const char* path1, + const char* path2, + const quiver_compare_options_t* options, + quiver_compare_status_t* out_status, + char** out_report); + +// Free +QUIVER_C_API quiver_error_t quiver_binary_comparator_free_string(char* str); + +#ifdef __cplusplus +} +#endif + +#endif // QUIVER_C_BINARY_COMPARATOR_H diff --git a/include/quiver/c/binary/binary_file.h b/include/quiver/c/binary/binary_file.h index 8d151987..e13b5ba3 100644 --- a/include/quiver/c/binary/binary_file.h +++ b/include/quiver/c/binary/binary_file.h @@ -33,6 +33,13 @@ QUIVER_C_API quiver_error_t quiver_binary_file_write(quiver_binary_file_t* binar const double* data, size_t data_count); +// Dimension iteration +QUIVER_C_API quiver_error_t quiver_binary_file_next_dimensions(quiver_binary_file_t* binary_file, + const int64_t* current_dimensions, + size_t dim_count, + int64_t** out_dimensions, + size_t* out_count); + // Getters QUIVER_C_API quiver_error_t quiver_binary_file_get_metadata(quiver_binary_file_t* binary_file, quiver_binary_metadata_t** out); @@ -41,6 +48,7 @@ QUIVER_C_API quiver_error_t quiver_binary_file_get_file_path(quiver_binary_file_ // Free QUIVER_C_API quiver_error_t quiver_binary_file_free_string(char* str); QUIVER_C_API quiver_error_t quiver_binary_file_free_float_array(double* data); +QUIVER_C_API quiver_error_t quiver_binary_file_free_int64_array(int64_t* data); #ifdef __cplusplus } diff --git a/include/quiver/quiver.h b/include/quiver/quiver.h index 3d8e443a..f86f8f40 100644 --- a/include/quiver/quiver.h +++ b/include/quiver/quiver.h @@ -1,6 +1,7 @@ #ifndef QUIVER_H #define QUIVER_H +#include "binary/binary_comparator.h" #include "binary/binary_file.h" #include "binary/binary_metadata.h" #include "binary/csv_converter.h" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 360f178b..a146f156 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -21,6 +21,7 @@ set(QUIVER_SOURCES schema_validator.cpp type_validator.cpp binary/binary_file.cpp + binary/binary_comparator.cpp binary/csv_converter.cpp binary/binary_metadata.cpp binary/dimension.cpp @@ -117,6 +118,7 @@ if(QUIVER_BUILD_C_API) c/element.cpp c/lua_runner.cpp c/binary/binary_file.cpp + c/binary/binary_comparator.cpp c/binary/csv_converter.cpp c/binary/binary_metadata.cpp ) diff --git a/src/binary/binary_comparator.cpp b/src/binary/binary_comparator.cpp new file mode 100644 index 00000000..1fa12305 --- /dev/null +++ b/src/binary/binary_comparator.cpp @@ -0,0 +1,152 @@ +#include "quiver/binary/binary_comparator.h" + +#include "quiver/binary/binary_file.h" + +#include +#include +#include +#include + +namespace { + +bool is_approx(double a, double b, double atol, double rtol) { + if (std::isnan(a) && std::isnan(b)) + return true; + if (std::isnan(a) || std::isnan(b)) + return false; + return std::abs(a - b) <= atol + rtol * std::abs(b); +} + +} // namespace + +namespace quiver { + +struct BinaryComparator::Impl { + BinaryFile binary1; + BinaryFile binary2; + CompareOptions options; +}; + +BinaryComparator::BinaryComparator(BinaryFile binary1, BinaryFile binary2, const CompareOptions& options) + : impl_(std::make_unique(Impl{std::move(binary1), std::move(binary2), options})) {} + +BinaryComparator::~BinaryComparator() = default; + +BinaryComparator::BinaryComparator(BinaryComparator&& other) noexcept = default; +BinaryComparator& BinaryComparator::operator=(BinaryComparator&& other) noexcept = default; + +CompareResult +BinaryComparator::compare(const std::string& file_path1, const std::string& file_path2, const CompareOptions& options) { + auto binary1 = BinaryFile::open_file(file_path1, 'r'); + auto binary2 = BinaryFile::open_file(file_path2, 'r'); + BinaryComparator comparator(std::move(binary1), std::move(binary2), options); + return comparator.run(); +} + +CompareResult BinaryComparator::run() { + auto& binary1 = impl_->binary1; + auto& binary2 = impl_->binary2; + const auto& metadata1 = binary1.get_metadata(); + const auto& metadata2 = binary2.get_metadata(); + + std::string report; + + if (metadata1 != metadata2) { + if (impl_->options.detailed_report) { + report = "Metadata mismatch between files '" + binary1.get_file_path() + "' and '" + + binary2.get_file_path() + "'.\n"; + } + return {CompareStatus::MetadataMismatch, report}; + } + + const auto& metadata = metadata1; + const auto& dimensions = metadata.dimensions; + + // Get initial dimension values + std::vector initial_dimensions; + for (const auto& dim : dimensions) { + if (dim.is_time_dimension()) { + initial_dimensions.push_back(dim.time->initial_value); + } else { + initial_dimensions.push_back(1); + } + } + + // Calculate maximum number of lines + int64_t max_lines = 1; + for (const auto& dim : dimensions) { + max_lines *= dim.size; + } + + CompareStatus status = CompareStatus::FileMatch; + int report_lines = 0; + + auto current_dims = initial_dimensions; + for (int64_t i = 0; i < max_lines; ++i) { + std::unordered_map dims; + for (size_t j = 0; j < dimensions.size(); ++j) { + dims[dimensions[j].name] = current_dims[j]; + } + + auto data1 = binary1.read(dims, true); + auto data2 = binary2.read(dims, true); + + for (size_t k = 0; k < data1.size(); ++k) { + if (!is_approx(data1[k], data2[k], impl_->options.absolute_tolerance, impl_->options.relative_tolerance)) { + status = CompareStatus::DataMismatch; + + if (!impl_->options.detailed_report) { + // Early exit — no report needed + return {status, report}; + } + + // Initialize report header on first mismatch + if (report.empty()) { + report = "Data mismatch between '" + binary1.get_file_path() + "' and '" + binary2.get_file_path() + + "':\n"; + } + + // Build dimension string + std::string dim_str; + for (size_t j = 0; j < dimensions.size(); ++j) { + if (!dim_str.empty()) + dim_str += ", "; + dim_str += dimensions[j].name + "=" + std::to_string(current_dims[j]); + } + + // Build data string + std::string data_str = "'" + metadata.labels[k] + "': "; + if (std::isnan(data1[k])) { + data_str += "NaN"; + } else { + data_str += std::to_string(data1[k]); + } + data_str += " vs "; + if (std::isnan(data2[k])) { + data_str += "NaN"; + } else { + data_str += std::to_string(data2[k]); + } + + report += " {" + dim_str + "}, " + data_str + "\n"; + report_lines++; + if (report_lines >= impl_->options.max_report_lines) { + report += " more ...\n"; + return {status, report}; + } + } + } + + current_dims = binary1.next_dimensions(current_dims); + if (current_dims == initial_dimensions) + break; + } + + if (impl_->options.detailed_report && status == CompareStatus::FileMatch) { + report = "Files '" + binary1.get_file_path() + "' and '" + binary2.get_file_path() + "' match."; + } + + return {status, report}; +} + +} // namespace quiver diff --git a/src/c/binary/binary_comparator.cpp b/src/c/binary/binary_comparator.cpp new file mode 100644 index 00000000..08061f5d --- /dev/null +++ b/src/c/binary/binary_comparator.cpp @@ -0,0 +1,62 @@ +#include "quiver/c/binary/binary_comparator.h" + +#include "../database_helpers.h" +#include "../internal.h" +#include "quiver/binary/binary_comparator.h" + +#include + +extern "C" { + +QUIVER_C_API quiver_compare_options_t quiver_compare_options_default(void) { + return {1e-6, 0.0, 0, 100}; +} + +QUIVER_C_API quiver_error_t quiver_binary_compare_files(const char* path1, + const char* path2, + const quiver_compare_options_t* options, + quiver_compare_status_t* out_status, + char** out_report) { + QUIVER_REQUIRE(path1, path2, options, out_status, out_report); + + try { + auto result = quiver::BinaryComparator::compare(path1, + path2, + {.absolute_tolerance = options->absolute_tolerance, + .relative_tolerance = options->relative_tolerance, + .detailed_report = options->detailed_report != 0, + .max_report_lines = options->max_report_lines}); + + switch (result.status) { + case quiver::CompareStatus::FileMatch: + *out_status = QUIVER_COMPARE_FILE_MATCH; + break; + case quiver::CompareStatus::MetadataMismatch: + *out_status = QUIVER_COMPARE_METADATA_MISMATCH; + break; + case quiver::CompareStatus::DataMismatch: + *out_status = QUIVER_COMPARE_DATA_MISMATCH; + break; + } + + if (result.report.empty()) { + *out_report = nullptr; + } else { + *out_report = quiver::string::new_c_str(result.report); + } + + return QUIVER_OK; + } catch (const std::exception& e) { + quiver_set_last_error(e.what()); + return QUIVER_ERROR; + } +} + +// Free + +QUIVER_C_API quiver_error_t quiver_binary_comparator_free_string(char* str) { + delete[] str; + return QUIVER_OK; +} + +} // extern "C" diff --git a/src/c/binary/binary_file.cpp b/src/c/binary/binary_file.cpp index ffcbe822..2b051156 100644 --- a/src/c/binary/binary_file.cpp +++ b/src/c/binary/binary_file.cpp @@ -112,6 +112,28 @@ QUIVER_C_API quiver_error_t quiver_binary_file_write(quiver_binary_file_t* binar } } +// Dimension iteration + +QUIVER_C_API quiver_error_t quiver_binary_file_next_dimensions(quiver_binary_file_t* binary_file, + const int64_t* current_dimensions, + size_t dim_count, + int64_t** out_dimensions, + size_t* out_count) { + QUIVER_REQUIRE(binary_file, current_dimensions, out_dimensions, out_count); + + try { + std::vector current(current_dimensions, current_dimensions + dim_count); + auto result = binary_file->binary_file.next_dimensions(current); + *out_count = result.size(); + *out_dimensions = new int64_t[result.size()]; + std::copy(result.begin(), result.end(), *out_dimensions); + return QUIVER_OK; + } catch (const std::exception& e) { + quiver_set_last_error(e.what()); + return QUIVER_ERROR; + } +} + // Getters QUIVER_C_API quiver_error_t quiver_binary_file_get_metadata(quiver_binary_file_t* binary_file, @@ -146,4 +168,9 @@ QUIVER_C_API quiver_error_t quiver_binary_file_free_float_array(double* data) { return QUIVER_OK; } +QUIVER_C_API quiver_error_t quiver_binary_file_free_int64_array(int64_t* data) { + delete[] data; + return QUIVER_OK; +} + } // extern "C" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1b1c1627..f060ba5e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,6 +2,7 @@ include(GoogleTest) add_executable(quiver_tests test_binary_file.cpp + test_binary_comparator.cpp test_csv_converter.cpp test_binary_metadata.cpp test_binary_time_properties.cpp @@ -49,6 +50,7 @@ gtest_discover_tests(quiver_tests if(QUIVER_BUILD_C_API) add_executable(quiver_c_tests test_c_api_binary_file.cpp + test_c_api_binary_comparator.cpp test_c_api_csv_converter.cpp test_c_api_binary_metadata.cpp test_c_api_database_create.cpp diff --git a/tests/test_binary_comparator.cpp b/tests/test_binary_comparator.cpp new file mode 100644 index 00000000..97987fa5 --- /dev/null +++ b/tests/test_binary_comparator.cpp @@ -0,0 +1,416 @@ +#include +#include +#include +#include +#include + +using namespace quiver; +namespace fs = std::filesystem; + +// ============================================================================ +// Fixture +// ============================================================================ + +class BinaryCompareFixture : public ::testing::Test { +protected: + void SetUp() override { + path1 = (fs::temp_directory_path() / "quiver_compare_test1").string(); + path2 = (fs::temp_directory_path() / "quiver_compare_test2").string(); + } + + void TearDown() override { + for (const auto& p : {path1, path2}) { + for (auto ext : {".qvr", ".toml"}) { + auto full = p + ext; + if (fs::exists(full)) + fs::remove(full); + } + } + } + + std::string path1; + std::string path2; + + static BinaryMetadata make_metadata() { + BinaryMetadata md; + md.version = "1"; + md.add_dimension("scenario", 2); + md.add_dimension("block", 3); + md.unit = "MW"; + md.labels = {"plant_1", "plant_2"}; + return md; + } +}; + +// ============================================================================ +// CompareFiles — Status +// ============================================================================ + +TEST_F(BinaryCompareFixture, IdenticalFilesReturnFileMatch) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + bin.write({30.0, 40.0}, {{"scenario", 1}, {"block", 2}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + bin.write({30.0, 40.0}, {{"scenario", 1}, {"block", 2}}); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::FileMatch); +} + +TEST_F(BinaryCompareFixture, BothFilesUnwrittenReturnFileMatch) { + auto md = make_metadata(); + { + BinaryFile::open_file(path1, 'w', md); + } + { + BinaryFile::open_file(path2, 'w', md); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::FileMatch); +} + +TEST_F(BinaryCompareFixture, DifferentDataReturnsDataMismatch) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({99.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); +} + +TEST_F(BinaryCompareFixture, WrittenVsUnwrittenReturnsDataMismatch) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + BinaryFile::open_file(path2, 'w', md); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); +} + +TEST_F(BinaryCompareFixture, DifferentMetadataReturnsMetadataMismatch) { + auto md1 = make_metadata(); + auto md2 = make_metadata(); + md2.unit = "GWh"; + { + auto bin = BinaryFile::open_file(path1, 'w', md1); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md2); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::MetadataMismatch); +} + +TEST_F(BinaryCompareFixture, DifferentDimensionSizesReturnsMetadataMismatch) { + auto md1 = make_metadata(); + BinaryMetadata md2; + md2.version = "1"; + md2.add_dimension("scenario", 4); + md2.add_dimension("block", 3); + md2.unit = "MW"; + md2.labels = {"plant_1", "plant_2"}; + { + BinaryFile::open_file(path1, 'w', md1); + } + { + BinaryFile::open_file(path2, 'w', md2); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::MetadataMismatch); +} + +TEST_F(BinaryCompareFixture, DifferentLabelsReturnsMetadataMismatch) { + auto md1 = make_metadata(); + auto md2 = make_metadata(); + md2.labels = {"gen_1", "gen_2"}; + { + BinaryFile::open_file(path1, 'w', md1); + } + { + BinaryFile::open_file(path2, 'w', md2); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::MetadataMismatch); +} + +// ============================================================================ +// CompareFiles — Report with detailed_report=true +// ============================================================================ + +TEST_F(BinaryCompareFixture, FileMatchReportIsNotEmpty) { + auto md = make_metadata(); + { + BinaryFile::open_file(path1, 'w', md); + } + { + BinaryFile::open_file(path2, 'w', md); + } + + auto result = BinaryComparator::compare(path1, path2, {.detailed_report = true}); + EXPECT_EQ(result.status, CompareStatus::FileMatch); + EXPECT_FALSE(result.report.empty()); +} + +TEST_F(BinaryCompareFixture, DataMismatchReportContainsDimensionInfo) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 2}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({99.0, 20.0}, {{"scenario", 1}, {"block", 2}}); + } + + auto result = BinaryComparator::compare(path1, path2, {.detailed_report = true}); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); + EXPECT_NE(result.report.find("scenario=1"), std::string::npos); + EXPECT_NE(result.report.find("block=2"), std::string::npos); + EXPECT_NE(result.report.find("plant_1"), std::string::npos); +} + +TEST_F(BinaryCompareFixture, DataMismatchReportContainsValues) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({99.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + + auto result = BinaryComparator::compare(path1, path2, {.detailed_report = true}); + EXPECT_NE(result.report.find("10.0"), std::string::npos); + EXPECT_NE(result.report.find("99.0"), std::string::npos); +} + +TEST_F(BinaryCompareFixture, DataMismatchReportShowsNaN) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + // leave position unwritten (NaN) + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({5.0, 6.0}, {{"scenario", 1}, {"block", 1}}); + } + + auto result = BinaryComparator::compare(path1, path2, {.detailed_report = true}); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); + EXPECT_NE(result.report.find("NaN"), std::string::npos); +} + +TEST_F(BinaryCompareFixture, DataMismatchReportOnlyShowsDifferingPositions) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + bin.write({30.0, 40.0}, {{"scenario", 1}, {"block", 2}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); // same + bin.write({99.0, 40.0}, {{"scenario", 1}, {"block", 2}}); // different + } + + auto result = BinaryComparator::compare(path1, path2, {.detailed_report = true}); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); + // block=2 should appear (mismatch), block=1 should not (match) + EXPECT_NE(result.report.find("block=2"), std::string::npos); + EXPECT_EQ(result.report.find("block=1"), std::string::npos); +} + +TEST_F(BinaryCompareFixture, CustomMaxReportLinesTruncatesReport) { + // 2 scenarios x 3 blocks x 2 labels = 12 mismatch lines, truncated at 5 + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + for (int64_t s = 1; s <= 2; ++s) + for (int64_t b = 1; b <= 3; ++b) + bin.write({1.0, 2.0}, {{"scenario", s}, {"block", b}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + for (int64_t s = 1; s <= 2; ++s) + for (int64_t b = 1; b <= 3; ++b) + bin.write({99.0, 98.0}, {{"scenario", s}, {"block", b}}); + } + + auto result = BinaryComparator::compare(path1, path2, {.detailed_report = true, .max_report_lines = 5}); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); + EXPECT_NE(result.report.find("more ..."), std::string::npos); +} + +TEST_F(BinaryCompareFixture, MetadataMismatchReportIsNotEmpty) { + auto md1 = make_metadata(); + auto md2 = make_metadata(); + md2.unit = "GWh"; + { + BinaryFile::open_file(path1, 'w', md1); + } + { + BinaryFile::open_file(path2, 'w', md2); + } + + auto result = BinaryComparator::compare(path1, path2, {.detailed_report = true}); + EXPECT_EQ(result.status, CompareStatus::MetadataMismatch); + EXPECT_FALSE(result.report.empty()); +} + +// ============================================================================ +// CompareFiles — Report with detailed_report=false (default) +// ============================================================================ + +TEST_F(BinaryCompareFixture, FileMatchNoReportIsEmpty) { + auto md = make_metadata(); + { + BinaryFile::open_file(path1, 'w', md); + } + { + BinaryFile::open_file(path2, 'w', md); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::FileMatch); + EXPECT_TRUE(result.report.empty()); +} + +TEST_F(BinaryCompareFixture, DataMismatchNoReportIsEmpty) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({99.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); + EXPECT_TRUE(result.report.empty()); +} + +TEST_F(BinaryCompareFixture, MetadataMismatchNoReportIsEmpty) { + auto md1 = make_metadata(); + auto md2 = make_metadata(); + md2.unit = "GWh"; + { + BinaryFile::open_file(path1, 'w', md1); + } + { + BinaryFile::open_file(path2, 'w', md2); + } + + auto result = BinaryComparator::compare(path1, path2); + EXPECT_EQ(result.status, CompareStatus::MetadataMismatch); + EXPECT_TRUE(result.report.empty()); +} + +// ============================================================================ +// CompareFiles — Tolerance +// ============================================================================ + +TEST_F(BinaryCompareFixture, AbsoluteToleranceMatchesWithinThreshold) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({10.05, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + + auto result = BinaryComparator::compare(path1, path2, {.absolute_tolerance = 0.1}); + EXPECT_EQ(result.status, CompareStatus::FileMatch); +} + +TEST_F(BinaryCompareFixture, AbsoluteToleranceFailsOutsideThreshold) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({10.05, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + + auto result = BinaryComparator::compare(path1, path2, {.absolute_tolerance = 0.01}); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); +} + +TEST_F(BinaryCompareFixture, RelativeToleranceMatch) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({100.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({100.05, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + + // diff = 0.05, threshold = 0.0 + 0.001 * 100.05 = 0.10005 + auto result = BinaryComparator::compare(path1, path2, {.absolute_tolerance = 0.0, .relative_tolerance = 0.001}); + EXPECT_EQ(result.status, CompareStatus::FileMatch); +} + +TEST_F(BinaryCompareFixture, RelativeToleranceFailsOutsideThreshold) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({100.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + bin.write({100.2, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + + // diff = 0.2, threshold = 0.0 + 0.001 * 100.2 = 0.1002 + auto result = BinaryComparator::compare(path1, path2, {.absolute_tolerance = 0.0, .relative_tolerance = 0.001}); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); +} + +TEST_F(BinaryCompareFixture, ToleranceReportOnlyShowsOutOfTolerance) { + auto md = make_metadata(); + { + auto bin = BinaryFile::open_file(path1, 'w', md); + bin.write({10.0, 20.0}, {{"scenario", 1}, {"block", 1}}); + } + { + auto bin = BinaryFile::open_file(path2, 'w', md); + // plant_1: diff = 0.01 (within atol=0.1), plant_2: diff = 5.0 (outside) + bin.write({10.01, 25.0}, {{"scenario", 1}, {"block", 1}}); + } + + auto result = BinaryComparator::compare(path1, path2, {.absolute_tolerance = 0.1, .detailed_report = true}); + EXPECT_EQ(result.status, CompareStatus::DataMismatch); + EXPECT_EQ(result.report.find("plant_1"), std::string::npos); + EXPECT_NE(result.report.find("plant_2"), std::string::npos); +} diff --git a/tests/test_binary_file.cpp b/tests/test_binary_file.cpp index 8ef26e1b..73cd198e 100644 --- a/tests/test_binary_file.cpp +++ b/tests/test_binary_file.cpp @@ -437,6 +437,39 @@ TEST_F(BinaryTempFileFixture, InvalidTimeDimensionCoordinates) { EXPECT_THROW(binary_file.write({1.0, 2.0}, {{"stage", 2}, {"block", 30}}), std::invalid_argument); } +// ============================================================================ +// BinaryNextDimensions +// ============================================================================ + +TEST_F(BinaryTempFileFixture, NextDimensionsTimeDimResetToInitialValueWhenParentAtInitial) { + // Dims: [t1=monthly(2), d=plain(3), t2=daily(31)], initial_datetime=2025-01-05 + // t1.initial_value=1, t2.initial_value=5 (day 5 of the month) + // When d rolls and t1 stays at its initial value (1), t2 must reset to 5 (not 1). + // When d rolls and t1 is NOT at its initial value (2), t2 resets to 1 normally. + auto md = BinaryMetadata::from_element(Element() + .set("version", "1") + .set("initial_datetime", "2025-01-05T00:00:00") + .set("unit", "MW") + .set("dimensions", {"month", "scenario", "day"}) + .set("dimension_sizes", {2, 3, 31}) + .set("time_dimensions", {"month", "day"}) + .set("frequencies", {"monthly", "daily"}) + .set("labels", {"val"})); + auto binary_file = BinaryFile::open_file(path, 'w', md); + + // [1, 2, 31]: d increments (2->3), t1 stays at 1 (its initial value). + // t2 was reset to 1 by carry logic, but 1 < initial_value(5) and t1==initial, so t2 is corrected to 5. + EXPECT_EQ(binary_file.next_dimensions({1, 2, 31}), (std::vector{1, 3, 5})); + + // [1, 3, 31]: d is at max (3), so d resets to 1 and t1 increments to 2. + // t1 is now 2, which is != its initial value (1), so t2 resets to 1 normally. + EXPECT_EQ(binary_file.next_dimensions({1, 3, 31}), (std::vector{2, 1, 1})); + + // [2, 2, 31]: d increments (2->3), t1 stays at 2 (not its initial value). + // t2 resets to 1 normally. + EXPECT_EQ(binary_file.next_dimensions({2, 2, 31}), (std::vector{2, 3, 1})); +} + // ============================================================================ // WriteRegistry // ============================================================================ diff --git a/tests/test_c_api_binary_comparator.cpp b/tests/test_c_api_binary_comparator.cpp new file mode 100644 index 00000000..b782bea0 --- /dev/null +++ b/tests/test_c_api_binary_comparator.cpp @@ -0,0 +1,185 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +// ============================================================================ +// Fixture +// ============================================================================ + +class BinaryCApiCompareFixture : public ::testing::Test { +protected: + void SetUp() override { + path1 = (fs::temp_directory_path() / "quiver_c_compare_test_1").string(); + path2 = (fs::temp_directory_path() / "quiver_c_compare_test_2").string(); + } + + void TearDown() override { + for (auto* p : {&path1, &path2}) { + for (auto ext : {".qvr", ".toml"}) { + auto full = *p + ext; + if (fs::exists(full)) + fs::remove(full); + } + } + } + + std::string path1; + std::string path2; + + quiver_binary_metadata_t* make_metadata() { + quiver_element_t* el = nullptr; + quiver_element_create(&el); + quiver_element_set_string(el, "version", "1"); + quiver_element_set_string(el, "initial_datetime", "2025-01-01T00:00:00"); + quiver_element_set_string(el, "unit", "MW"); + + const char* dims[] = {"scenario", "block"}; + quiver_element_set_array_string(el, "dimensions", dims, 2); + int64_t sizes[] = {2, 3}; + quiver_element_set_array_integer(el, "dimension_sizes", sizes, 2); + const char* labels[] = {"plant_1", "plant_2"}; + quiver_element_set_array_string(el, "labels", labels, 2); + + quiver_binary_metadata_t* md = nullptr; + quiver_binary_metadata_from_element(el, &md); + quiver_element_destroy(el); + return md; + } + + void write_file(const std::string& path, quiver_binary_metadata_t* md, double val1, double val2) { + quiver_binary_file_t* binary = nullptr; + quiver_binary_file_open_write(path.c_str(), md, &binary); + + const char* dim_names[] = {"scenario", "block"}; + int64_t dim_values[] = {1, 1}; + double data[] = {val1, val2}; + quiver_binary_file_write(binary, dim_names, dim_values, 2, data, 2); + quiver_binary_file_close(binary); + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +TEST_F(BinaryCApiCompareFixture, FileMatch) { + auto* md = make_metadata(); + write_file(path1, md, 10.0, 20.0); + write_file(path2, md, 10.0, 20.0); + quiver_binary_metadata_free(md); + + auto options = quiver_compare_options_default(); + quiver_compare_status_t status; + char* report = nullptr; + ASSERT_EQ(quiver_binary_compare_files(path1.c_str(), path2.c_str(), &options, &status, &report), QUIVER_OK); + EXPECT_EQ(status, QUIVER_COMPARE_FILE_MATCH); + EXPECT_EQ(report, nullptr); +} + +TEST_F(BinaryCApiCompareFixture, DataMismatch) { + auto* md = make_metadata(); + write_file(path1, md, 10.0, 20.0); + write_file(path2, md, 99.0, 20.0); + quiver_binary_metadata_free(md); + + auto options = quiver_compare_options_default(); + quiver_compare_status_t status; + char* report = nullptr; + ASSERT_EQ(quiver_binary_compare_files(path1.c_str(), path2.c_str(), &options, &status, &report), QUIVER_OK); + EXPECT_EQ(status, QUIVER_COMPARE_DATA_MISMATCH); + EXPECT_EQ(report, nullptr); +} + +TEST_F(BinaryCApiCompareFixture, MetadataMismatch) { + auto* md1 = make_metadata(); + write_file(path1, md1, 10.0, 20.0); + quiver_binary_metadata_free(md1); + + // Create second file with different unit + quiver_element_t* el = nullptr; + quiver_element_create(&el); + quiver_element_set_string(el, "version", "1"); + quiver_element_set_string(el, "initial_datetime", "2025-01-01T00:00:00"); + quiver_element_set_string(el, "unit", "GWh"); + const char* dims[] = {"scenario", "block"}; + quiver_element_set_array_string(el, "dimensions", dims, 2); + int64_t sizes[] = {2, 3}; + quiver_element_set_array_integer(el, "dimension_sizes", sizes, 2); + const char* labels[] = {"plant_1", "plant_2"}; + quiver_element_set_array_string(el, "labels", labels, 2); + + quiver_binary_metadata_t* md2 = nullptr; + quiver_binary_metadata_from_element(el, &md2); + quiver_element_destroy(el); + + write_file(path2, md2, 10.0, 20.0); + quiver_binary_metadata_free(md2); + + auto options = quiver_compare_options_default(); + quiver_compare_status_t status; + char* report = nullptr; + ASSERT_EQ(quiver_binary_compare_files(path1.c_str(), path2.c_str(), &options, &status, &report), QUIVER_OK); + EXPECT_EQ(status, QUIVER_COMPARE_METADATA_MISMATCH); + EXPECT_EQ(report, nullptr); +} + +TEST_F(BinaryCApiCompareFixture, DetailedReportOnMatch) { + auto* md = make_metadata(); + write_file(path1, md, 10.0, 20.0); + write_file(path2, md, 10.0, 20.0); + quiver_binary_metadata_free(md); + + auto options = quiver_compare_options_default(); + options.detailed_report = 1; + quiver_compare_status_t status; + char* report = nullptr; + ASSERT_EQ(quiver_binary_compare_files(path1.c_str(), path2.c_str(), &options, &status, &report), QUIVER_OK); + EXPECT_EQ(status, QUIVER_COMPARE_FILE_MATCH); + ASSERT_NE(report, nullptr); + quiver_binary_comparator_free_string(report); +} + +TEST_F(BinaryCApiCompareFixture, DetailedReportOnDataMismatch) { + auto* md = make_metadata(); + write_file(path1, md, 10.0, 20.0); + write_file(path2, md, 99.0, 20.0); + quiver_binary_metadata_free(md); + + auto options = quiver_compare_options_default(); + options.detailed_report = 1; + quiver_compare_status_t status; + char* report = nullptr; + ASSERT_EQ(quiver_binary_compare_files(path1.c_str(), path2.c_str(), &options, &status, &report), QUIVER_OK); + EXPECT_EQ(status, QUIVER_COMPARE_DATA_MISMATCH); + ASSERT_NE(report, nullptr); + + std::string report_str(report); + EXPECT_NE(report_str.find("plant_1"), std::string::npos); + quiver_binary_comparator_free_string(report); +} + +TEST_F(BinaryCApiCompareFixture, NullArgs) { + auto options = quiver_compare_options_default(); + quiver_compare_status_t status; + char* report = nullptr; + EXPECT_EQ(quiver_binary_compare_files(nullptr, nullptr, &options, &status, &report), QUIVER_ERROR); + EXPECT_STREQ(quiver_get_last_error(), "Null argument: path1"); +} + +TEST_F(BinaryCApiCompareFixture, NonExistentFile) { + auto* md = make_metadata(); + write_file(path1, md, 10.0, 20.0); + quiver_binary_metadata_free(md); + + auto options = quiver_compare_options_default(); + quiver_compare_status_t status; + char* report = nullptr; + EXPECT_EQ(quiver_binary_compare_files(path1.c_str(), "nonexistent", &options, &status, &report), QUIVER_ERROR); +} diff --git a/tests/test_c_api_binary_file.cpp b/tests/test_c_api_binary_file.cpp index fa8c1917..74431751 100644 --- a/tests/test_c_api_binary_file.cpp +++ b/tests/test_c_api_binary_file.cpp @@ -396,6 +396,144 @@ TEST_F(BinaryCApiFixture, FreeFloatArrayNull) { EXPECT_EQ(quiver_binary_file_free_float_array(nullptr), QUIVER_OK); } +// ============================================================================ +// next_dimensions +// ============================================================================ + +TEST_F(BinaryCApiFixture, NextDimensionsSimpleIncrement) { + auto* md = make_simple_metadata(); + quiver_binary_file_t* binary_file = nullptr; + ASSERT_EQ(quiver_binary_file_open_write(path.c_str(), md, &binary_file), QUIVER_OK); + + // [1, 1] -> [1, 2] + int64_t current[] = {1, 1}; + int64_t* out_dims = nullptr; + size_t out_count = 0; + EXPECT_EQ(quiver_binary_file_next_dimensions(binary_file, current, 2, &out_dims, &out_count), QUIVER_OK); + ASSERT_EQ(out_count, 2u); + EXPECT_EQ(out_dims[0], 1); + EXPECT_EQ(out_dims[1], 2); + quiver_binary_file_free_int64_array(out_dims); + + quiver_binary_file_close(binary_file); + quiver_binary_metadata_free(md); +} + +TEST_F(BinaryCApiFixture, NextDimensionsColRollover) { + auto* md = make_simple_metadata(); + quiver_binary_file_t* binary_file = nullptr; + ASSERT_EQ(quiver_binary_file_open_write(path.c_str(), md, &binary_file), QUIVER_OK); + + // col at max (2), row increments: [1, 2] -> [2, 1] + int64_t current[] = {1, 2}; + int64_t* out_dims = nullptr; + size_t out_count = 0; + EXPECT_EQ(quiver_binary_file_next_dimensions(binary_file, current, 2, &out_dims, &out_count), QUIVER_OK); + ASSERT_EQ(out_count, 2u); + EXPECT_EQ(out_dims[0], 2); + EXPECT_EQ(out_dims[1], 1); + quiver_binary_file_free_int64_array(out_dims); + + quiver_binary_file_close(binary_file); + quiver_binary_metadata_free(md); +} + +TEST_F(BinaryCApiFixture, NextDimensionsWrapAround) { + auto* md = make_simple_metadata(); + quiver_binary_file_t* binary_file = nullptr; + ASSERT_EQ(quiver_binary_file_open_write(path.c_str(), md, &binary_file), QUIVER_OK); + + // All at max: [3, 2] -> [1, 1] (wraps around) + int64_t current[] = {3, 2}; + int64_t* out_dims = nullptr; + size_t out_count = 0; + EXPECT_EQ(quiver_binary_file_next_dimensions(binary_file, current, 2, &out_dims, &out_count), QUIVER_OK); + ASSERT_EQ(out_count, 2u); + EXPECT_EQ(out_dims[0], 1); + EXPECT_EQ(out_dims[1], 1); + quiver_binary_file_free_int64_array(out_dims); + + quiver_binary_file_close(binary_file); + quiver_binary_metadata_free(md); +} + +TEST_F(BinaryCApiFixture, NextDimensionsTimeDimensionVariableMonthLength) { + // Monthly + daily: starting 2025-01-01, Jan has 31 days. + // [1, 31] -> [2, 1] (end of Jan, move to Feb day 1) + quiver_element_t* el = nullptr; + quiver_element_create(&el); + quiver_element_set_string(el, "version", "1"); + quiver_element_set_string(el, "initial_datetime", "2025-01-01T00:00:00"); + quiver_element_set_string(el, "unit", "MW"); + const char* dims[] = {"stage", "block"}; + quiver_element_set_array_string(el, "dimensions", dims, 2); + int64_t sizes[] = {4, 31}; + quiver_element_set_array_integer(el, "dimension_sizes", sizes, 2); + const char* labels[] = {"plant_1"}; + quiver_element_set_array_string(el, "labels", labels, 1); + const char* time_dims[] = {"stage", "block"}; + quiver_element_set_array_string(el, "time_dimensions", time_dims, 2); + const char* freqs[] = {"monthly", "daily"}; + quiver_element_set_array_string(el, "frequencies", freqs, 2); + + quiver_binary_metadata_t* md = nullptr; + ASSERT_EQ(quiver_binary_metadata_from_element(el, &md), QUIVER_OK); + quiver_element_destroy(el); + + quiver_binary_file_t* binary_file = nullptr; + ASSERT_EQ(quiver_binary_file_open_write(path.c_str(), md, &binary_file), QUIVER_OK); + + // Jan has 31 days: [1, 31] -> [2, 1] + int64_t current[] = {1, 31}; + int64_t* out_dims = nullptr; + size_t out_count = 0; + EXPECT_EQ(quiver_binary_file_next_dimensions(binary_file, current, 2, &out_dims, &out_count), QUIVER_OK); + ASSERT_EQ(out_count, 2u); + EXPECT_EQ(out_dims[0], 2); + EXPECT_EQ(out_dims[1], 1); + quiver_binary_file_free_int64_array(out_dims); + + // Feb has 28 days in 2025: [2, 28] -> [3, 1] + int64_t current2[] = {2, 28}; + EXPECT_EQ(quiver_binary_file_next_dimensions(binary_file, current2, 2, &out_dims, &out_count), QUIVER_OK); + ASSERT_EQ(out_count, 2u); + EXPECT_EQ(out_dims[0], 3); + EXPECT_EQ(out_dims[1], 1); + quiver_binary_file_free_int64_array(out_dims); + + quiver_binary_file_close(binary_file); + quiver_binary_metadata_free(md); +} + +TEST_F(BinaryCApiFixture, NextDimensionsNullArgs) { + int64_t current[] = {1, 1}; + int64_t* out_dims = nullptr; + size_t out_count = 0; + + EXPECT_EQ(quiver_binary_file_next_dimensions(nullptr, current, 2, &out_dims, &out_count), QUIVER_ERROR); + EXPECT_STREQ(quiver_get_last_error(), "Null argument: binary_file"); + + auto* md = make_simple_metadata(); + quiver_binary_file_t* binary_file = nullptr; + ASSERT_EQ(quiver_binary_file_open_write(path.c_str(), md, &binary_file), QUIVER_OK); + + EXPECT_EQ(quiver_binary_file_next_dimensions(binary_file, nullptr, 2, &out_dims, &out_count), QUIVER_ERROR); + EXPECT_STREQ(quiver_get_last_error(), "Null argument: current_dimensions"); + + EXPECT_EQ(quiver_binary_file_next_dimensions(binary_file, current, 2, nullptr, &out_count), QUIVER_ERROR); + EXPECT_STREQ(quiver_get_last_error(), "Null argument: out_dimensions"); + + EXPECT_EQ(quiver_binary_file_next_dimensions(binary_file, current, 2, &out_dims, nullptr), QUIVER_ERROR); + EXPECT_STREQ(quiver_get_last_error(), "Null argument: out_count"); + + quiver_binary_file_close(binary_file); + quiver_binary_metadata_free(md); +} + +TEST_F(BinaryCApiFixture, FreeInt64ArrayNull) { + EXPECT_EQ(quiver_binary_file_free_int64_array(nullptr), QUIVER_OK); +} + // ============================================================================ // Dimension mismatch errors // ============================================================================