From b49eb8bd35b9767b21070bf23790c64915dab24c Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Wed, 26 Aug 2026 00:53:30 -0700 Subject: [PATCH 01/22] add example of bmad dump file --- tests/data/bmad-dump.h5 | Bin 0 -> 79084 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/data/bmad-dump.h5 diff --git a/tests/data/bmad-dump.h5 b/tests/data/bmad-dump.h5 new file mode 100644 index 0000000000000000000000000000000000000000..bed0ff4191f58ffcce4ff40e3c2dc978e98f3127 GIT binary patch literal 79084 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

FwvBPLR=T1s?|AXHm6XO6t%cojSL^9Gw-nxw0fZ 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%0A}c1ou9k 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

dc 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 Date: Wed, 26 Aug 2026 00:59:09 -0700 Subject: [PATCH 02/22] organize test data --- tests/CMakeLists.txt | 8 ++++---- tests/test_generate_openpmd.c | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bfc5de2..5536267 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -118,28 +118,28 @@ find_program(OPENPMD_VALIDATOR openPMD_check_h5) if(OPENPMD_VALIDATOR) add_test( NAME ValidateGroupBasedOpenPMD - COMMAND ${OPENPMD_VALIDATOR} -i ${CMAKE_CURRENT_BINARY_DIR}/generated_openpmd/example_group_based.h5 + COMMAND ${OPENPMD_VALIDATOR} -i ${CMAKE_CURRENT_BINARY_DIR}/data/generated_openpmd/example_group_based.h5 WORKING_DIRECTORY ${CMAKE_BINARY_DIR} ) set_tests_properties(ValidateGroupBasedOpenPMD PROPERTIES DEPENDS GenerateOpenPMDTests) add_test( NAME ValidateFileBasedOpenPMD_iter0 - COMMAND ${OPENPMD_VALIDATOR} -i ${CMAKE_CURRENT_BINARY_DIR}/generated_openpmd/file_based_example/data_0.h5 + COMMAND ${OPENPMD_VALIDATOR} -i ${CMAKE_CURRENT_BINARY_DIR}/data/generated_openpmd/file_based_example/data_0.h5 WORKING_DIRECTORY ${CMAKE_BINARY_DIR} ) set_tests_properties(ValidateFileBasedOpenPMD_iter0 PROPERTIES DEPENDS GenerateOpenPMDTests) add_test( NAME ValidateFileBasedOpenPMD_iter5 - COMMAND ${OPENPMD_VALIDATOR} -i ${CMAKE_CURRENT_BINARY_DIR}/generated_openpmd/file_based_example/data_5.h5 + COMMAND ${OPENPMD_VALIDATOR} -i ${CMAKE_CURRENT_BINARY_DIR}/data/generated_openpmd/file_based_example/data_5.h5 WORKING_DIRECTORY ${CMAKE_BINARY_DIR} ) set_tests_properties(ValidateFileBasedOpenPMD_iter5 PROPERTIES DEPENDS GenerateOpenPMDTests) add_test( NAME ValidateFileBasedOpenPMD_iter10 - COMMAND ${OPENPMD_VALIDATOR} -i ${CMAKE_CURRENT_BINARY_DIR}/generated_openpmd/file_based_example/data_10.h5 + COMMAND ${OPENPMD_VALIDATOR} -i ${CMAKE_CURRENT_BINARY_DIR}/data/generated_openpmd/file_based_example/data_10.h5 WORKING_DIRECTORY ${CMAKE_BINARY_DIR} ) set_tests_properties(ValidateFileBasedOpenPMD_iter10 PROPERTIES DEPENDS GenerateOpenPMDTests) diff --git a/tests/test_generate_openpmd.c b/tests/test_generate_openpmd.c index a112164..536609c 100644 --- a/tests/test_generate_openpmd.c +++ b/tests/test_generate_openpmd.c @@ -21,10 +21,12 @@ #include #endif -#define TEST_OUTPUT_DIR "tests/generated_openpmd" +#define TEST_DATA_DIR "tests/data" +#define TEST_OUTPUT_DIR TEST_DATA_DIR "/generated_openpmd" void setUp(void) { - /* Create output directory if it doesn't exist */ + /* Create output directories if they don't exist */ + mkdir(TEST_DATA_DIR, 0755); mkdir(TEST_OUTPUT_DIR, 0755); } From c96253f85dc26ca60585b38bc8caeb887d3eb36f Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Wed, 26 Aug 2026 01:30:47 -0700 Subject: [PATCH 03/22] add test for Bmad file --- tests/CMakeLists.txt | 3 +++ tests/test_read.c | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5536267..d89fa6e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,6 +16,9 @@ else() message(WARNING "Python3 not found - test data will not be generated automatically") endif() +# Copy checked-in reference data alongside the generated test data +configure_file(data/bmad-dump.h5 ${CMAKE_CURRENT_BINARY_DIR}/data/bmad-dump.h5 COPYONLY) + # Test executables add_executable(test_read test_read.c) add_executable(test_utilities test_utilities.c) diff --git a/tests/test_read.c b/tests/test_read.c index 096c31d..7a017d4 100644 --- a/tests/test_read.c +++ b/tests/test_read.c @@ -917,6 +917,41 @@ void test_valid_all_metadata(void) { pmd_close_series(series); } +/* Test: Reference dump produced by Bmad + * File: tests/data/bmad-dump.h5 + * Tests: Reading Bmad file with a single iteration and 1000 particles */ +void test_read_bmad_dump(void) { + pmd_series *series; + pmd_iteration *iter; + pmd_status result; + int64_t *iterations; + int num_iterations; + int64_t particle_count; + + /* Open series */ + result = pmd_open_series("tests/data/bmad-dump.h5", &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_NOT_NULL(series); + + /* The dump holds exactly one iteration */ + result = pmd_list_iterations(series, &iterations, &num_iterations); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_INT(1, num_iterations); + + result = pmd_open_iteration(series, iterations[0], &iter); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_NOT_NULL(iter); + + /* The iteration holds 1000 electrons */ + result = pmd_get_num_particles(iter, "electron", &particle_count); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_INT64(1000, particle_count); + + /* Clean up */ + pmd_close_iteration(iter); + pmd_close_series(series); +} + /* ========================================================================= * Unit Conversion (unitSI) Tests * ========================================================================= */ @@ -2735,6 +2770,7 @@ int main(void) { RUN_TEST(test_user_supplied_arrays); RUN_TEST(test_read_openpmd_constant); RUN_TEST(test_read_openpmd_dataset); + RUN_TEST(test_read_bmad_dump); /* Unit Conversion tests */ RUN_TEST(test_position_non_si_units); From 6b7ff4a1d39588d62fd268cef1ecac5612d3b329 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 27 Aug 2026 00:17:17 -0700 Subject: [PATCH 04/22] factor out find_first_iteration_file --- parcel.h | 88 ++++++++++++++++++++++++++++++++------------------------ 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/parcel.h b/parcel.h index b4140bf..c2d84f9 100644 --- a/parcel.h +++ b/parcel.h @@ -1766,6 +1766,54 @@ static pmd_status delete_matching_iteration_files(const char *pattern) { return status; } +/** + * Find the lowest-numbered existing file matching an iteration pattern + * Sets *found_out to 1 and *iteration_out to the iteration index when a match exists on disk + */ +static pmd_status find_first_iteration_file(const char *filename, const iteration_pattern *pattern_info, + int *found_out, int64_t *iteration_out) { + pmd_dirent *entry; + + *found_out = 0; + + /* Open directory and search for matching files */ + pmd_dir *dir = pmd_opendir(pattern_info->scan_parent); + if (!dir) { + return PMD_ERROR_FILE_NOT_FOUND; + } + + while ((entry = pmd_readdir(dir)) != NULL) { + int64_t iteration; + + /* Try to extract iteration from name matching first segment pattern */ + if (extract_iteration_from_name(entry->d_name, pattern_info->first_segment, &iteration) != PMD_SUCCESS) { + continue; + } + + /* Reconstruct full file path from pattern (ie for paths that look like data_%T/file_%T.h5 where we only found + * the directory data_1) */ + char *full_path = replace_iteration(filename, iteration); + if (!full_path) { + pmd_log(PMD_LOG_ERROR, "find_first_iteration_file - out of memory constructing path."); + pmd_closedir(dir); + return PMD_ERROR_OUT_OF_MEMORY; + } + + /* Test for file existance */ + FILE *test = fopen(full_path, "rb"); + free(full_path); + if (test) { + (void)fclose(test); + *found_out = 1; + *iteration_out = iteration; + break; + } + } + + pmd_closedir(dir); + return PMD_SUCCESS; +} + pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_access_mode mode) { pmd_series *series = NULL; hid_t file_id = -1; @@ -1824,44 +1872,11 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac status = parse_iteration_pattern(filename, &pattern_info); if (status != PMD_SUCCESS) goto cleanup; - /* Open directory and search for matching files */ - pmd_dir *dir = pmd_opendir(pattern_info.scan_parent); - if (!dir) { - status = PMD_ERROR_FILE_NOT_FOUND; - goto cleanup; - } - /* Find first file matching the pattern */ int found = 0; - int64_t first_iteration; - pmd_dirent *entry; - if (dir) { - while ((entry = pmd_readdir(dir)) != NULL && !found) { - /* Try to extract iteration from name matching first segment pattern */ - if (extract_iteration_from_name(entry->d_name, pattern_info.first_segment, &first_iteration) == PMD_SUCCESS) { - /* Reconstruct full file path from pattern (ie for paths that look like data_%T/file_%T.h5 where we only found - * the directory data_1) */ - char *full_path = replace_iteration(filename, first_iteration); - if (!full_path) { - pmd_closedir(dir); - free_iteration_pattern(&pattern_info); - free(series); - return PMD_ERROR_OUT_OF_MEMORY; - } - - /* Test for file existance */ - FILE *test = fopen(full_path, "rb"); - if (test) { - found = 1; - free(full_path); - (void)fclose(test); - break; - } - free(full_path); - } - } - pmd_closedir(dir); - } + int64_t first_iteration = 0; + status = find_first_iteration_file(filename, &pattern_info, &found, &first_iteration); + if (status != PMD_SUCCESS) goto cleanup; if (!found) { /* No existing files found */ @@ -1935,7 +1950,6 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac } else { iter_filename = replace_iteration(filename, first_iteration); if (!iter_filename) { - pmd_closedir(dir); status = PMD_ERROR_OUT_OF_MEMORY; goto cleanup; } From 332526224c4d1c6dbb1c7e358e57628ab38567dd Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 27 Aug 2026 00:25:06 -0700 Subject: [PATCH 05/22] move out path_after_directory --- parcel.h | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/parcel.h b/parcel.h index c2d84f9..788bd7c 100644 --- a/parcel.h +++ b/parcel.h @@ -1715,6 +1715,28 @@ static void normalize_path_separators(char *path) { } } +/** + * Skip a leading directory prefix on a path + * Returns a pointer into path just past directory and its separator, or path unchanged when + * directory is "." or is not a prefix of path + */ +static const char* path_after_directory(const char *path, const char *directory) { + if (strcmp(directory, ".") == 0) { + return path; + } + + size_t dir_len = strlen(directory); + if (strncmp(path, directory, dir_len) != 0) { + return path; + } + + const char *rest = path + dir_len; + if (*rest == '/' || *rest == '\\') { + rest++; + } + return rest; +} + /** * Delete all iteration files matching the pattern * Used during truncate mode to remove existing iterations @@ -1890,18 +1912,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac series->iteration_encoding = PMD_FILE_BASED; /* Extract filename pattern (everything after directory) */ - const char *filename_pattern = filename; - if (strcmp(pattern_info.scan_parent, ".") != 0) { - /* Skip past directory and separator */ - size_t dir_len = strlen(pattern_info.scan_parent); - if (strncmp(filename, pattern_info.scan_parent, dir_len) == 0) { - filename_pattern = filename + dir_len; - /* Skip separator */ - if (*filename_pattern == '/' || *filename_pattern == '\\') { - filename_pattern++; - } - } - } + const char *filename_pattern = path_after_directory(filename, pattern_info.scan_parent); series->iteration_format = pmd_strdup(filename_pattern); normalize_path_separators(series->iteration_format); series->base_path = pmd_strdup("/data/%T/"); @@ -1928,18 +1939,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac series->iteration_encoding = PMD_FILE_BASED; /* Extract filename pattern (everything after directory) */ - const char *filename_pattern = filename; - if (strcmp(pattern_info.scan_parent, ".") != 0) { - /* Skip past directory and separator */ - size_t dir_len = strlen(pattern_info.scan_parent); - if (strncmp(filename, pattern_info.scan_parent, dir_len) == 0) { - filename_pattern = filename + dir_len; - /* Skip separator */ - if (*filename_pattern == '/' || *filename_pattern == '\\') { - filename_pattern++; - } - } - } + const char *filename_pattern = path_after_directory(filename, pattern_info.scan_parent); series->iteration_format = pmd_strdup(filename_pattern); normalize_path_separators(series->iteration_format); series->base_path = pmd_strdup("/data/%T/"); From 247d06171cb05560edf01fa114f18e8bc30e7341 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 27 Aug 2026 00:31:03 -0700 Subject: [PATCH 06/22] breaking out more pieces from open_series --- parcel.h | 58 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/parcel.h b/parcel.h index 788bd7c..c004585 100644 --- a/parcel.h +++ b/parcel.h @@ -1520,6 +1520,42 @@ static pmd_status ensure_parent_groups(hid_t file_id, const char *path) { return PMD_SUCCESS; } +/** + * Create the group structure above the iteration groups of a GROUP_BASED series + * Lets pmd_list_iterations enumerate groups before any iteration exists, e.g. creates "/data" + * for a base path of "/data/%T/" + */ +static pmd_status ensure_base_path_groups(hid_t file_id, const char *base_path) { + iteration_pattern pattern_info; + + pmd_status status = parse_iteration_pattern(base_path, &pattern_info); + if (status != PMD_SUCCESS) { + return status; + } + + /* Base path has no groups above the iteration groups */ + if (strcmp(pattern_info.scan_parent, ".") == 0) { + free_iteration_pattern(&pattern_info); + return PMD_SUCCESS; + } + + status = ensure_parent_groups(file_id, pattern_info.scan_parent); + if (status == PMD_SUCCESS) { + /* Create the scan_parent group itself */ + htri_t exists = H5Lexists(file_id, pattern_info.scan_parent, H5P_DEFAULT); + if (exists == 0) { + hid_t group_id = H5Gcreate(file_id, pattern_info.scan_parent, + H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT); + if (group_id >= 0) { + H5Gclose(group_id); + } + } + } + + free_iteration_pattern(&pattern_info); + return status; +} + /** * Read series metadata from an existing HDF5 file * @@ -2000,27 +2036,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac if (status != PMD_SUCCESS) goto cleanup; /* Create base path structure up to scan_parent for GROUP_BASED */ - /* This ensures pmd_list_iterations can enumerate groups even when no iterations exist yet */ - iteration_pattern pattern_info; - status = parse_iteration_pattern(series->base_path, &pattern_info); - if (status == PMD_SUCCESS) { - /* Create the scan_parent group if it doesn't exist (e.g., "/data" for "/data/%T/") */ - if (strcmp(pattern_info.scan_parent, ".") != 0) { - status = ensure_parent_groups(file_id, pattern_info.scan_parent); - if (status == PMD_SUCCESS) { - /* Create the scan_parent group itself */ - htri_t exists = H5Lexists(file_id, pattern_info.scan_parent, H5P_DEFAULT); - if (exists == 0) { - hid_t group_id = H5Gcreate(file_id, pattern_info.scan_parent, - H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT); - if (group_id >= 0) { - H5Gclose(group_id); - } - } - } - } - free_iteration_pattern(&pattern_info); - } + status = ensure_base_path_groups(file_id, series->base_path); /* Keep file open for group-based */ series->file_id = file_id; From 0a83ed980e25a78bd12377546d6644dce7598cab Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 27 Aug 2026 01:05:56 -0700 Subject: [PATCH 07/22] add padding to string processing tools --- parcel.h | 56 +++++++++----- tests/test_utilities.c | 164 ++++++++++++++++++++++++++++++++++------- tests/test_write.c | 2 +- 3 files changed, 175 insertions(+), 47 deletions(-) diff --git a/parcel.h b/parcel.h index c004585..192b753 100644 --- a/parcel.h +++ b/parcel.h @@ -746,8 +746,8 @@ typedef struct { static pmd_status parse_iteration_pattern(const char *pattern, iteration_pattern *info); static void free_iteration_pattern(iteration_pattern *info); static pmd_status extract_iteration_from_name(const char *name, const char *pattern, - int64_t *iteration_out); -static char* replace_iteration(const char *pattern, int64_t iteration); + int64_t *iteration_out, unsigned int *padding_out); +static char* replace_iteration(const char *pattern, int64_t iteration, unsigned int padding); /* ========================================================================= * C99-Compliant String Utilities @@ -1796,11 +1796,11 @@ static pmd_status delete_matching_iteration_files(const char *pattern) { pmd_dirent *del_entry; while ((del_entry = pmd_readdir(del_dir)) != NULL) { int64_t iter_index; - if (extract_iteration_from_name(del_entry->d_name, pattern_info.first_segment, &iter_index) == PMD_SUCCESS) { + if (extract_iteration_from_name(del_entry->d_name, pattern_info.first_segment, &iter_index, NULL) == PMD_SUCCESS) { /* Reconstruct full file path from pattern (for paths like data_%T/file_%T.h5 * where we only found the directory data_1) */ free(full_path); - full_path = replace_iteration(pattern, iter_index); + full_path = replace_iteration(pattern, iter_index, 0); if (!full_path) { pmd_log(PMD_LOG_ERROR, "delete_matching_iteration_files - out of memory constructing path."); status = PMD_ERROR_OUT_OF_MEMORY; @@ -1844,13 +1844,13 @@ static pmd_status find_first_iteration_file(const char *filename, const iteratio int64_t iteration; /* Try to extract iteration from name matching first segment pattern */ - if (extract_iteration_from_name(entry->d_name, pattern_info->first_segment, &iteration) != PMD_SUCCESS) { + if (extract_iteration_from_name(entry->d_name, pattern_info->first_segment, &iteration, NULL) != PMD_SUCCESS) { continue; } /* Reconstruct full file path from pattern (ie for paths that look like data_%T/file_%T.h5 where we only found * the directory data_1) */ - char *full_path = replace_iteration(filename, iteration); + char *full_path = replace_iteration(filename, iteration, 0); if (!full_path) { pmd_log(PMD_LOG_ERROR, "find_first_iteration_file - out of memory constructing path."); pmd_closedir(dir); @@ -1984,7 +1984,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac /* Don't create any files yet - will be created when iterations are added */ series->file_id = -1; } else { - iter_filename = replace_iteration(filename, first_iteration); + iter_filename = replace_iteration(filename, first_iteration, 0); if (!iter_filename) { status = PMD_ERROR_OUT_OF_MEMORY; goto cleanup; @@ -2099,7 +2099,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac if (match_status == PMD_SUCCESS) { pmd_status extract_status = extract_iteration_from_name(actual_basename, actual_pattern.first_segment, - &extracted_iteration); + &extracted_iteration, NULL); free_iteration_pattern(&actual_pattern); if (extract_status != PMD_SUCCESS) { /* Filename doesn't match the pattern specified in iterationFormat */ @@ -2201,14 +2201,14 @@ static herr_t collect_iterations_callback(hid_t loc_id, const char *name, int64_t iteration; /* Try to extract iteration number from name matching first segment pattern */ - if (extract_iteration_from_name(name, collector->first_segment, &iteration) != PMD_SUCCESS) { + if (extract_iteration_from_name(name, collector->first_segment, &iteration, NULL) != PMD_SUCCESS) { return 0; /* Name doesn't match pattern, skip */ } /* Validate full path if pattern has additional components after first segment */ if (collector->full_pattern) { /* Replace all %T in full pattern with extracted iteration */ - char *full_path = replace_iteration(collector->full_pattern, iteration); + char *full_path = replace_iteration(collector->full_pattern, iteration, 0); if (!full_path) { collector->status = PMD_ERROR_OUT_OF_MEMORY; return -1; @@ -2319,7 +2319,7 @@ static pmd_status pmd_parse_iterations(pmd_series *series) { int64_t iteration; /* Try to extract iteration from name matching first segment pattern */ - if (extract_iteration_from_name(entry->d_name, pattern_info.first_segment, &iteration) != PMD_SUCCESS) { + if (extract_iteration_from_name(entry->d_name, pattern_info.first_segment, &iteration, NULL) != PMD_SUCCESS) { continue; /* Name doesn't match, skip */ } @@ -2328,7 +2328,7 @@ static pmd_status pmd_parse_iterations(pmd_series *series) { int sstatus; /* Build full file path with iteration substituted */ - char *rel_path = replace_iteration(series->iteration_format, iteration); + char *rel_path = replace_iteration(series->iteration_format, iteration, 0); if (!rel_path) { collector.status = PMD_ERROR_OUT_OF_MEMORY; break; @@ -2570,7 +2570,7 @@ static void free_iteration_pattern(iteration_pattern *info) { * @return PMD_SUCCESS if match found and iteration extracted, error otherwise */ static pmd_status extract_iteration_from_name(const char *name, const char *pattern, - int64_t *iteration_out) { + int64_t *iteration_out, unsigned int *padding_out) { const char *p = pattern; const char *n = name; char matched_number[64] = {0}; @@ -2580,6 +2580,10 @@ static pmd_status extract_iteration_from_name(const char *name, const char *patt return PMD_ERROR_NULL_POINTER; } + if (padding_out) { + *padding_out = 0; + } + /* Check for ambiguous consecutive %T patterns */ const char *check = pattern; while (*check) { @@ -2649,6 +2653,15 @@ static pmd_status extract_iteration_from_name(const char *name, const char *patt return PMD_ERROR; } + /* Report the zero padding width, if the name was wider than the bare number needs */ + if (padding_out) { + char unpadded[64]; + int unpadded_len = snprintf(unpadded, sizeof(unpadded), "%lld", (long long)*iteration_out); + if (unpadded_len > 0 && (size_t)unpadded_len < strlen(matched_number)) { + *padding_out = (unsigned int)strlen(matched_number); + } + } + return PMD_SUCCESS; } @@ -2667,7 +2680,7 @@ static pmd_status extract_iteration_from_name(const char *name, const char *patt * @param iteration Iteration number to substitute * @return Newly allocated string with all %T replaced (caller must free), or NULL on error */ -static char* replace_iteration(const char *pattern, int64_t iteration) { +static char* replace_iteration(const char *pattern, int64_t iteration, unsigned int padding) { if (!pattern) { return NULL; } @@ -2685,9 +2698,14 @@ static char* replace_iteration(const char *pattern, int64_t iteration) { return pmd_strdup(pattern); } - /* Format iteration number */ - char iter_str[32]; - int status = snprintf(iter_str, sizeof(iter_str), "%lld", (long long)iteration); + /* Format iteration number, zero padded to the requested width (0 means no minimum width) */ + char iter_str[64]; + if (padding >= sizeof(iter_str)) { + pmd_log(PMD_LOG_ERROR, "replace_iteration - Padding width %u exceeds maximum of %zu.", + padding, sizeof(iter_str) - 1); + return NULL; + } + int status = snprintf(iter_str, sizeof(iter_str), "%0*lld", (int)padding, (long long)iteration); if (status < 0) { pmd_log(PMD_LOG_ERROR, "replace_iteration - Failed to format iteration count into string."); return NULL; @@ -2893,7 +2911,7 @@ pmd_status pmd_open_iteration(pmd_series *series, int64_t index, pmd_iteration * iter->iteration_group_id = -1; /* Construct iteration group path */ - iteration_path = replace_iteration(series->base_path, index); + iteration_path = replace_iteration(series->base_path, index, 0); if (!iteration_path) { status = PMD_ERROR_OUT_OF_MEMORY; goto cleanup; @@ -2970,7 +2988,7 @@ pmd_status pmd_open_iteration(pmd_series *series, int64_t index, pmd_iteration * } } else { /* Not already open - open or create file for this iteration */ - filename = replace_iteration(series->iteration_format, index); + filename = replace_iteration(series->iteration_format, index, 0); if (!filename) { status = PMD_ERROR_OUT_OF_MEMORY; goto cleanup; diff --git a/tests/test_utilities.c b/tests/test_utilities.c index ad35d24..87265f2 100644 --- a/tests/test_utilities.c +++ b/tests/test_utilities.c @@ -92,70 +92,123 @@ void test_extract_iteration_from_name(void) { pmd_status status; /* Simple extraction */ - status = extract_iteration_from_name("data_0", "data_%T", &iteration); + status = extract_iteration_from_name("data_0", "data_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); TEST_ASSERT_EQUAL_INT64(0, iteration); - status = extract_iteration_from_name("data_123", "data_%T", &iteration); + status = extract_iteration_from_name("data_123", "data_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); TEST_ASSERT_EQUAL_INT64(123, iteration); - status = extract_iteration_from_name("data_999999", "data_%T", &iteration); + status = extract_iteration_from_name("data_999999", "data_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); TEST_ASSERT_EQUAL_INT64(999999, iteration); /* With file extension */ - status = extract_iteration_from_name("sim_42.h5", "sim_%T.h5", &iteration); + status = extract_iteration_from_name("sim_42.h5", "sim_%T.h5", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); TEST_ASSERT_EQUAL_INT64(42, iteration); /* Multiple %T in pattern - should extract the number */ - status = extract_iteration_from_name("data_5_step_5", "data_%T_step_%T", &iteration); + status = extract_iteration_from_name("data_5_step_5", "data_%T_step_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); TEST_ASSERT_EQUAL_INT64(5, iteration); /* Just %T pattern */ - status = extract_iteration_from_name("123", "%T", &iteration); + status = extract_iteration_from_name("123", "%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); TEST_ASSERT_EQUAL_INT64(123, iteration); /* Leading zeros */ - status = extract_iteration_from_name("data_00042", "data_%T", &iteration); + status = extract_iteration_from_name("data_00042", "data_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); TEST_ASSERT_EQUAL_INT64(42, iteration); /* Mismatch cases - should fail */ - status = extract_iteration_from_name("data_abc", "data_%T", &iteration); + status = extract_iteration_from_name("data_abc", "data_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR, status); - status = extract_iteration_from_name("wrong_5", "data_%T", &iteration); + status = extract_iteration_from_name("wrong_5", "data_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR, status); - status = extract_iteration_from_name("data_5.txt", "data_%T.h5", &iteration); + status = extract_iteration_from_name("data_5.txt", "data_%T.h5", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR, status); /* Multiple %T with different numbers - should fail */ - status = extract_iteration_from_name("data_5_step_6", "data_%T_step_%T", &iteration); + status = extract_iteration_from_name("data_5_step_6", "data_%T_step_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR, status); /* Consecutive %T patterns - ambiguous, should fail */ - status = extract_iteration_from_name("data_123", "data_%T%T", &iteration); + status = extract_iteration_from_name("data_123", "data_%T%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR, status); - status = extract_iteration_from_name("123", "%T%T", &iteration); + status = extract_iteration_from_name("123", "%T%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR, status); - status = extract_iteration_from_name("sim_456_end", "sim_%T%T_end", &iteration); + status = extract_iteration_from_name("sim_456_end", "sim_%T%T_end", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR, status); /* NULL input */ - status = extract_iteration_from_name(NULL, "data_%T", &iteration); + status = extract_iteration_from_name(NULL, "data_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR_NULL_POINTER, status); - status = extract_iteration_from_name("data_5", NULL, &iteration); + status = extract_iteration_from_name("data_5", NULL, &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR_NULL_POINTER, status); } +/* ========================================================================= + * extract_iteration_from_name() padding detection tests + * ========================================================================= */ + +void test_extract_iteration_padding(void) { + int64_t iteration; + unsigned int padding; + pmd_status status; + + /* Zero padded names report the full digit width */ + status = extract_iteration_from_name("00001", "%T", &iteration, &padding); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); + TEST_ASSERT_EQUAL_INT64(1, iteration); + TEST_ASSERT_EQUAL_UINT(5, padding); + + status = extract_iteration_from_name("data_0042.h5", "data_%T.h5", &iteration, &padding); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); + TEST_ASSERT_EQUAL_INT64(42, iteration); + TEST_ASSERT_EQUAL_UINT(4, padding); + + /* All zeros is still padding when wider than the bare value */ + status = extract_iteration_from_name("0000", "%T", &iteration, &padding); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); + TEST_ASSERT_EQUAL_INT64(0, iteration); + TEST_ASSERT_EQUAL_UINT(4, padding); + + /* Unpadded names report no padding, including multi digit ones */ + status = extract_iteration_from_name("1", "%T", &iteration, &padding); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); + TEST_ASSERT_EQUAL_UINT(0, padding); + + status = extract_iteration_from_name("10", "%T", &iteration, &padding); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); + TEST_ASSERT_EQUAL_INT64(10, iteration); + TEST_ASSERT_EQUAL_UINT(0, padding); + + status = extract_iteration_from_name("0", "%T", &iteration, &padding); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); + TEST_ASSERT_EQUAL_INT64(0, iteration); + TEST_ASSERT_EQUAL_UINT(0, padding); + + /* Padding is reset on every call, so a stale value cannot leak through */ + padding = 7; + status = extract_iteration_from_name("123", "%T", &iteration, &padding); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); + TEST_ASSERT_EQUAL_UINT(0, padding); + + /* Padding out is optional */ + status = extract_iteration_from_name("00001", "%T", &iteration, NULL); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, status); + TEST_ASSERT_EQUAL_INT64(1, iteration); +} + /* ========================================================================= * replace_iteration() tests * ========================================================================= */ @@ -164,62 +217,117 @@ void test_replace_iteration(void) { char *result; /* Single %T replacement */ - result = replace_iteration("data_%T.h5", 0); + result = replace_iteration("data_%T.h5", 0, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("data_0.h5", result); free(result); - result = replace_iteration("data_%T.h5", 123); + result = replace_iteration("data_%T.h5", 123, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("data_123.h5", result); free(result); - result = replace_iteration("iter_%T", 42); + result = replace_iteration("iter_%T", 42, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("iter_42", result); free(result); /* Multiple %T replacements - all should get same number */ - result = replace_iteration("data_%T/step_%T/final.h5", 5); + result = replace_iteration("data_%T/step_%T/final.h5", 5, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("data_5/step_5/final.h5", result); free(result); - result = replace_iteration("/path/%T/data_%T.h5", 100); + result = replace_iteration("/path/%T/data_%T.h5", 100, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("/path/100/data_100.h5", result); free(result); - result = replace_iteration("sim_%T_iter_%T_final_%T.h5", 7); + result = replace_iteration("sim_%T_iter_%T_final_%T.h5", 7, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("sim_7_iter_7_final_7.h5", result); free(result); /* No %T in template - should return copy of template */ - result = replace_iteration("no_iteration.h5", 999); + result = replace_iteration("no_iteration.h5", 999, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("no_iteration.h5", result); free(result); /* Just %T */ - result = replace_iteration("%T", 0); + result = replace_iteration("%T", 0, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("0", result); free(result); - result = replace_iteration("%T", 12345); + result = replace_iteration("%T", 12345, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("12345", result); free(result); /* Large iteration numbers */ - result = replace_iteration("data_%T.h5", 1000000); + result = replace_iteration("data_%T.h5", 1000000, 0); TEST_ASSERT_NOT_NULL(result); TEST_ASSERT_EQUAL_STRING("data_1000000.h5", result); free(result); /* NULL input */ - result = replace_iteration(NULL, 5); + result = replace_iteration(NULL, 5, 0); + TEST_ASSERT_NULL(result); +} + +/* ========================================================================= + * replace_iteration() padding tests + * ========================================================================= */ + +void test_replace_iteration_padding(void) { + char *result; + + /* Zero padded to the requested width */ + result = replace_iteration("/data/%T/", 1, 5); + TEST_ASSERT_NOT_NULL(result); + TEST_ASSERT_EQUAL_STRING("/data/00001/", result); + free(result); + + result = replace_iteration("data_%T.h5", 42, 4); + TEST_ASSERT_NOT_NULL(result); + TEST_ASSERT_EQUAL_STRING("data_0042.h5", result); + free(result); + + result = replace_iteration("%T", 0, 3); + TEST_ASSERT_NOT_NULL(result); + TEST_ASSERT_EQUAL_STRING("000", result); + free(result); + + /* Every %T gets the same padded width */ + result = replace_iteration("data_%T/step_%T.h5", 7, 3); + TEST_ASSERT_NOT_NULL(result); + TEST_ASSERT_EQUAL_STRING("data_007/step_007.h5", result); + free(result); + + /* Padding narrower than the number does not truncate it */ + result = replace_iteration("data_%T.h5", 123456, 3); + TEST_ASSERT_NOT_NULL(result); + TEST_ASSERT_EQUAL_STRING("data_123456.h5", result); + free(result); + + /* Padding equal to the number width changes nothing */ + result = replace_iteration("data_%T.h5", 123, 3); + TEST_ASSERT_NOT_NULL(result); + TEST_ASSERT_EQUAL_STRING("data_123.h5", result); + free(result); + + /* Padding of 0 is the unpadded behaviour */ + result = replace_iteration("data_%T.h5", 5, 0); + TEST_ASSERT_NOT_NULL(result); + TEST_ASSERT_EQUAL_STRING("data_5.h5", result); + free(result); + + /* Padding wider than the internal buffer is rejected */ + result = replace_iteration("data_%T.h5", 5, 64); + TEST_ASSERT_NULL(result); + + result = replace_iteration("data_%T.h5", 5, 1000); TEST_ASSERT_NULL(result); } @@ -231,7 +339,9 @@ int main(void) { UNITY_BEGIN(); RUN_TEST(test_parse_iteration_pattern); RUN_TEST(test_extract_iteration_from_name); + RUN_TEST(test_extract_iteration_padding); RUN_TEST(test_replace_iteration); + RUN_TEST(test_replace_iteration_padding); /* Clean up HDF5 library internal resources */ H5close(); diff --git a/tests/test_write.c b/tests/test_write.c index e760aa0..e7a6e70 100644 --- a/tests/test_write.c +++ b/tests/test_write.c @@ -1300,7 +1300,7 @@ void test_openpmd_required_attributes(void) { file_id = series->file_id; } else { /* For file-based, open first file to check root attributes */ - char *filename = replace_iteration(cases[case_idx].pattern, test_iterations[0]); + char *filename = replace_iteration(cases[case_idx].pattern, test_iterations[0], 0); file_id = H5Fopen(filename, H5F_ACC_RDONLY, H5P_DEFAULT); free(filename); } From 59f2fe91bff2c0045c67dfe52f925fe3dbd85b2d Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 27 Aug 2026 01:12:43 -0700 Subject: [PATCH 08/22] add zero padding to series; add to group-based search --- parcel.h | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/parcel.h b/parcel.h index 192b753..349e5d1 100644 --- a/parcel.h +++ b/parcel.h @@ -187,6 +187,10 @@ typedef struct { /* Iteration encoding type */ pmd_iteration_encoding iteration_encoding; + /* Zero padding width applied to %T when it is expanded into a path or filename + * 0 means no padding, N means expand to at least N digits (e.g. 5 gives "/data/00001/") */ + unsigned int iteration_padding; + /* Optional paths (private - use accessor functions) */ char *_particles_path; /* e.g., "particles/" */ char *_meshes_path; /* e.g., "meshes/" */ @@ -1556,6 +1560,83 @@ static pmd_status ensure_base_path_groups(hid_t file_id, const char *base_path) return status; } +typedef struct { + const char *first_segment; /* First path segment of the pattern, e.g. "%T" */ + unsigned int padding; /* Zero padding width of the first matching name */ +} iteration_padding_probe; + +/** + * Callback for H5Literate that reports the padding of the first name matching the pattern + */ +static herr_t first_iteration_padding_callback(hid_t loc_id, const char *name, + const H5L_info_t *info, void *op_data) { + iteration_padding_probe *probe = (iteration_padding_probe *)op_data; + int64_t iteration; + + (void)loc_id; + (void)info; + + if (extract_iteration_from_name(name, probe->first_segment, &iteration, &probe->padding) != PMD_SUCCESS) { + return 0; /* Name doesn't match pattern, keep looking */ + } + + return 1; /* Stop iterating, the first match decides the padding */ +} + +/** + * Detect the zero padding applied to %T by the iteration groups of an open file + * + * Scans the group holding the iterations (e.g. "/data" for a base path of "/data/%T/") and reports + * the padding of the first matching name. Reports 0 when no name matches or the names are unpadded, + * so a caller that has already detected a padding elsewhere can leave it untouched rather than + * clobbering it with a zero. + */ +static pmd_status detect_iteration_padding_in_file(hid_t file_id, const char *pattern, + unsigned int *padding_out) { + iteration_pattern pattern_info; + iteration_padding_probe probe; + + if (!pattern || !padding_out) { + return PMD_ERROR_NULL_POINTER; + } + + *padding_out = 0; + + pmd_status status = parse_iteration_pattern(pattern, &pattern_info); + if (status != PMD_SUCCESS) { + return status; + } + + /* No iterations stored yet - nothing to detect */ + if (record_exists(file_id, pattern_info.scan_parent) < 1) { + free_iteration_pattern(&pattern_info); + return PMD_SUCCESS; + } + + hid_t group_id = H5Gopen(file_id, pattern_info.scan_parent, H5P_DEFAULT); + if (group_id < 0) { + free_iteration_pattern(&pattern_info); + return PMD_ERROR_HDF5; + } + + probe.first_segment = pattern_info.first_segment; + probe.padding = 0; + + herr_t iter_result = H5Literate(group_id, H5_INDEX_NAME, H5_ITER_INC, NULL, + first_iteration_padding_callback, &probe); + + H5Gclose(group_id); + free_iteration_pattern(&pattern_info); + + if (iter_result < 0) { + return PMD_ERROR_HDF5; + } + + *padding_out = probe.padding; + return PMD_SUCCESS; +} + + /** * Read series metadata from an existing HDF5 file * @@ -1726,6 +1807,17 @@ static pmd_status read_series_metadata_from_file(hid_t file_id, pmd_series *seri return PMD_ERROR_FILE_FORMAT; } + /* Detect zero padded iteration groups (e.g. "/data/00001/" for a base path of "/data/%T/") */ + unsigned int detected_padding = 0; + status = detect_iteration_padding_in_file(file_id, series->base_path, &detected_padding); + if (status != PMD_SUCCESS) { + free(iter_encoding_str); + return status; + } + if (detected_padding != 0) { + series->iteration_padding = detected_padding; + } + /* For groupBased, keep file open */ series->file_id = file_id; } else { @@ -1914,6 +2006,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac series->_meshes_path = NULL; series->directory = NULL; series->iteration_format = NULL; + series->iteration_padding = 0; series->base_path = NULL; series->_particles_path = NULL; series->_author = NULL; @@ -2189,6 +2282,7 @@ typedef struct { const char *first_segment; /* Pattern for first segment (e.g., "data_%T") */ const char *full_pattern; /* Full pattern for validation (e.g., "/data/%T/step_%T/") */ hid_t root_id; /* Root file ID for validating full paths */ + unsigned int padding; /* Zero padding width applied when expanding %T */ pmd_status status; /* Error status from memory allocation */ } iteration_collector; @@ -2208,7 +2302,7 @@ static herr_t collect_iterations_callback(hid_t loc_id, const char *name, /* Validate full path if pattern has additional components after first segment */ if (collector->full_pattern) { /* Replace all %T in full pattern with extracted iteration */ - char *full_path = replace_iteration(collector->full_pattern, iteration, 0); + char *full_path = replace_iteration(collector->full_pattern, iteration, collector->padding); if (!full_path) { collector->status = PMD_ERROR_OUT_OF_MEMORY; return -1; @@ -2268,11 +2362,12 @@ static pmd_status pmd_parse_iterations(pmd_series *series) { if (status != PMD_SUCCESS) goto cleanup; /* Common collector for both GROUP_BASED and FILE_BASED */ - iteration_collector collector = {NULL, 0, 0, NULL, NULL, -1, PMD_SUCCESS}; + iteration_collector collector = {NULL, 0, 0, NULL, NULL, -1, 0, PMD_SUCCESS}; /* Initialize collector with pattern info */ collector.first_segment = pattern_info.first_segment; collector.full_pattern = pattern_info.full_pattern; + collector.padding = series->iteration_padding; if (series->iteration_encoding == PMD_GROUP_BASED) { /* GROUP_BASED: enumerate groups using pattern matching */ @@ -2911,7 +3006,7 @@ pmd_status pmd_open_iteration(pmd_series *series, int64_t index, pmd_iteration * iter->iteration_group_id = -1; /* Construct iteration group path */ - iteration_path = replace_iteration(series->base_path, index, 0); + iteration_path = replace_iteration(series->base_path, index, series->iteration_padding); if (!iteration_path) { status = PMD_ERROR_OUT_OF_MEMORY; goto cleanup; From 884aefc4b7f39141abb9442f04271cfb884a1adf Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 27 Aug 2026 01:17:58 -0700 Subject: [PATCH 09/22] detect and use zero-padding in file-based search and open methods --- parcel.h | 56 +++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/parcel.h b/parcel.h index 349e5d1..172fae7 100644 --- a/parcel.h +++ b/parcel.h @@ -1795,6 +1795,19 @@ static pmd_status read_series_metadata_from_file(hid_t file_id, pmd_series *seri } free_iteration_pattern(&pattern_check); + /* Detect zero padded iteration groups inside the file (e.g. "/data/00001/"). Only adopt a + * padding that was actually found, so an unpadded group never clears a padding already + * detected from the filenames on disk. */ + unsigned int detected_padding = 0; + status = detect_iteration_padding_in_file(file_id, series->base_path, &detected_padding); + if (status != PMD_SUCCESS) { + free(iter_encoding_str); + return status; + } + if (detected_padding != 0) { + series->iteration_padding = detected_padding; + } + /* Don't keep file open for fileBased */ H5Fclose(file_id); series->file_id = -1; @@ -1888,11 +1901,12 @@ static pmd_status delete_matching_iteration_files(const char *pattern) { pmd_dirent *del_entry; while ((del_entry = pmd_readdir(del_dir)) != NULL) { int64_t iter_index; - if (extract_iteration_from_name(del_entry->d_name, pattern_info.first_segment, &iter_index, NULL) == PMD_SUCCESS) { + unsigned int iter_padding; + if (extract_iteration_from_name(del_entry->d_name, pattern_info.first_segment, &iter_index, &iter_padding) == PMD_SUCCESS) { /* Reconstruct full file path from pattern (for paths like data_%T/file_%T.h5 * where we only found the directory data_1) */ free(full_path); - full_path = replace_iteration(pattern, iter_index, 0); + full_path = replace_iteration(pattern, iter_index, iter_padding); if (!full_path) { pmd_log(PMD_LOG_ERROR, "delete_matching_iteration_files - out of memory constructing path."); status = PMD_ERROR_OUT_OF_MEMORY; @@ -1917,14 +1931,17 @@ static pmd_status delete_matching_iteration_files(const char *pattern) { } /** - * Find the lowest-numbered existing file matching an iteration pattern - * Sets *found_out to 1 and *iteration_out to the iteration index when a match exists on disk + * Find the first existing file matching an iteration pattern, in directory order + * Sets *found_out to 1, *iteration_out to the iteration index and *padding_out to the zero padding + * of the matched name when a match exists on disk */ static pmd_status find_first_iteration_file(const char *filename, const iteration_pattern *pattern_info, - int *found_out, int64_t *iteration_out) { + int *found_out, int64_t *iteration_out, + unsigned int *padding_out) { pmd_dirent *entry; *found_out = 0; + *padding_out = 0; /* Open directory and search for matching files */ pmd_dir *dir = pmd_opendir(pattern_info->scan_parent); @@ -1934,15 +1951,17 @@ static pmd_status find_first_iteration_file(const char *filename, const iteratio while ((entry = pmd_readdir(dir)) != NULL) { int64_t iteration; + unsigned int padding; /* Try to extract iteration from name matching first segment pattern */ - if (extract_iteration_from_name(entry->d_name, pattern_info->first_segment, &iteration, NULL) != PMD_SUCCESS) { + if (extract_iteration_from_name(entry->d_name, pattern_info->first_segment, &iteration, &padding) != PMD_SUCCESS) { continue; } /* Reconstruct full file path from pattern (ie for paths that look like data_%T/file_%T.h5 where we only found - * the directory data_1) */ - char *full_path = replace_iteration(filename, iteration, 0); + * the directory data_1). The padding of the matched name has to be carried over or a zero padded + * name on disk rebuilds to a path that does not exist. */ + char *full_path = replace_iteration(filename, iteration, padding); if (!full_path) { pmd_log(PMD_LOG_ERROR, "find_first_iteration_file - out of memory constructing path."); pmd_closedir(dir); @@ -1956,6 +1975,7 @@ static pmd_status find_first_iteration_file(const char *filename, const iteratio (void)fclose(test); *found_out = 1; *iteration_out = iteration; + *padding_out = padding; break; } } @@ -2026,9 +2046,13 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac /* Find first file matching the pattern */ int found = 0; int64_t first_iteration = 0; - status = find_first_iteration_file(filename, &pattern_info, &found, &first_iteration); + unsigned int first_padding = 0; + status = find_first_iteration_file(filename, &pattern_info, &found, &first_iteration, &first_padding); if (status != PMD_SUCCESS) goto cleanup; + /* Existing files decide the padding used to expand %T for the rest of this series */ + series->iteration_padding = first_padding; + if (!found) { /* No existing files found */ if (!is_write_mode) { @@ -2077,7 +2101,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac /* Don't create any files yet - will be created when iterations are added */ series->file_id = -1; } else { - iter_filename = replace_iteration(filename, first_iteration, 0); + iter_filename = replace_iteration(filename, first_iteration, series->iteration_padding); if (!iter_filename) { status = PMD_ERROR_OUT_OF_MEMORY; goto cleanup; @@ -2187,18 +2211,24 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac /* Try to extract iteration from actual filename using the iterationFormat pattern */ int64_t extracted_iteration; + unsigned int extracted_padding = 0; iteration_pattern actual_pattern; pmd_status match_status = parse_iteration_pattern(series->iteration_format, &actual_pattern); if (match_status == PMD_SUCCESS) { pmd_status extract_status = extract_iteration_from_name(actual_basename, actual_pattern.first_segment, - &extracted_iteration, NULL); + &extracted_iteration, &extracted_padding); free_iteration_pattern(&actual_pattern); if (extract_status != PMD_SUCCESS) { /* Filename doesn't match the pattern specified in iterationFormat */ status = PMD_ERROR_FILE_FORMAT; goto cleanup; } + + /* A zero padded name (e.g. "data_0007.h5") sets the padding for this series */ + if (extracted_padding != 0) { + series->iteration_padding = extracted_padding; + } } } } @@ -2423,7 +2453,7 @@ static pmd_status pmd_parse_iterations(pmd_series *series) { int sstatus; /* Build full file path with iteration substituted */ - char *rel_path = replace_iteration(series->iteration_format, iteration, 0); + char *rel_path = replace_iteration(series->iteration_format, iteration, series->iteration_padding); if (!rel_path) { collector.status = PMD_ERROR_OUT_OF_MEMORY; break; @@ -3083,7 +3113,7 @@ pmd_status pmd_open_iteration(pmd_series *series, int64_t index, pmd_iteration * } } else { /* Not already open - open or create file for this iteration */ - filename = replace_iteration(series->iteration_format, index, 0); + filename = replace_iteration(series->iteration_format, index, series->iteration_padding); if (!filename) { status = PMD_ERROR_OUT_OF_MEMORY; goto cleanup; From c3be00689cbed492d03e495c1f386611c73e27d3 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 27 Aug 2026 01:26:34 -0700 Subject: [PATCH 10/22] add some tests for zero-padded file-based series --- python/generate_test_data.py | 24 +++++++++++++ tests/test_read.c | 51 ++++++++++++++++++++++++++ tests/test_read_write.c | 70 ++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/python/generate_test_data.py b/python/generate_test_data.py index a0a10a2..64e59f7 100644 --- a/python/generate_test_data.py +++ b/python/generate_test_data.py @@ -1569,6 +1569,27 @@ def make_valid_file_based_series(dirname: str): print(f"make_valid_file_based_series: Created 3 files in {dirname}") +def make_zero_padded_file_based_series(dirname: str): + """File-based series whose filenames and iteration groups are zero padded""" + dir_path = Path(dirname) + dir_path.mkdir(parents=True, exist_ok=True) + + # Create 3 files: data_0001.h5, data_0002.h5, data_0003.h5 + for iteration in [1, 2, 3]: + fname = dir_path / f"data_{iteration:04d}.h5" + with h5py.File(fname, "w") as f: + write_openpmd_header( + f, base_path="/data/%T/", iteration_encoding="fileBased" + ) + f.attrs["iterationFormat"] = "data_%T.h5" + + iter_grp = f.create_group(f"data/{iteration:04d}") + write_iteration_attributes(iter_grp, time=iteration * 1.0e-15) + write_particle_group(iter_grp, "electron", 10) + + print(f"make_zero_padded_file_based_series: Created 3 files in {dirname}") + + def make_file_based_series_with_other_files(dirname: str): """File-based series with other non-matching files in directory""" dir_path = Path(dirname) @@ -1937,6 +1958,9 @@ def make_valid_partial_optional_fields(fname: str): print("=" * 80) make_valid_file_based_series(str(test_data_dir / "file_based_series")) + make_zero_padded_file_based_series( + str(test_data_dir / "file_based_series_zero_padded") + ) make_file_based_series_with_other_files( str(test_data_dir / "file_based_series_with_other") ) diff --git a/tests/test_read.c b/tests/test_read.c index 7a017d4..3a8a783 100644 --- a/tests/test_read.c +++ b/tests/test_read.c @@ -917,6 +917,56 @@ void test_valid_all_metadata(void) { pmd_close_series(series); } +/* Test: File-based series with zero padded filenames and iteration groups + * File: tests/data/file_based_series_zero_padded/data_%T.h5 + * Tests: Padding is detected from the names on disk and used to expand %T */ +void test_file_based_series_zero_padded(void) { + pmd_series *series; + pmd_iteration *iter; + pmd_status result; + int64_t *iterations; + int num_iterations; + int64_t particle_count; + + /* Opening by pattern has to scan the directory and match the padded names */ + result = pmd_open_series("tests/data/file_based_series_zero_padded/data_%T.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_NOT_NULL(series); + + /* Padding is taken from the four digit names on disk */ + TEST_ASSERT_EQUAL_INT(PMD_FILE_BASED, series->iteration_encoding); + TEST_ASSERT_EQUAL_UINT(4, series->iteration_padding); + + /* All three files are found despite the indices being padded */ + result = pmd_list_iterations(series, &iterations, &num_iterations); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_INT(3, num_iterations); + TEST_ASSERT_EQUAL_INT64(1, iterations[0]); + TEST_ASSERT_EQUAL_INT64(2, iterations[1]); + TEST_ASSERT_EQUAL_INT64(3, iterations[2]); + + /* The padded filename and the padded group inside it both resolve */ + result = pmd_open_iteration(series, iterations[1], &iter); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_NOT_NULL(iter); + + result = pmd_get_num_particles(iter, "electron", &particle_count); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_INT64(10, particle_count); + + pmd_close_iteration(iter); + free(iterations); + pmd_close_series(series); + + /* Opening one padded file by its literal name detects the same padding */ + result = pmd_open_series("tests/data/file_based_series_zero_padded/data_0002.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_UINT(4, series->iteration_padding); + pmd_close_series(series); +} + /* Test: Reference dump produced by Bmad * File: tests/data/bmad-dump.h5 * Tests: Reading Bmad file with a single iteration and 1000 particles */ @@ -2770,6 +2820,7 @@ int main(void) { RUN_TEST(test_user_supplied_arrays); RUN_TEST(test_read_openpmd_constant); RUN_TEST(test_read_openpmd_dataset); + RUN_TEST(test_file_based_series_zero_padded); RUN_TEST(test_read_bmad_dump); /* Unit Conversion tests */ diff --git a/tests/test_read_write.c b/tests/test_read_write.c index 450d2ea..2885083 100644 --- a/tests/test_read_write.c +++ b/tests/test_read_write.c @@ -26,6 +26,16 @@ #define TEST_TEMP_DIR "tests/temp_read_write" +/* Helper function to test whether a path exists */ +static int path_exists(const char *path) { + FILE *f = fopen(path, "rb"); + if (!f) { + return 0; + } + (void)fclose(f); + return 1; +} + /* Helper function to recursively remove directory */ static void remove_directory(const char *path) { #ifdef _WIN32 @@ -254,6 +264,65 @@ void test_write_and_read_particle_group_file_based(void) { test_write_and_read_particle_group_helper(TEST_TEMP_DIR "/roundtrip_%T.h5"); } +/** + * Test: Padding set on a series is used when writing and detected when reading back + */ +void test_write_and_read_zero_padded_file_based(void) { + pmd_series *series; + pmd_iteration *iter; + pmd_status result; + pmd_particle_group write_pg; + int64_t *iterations; + int num_iterations; + const int64_t NUM_PARTICLES = 16; + double x[16], y[16], z[16], px[16], py[16], pz[16], weight[16]; + + for (int64_t i = 0; i < NUM_PARTICLES; i++) { + x[i] = y[i] = z[i] = (double)i; + px[i] = py[i] = pz[i] = (double)i; + weight[i] = 1.0; + } + + memset(&write_pg, 0, sizeof(pmd_particle_group)); + write_pg.num_particles = NUM_PARTICLES; + write_pg.species_type = "electron"; + write_pg.x = x; write_pg.y = y; write_pg.z = z; + write_pg.px = px; write_pg.py = py; write_pg.pz = pz; + write_pg.weight = weight; + + /* Write a series with %T expanded to four digits */ + result = pmd_open_series(TEST_TEMP_DIR "/padded_%T.h5", &series, PMD_TRUNC); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + series->iteration_padding = 4; + + for (int64_t index = 1; index <= 2; index++) { + result = pmd_open_iteration(series, index, &iter); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + result = pmd_write_particle_group(iter, &write_pg); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + pmd_close_iteration(iter); + } + pmd_close_series(series); + + /* The padded name is the one that exists on disk */ + TEST_ASSERT_TRUE(path_exists(TEST_TEMP_DIR "/padded_0001.h5")); + TEST_ASSERT_FALSE(path_exists(TEST_TEMP_DIR "/padded_1.h5")); + + /* Reading it back detects the padding and finds both iterations */ + result = pmd_open_series(TEST_TEMP_DIR "/padded_%T.h5", &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_UINT(4, series->iteration_padding); + + result = pmd_list_iterations(series, &iterations, &num_iterations); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_INT(2, num_iterations); + TEST_ASSERT_EQUAL_INT64(1, iterations[0]); + TEST_ASSERT_EQUAL_INT64(2, iterations[1]); + + free(iterations); + pmd_close_series(series); +} + int main(void) { /* Suppress HDF5 error messages during tests */ pmd_set_log_level(PMD_LOG_NONE); @@ -263,6 +332,7 @@ int main(void) { RUN_TEST(test_write_and_read_particle_group_based); RUN_TEST(test_write_and_read_particle_group_file_based); + RUN_TEST(test_write_and_read_zero_padded_file_based); /* Clean up HDF5 library internal resources */ H5close(); From 70445ee66968f539d579b7f42636ece92401dd3d Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 27 Aug 2026 01:31:29 -0700 Subject: [PATCH 11/22] fix missed replace_iteration update for windows test --- tests/test_write.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_write.c b/tests/test_write.c index e7a6e70..50d29d6 100644 --- a/tests/test_write.c +++ b/tests/test_write.c @@ -2623,7 +2623,7 @@ void test_windows_iteration_format_normalized(void) { TEST_ASSERT_EQUAL_INT_MESSAGE(PMD_SUCCESS, result, cases[i].description); /* Open the created file directly with HDF5 and read iterationFormat attribute */ - char *actual_file = replace_iteration(cases[i].pattern, 5); + char *actual_file = replace_iteration(cases[i].pattern, 5, 0); TEST_ASSERT_NOT_NULL_MESSAGE(actual_file, cases[i].description); /* Normalize path for opening (Windows allows both) */ From bbab9d1568d80eebd158e5952770a7a75a24e13c Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 27 Aug 2026 01:33:10 -0700 Subject: [PATCH 12/22] clean up windows CI build messages --- CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7c960d1..5a02aea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,12 @@ elseif(MSVC) $<$:/W4> # Warning level 4 $<$:/std:c11> # MSVC uses C11 mode for C99 features ) + # Silence MSVC deprecation warnings for the standard C functions this project uses + # (C4996 on strncpy/strcpy/sscanf/fopen/gmtime, and on the POSIX name strdup). + add_compile_definitions( + _CRT_SECURE_NO_WARNINGS + _CRT_NONSTDC_NO_WARNINGS + ) endif() # Find HDF5 From ea5b499e0f7cb49d4374d87e60ec4dc2f44fe449 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 11:03:59 -0700 Subject: [PATCH 13/22] add tests for inconsistent padding --- python/generate_test_data.py | 71 ++++++++++++++++++++++++++++++++++++ tests/test_read.c | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/python/generate_test_data.py b/python/generate_test_data.py index 64e59f7..84446e7 100644 --- a/python/generate_test_data.py +++ b/python/generate_test_data.py @@ -1590,6 +1590,56 @@ def make_zero_padded_file_based_series(dirname: str): print(f"make_zero_padded_file_based_series: Created 3 files in {dirname}") +def make_padding_mismatch_file_based_multiple_percent_t(dirname: str): + """File-based series whose filename pads each %T to a different width""" + dir_path = Path(dirname) + dir_path.mkdir(parents=True, exist_ok=True) + + fname = dir_path / "data_0001_iter_1.h5" + with h5py.File(fname, "w") as f: + write_openpmd_header(f, base_path="/data/%T/", iteration_encoding="fileBased") + f.attrs["iterationFormat"] = "data_%T_iter_%T.h5" + + iter_grp = f.create_group("data/0001") + write_iteration_attributes(iter_grp) + write_particle_group(iter_grp, "electron", 10) + + print(f"make_padding_mismatch_file_based_multiple_percent_t: Created {fname}") + + +def make_padding_mismatch_group_based(fname: str, iteration_group: str): + """Group-based series with %T in two groups padded to different widths""" + with h5py.File(fname, "w") as f: + write_openpmd_header( + f, base_path="/data/%T/step_%T/", iteration_encoding="groupBased" + ) + + iter_grp = f.create_group(iteration_group) + write_iteration_attributes(iter_grp) + write_particle_group(iter_grp, "electron", 10) + + print(f"make_padding_mismatch_group_based: Created {fname}") + + +def make_padding_mismatch_file_based_root_group( + dirname: str, file_name: str, iteration_group: str +): + """File-based series whose filename and iteration group pad %T differently""" + dir_path = Path(dirname) + dir_path.mkdir(parents=True, exist_ok=True) + + fname = dir_path / file_name + with h5py.File(fname, "w") as f: + write_openpmd_header(f, base_path="/data/%T/", iteration_encoding="fileBased") + f.attrs["iterationFormat"] = "data_%T.h5" + + iter_grp = f.create_group(iteration_group) + write_iteration_attributes(iter_grp) + write_particle_group(iter_grp, "electron", 10) + + print(f"make_padding_mismatch_file_based_root_group: Created {fname}") + + def make_file_based_series_with_other_files(dirname: str): """File-based series with other non-matching files in directory""" dir_path = Path(dirname) @@ -1961,6 +2011,27 @@ def make_valid_partial_optional_fields(fname: str): make_zero_padded_file_based_series( str(test_data_dir / "file_based_series_zero_padded") ) + make_padding_mismatch_file_based_multiple_percent_t( + str(test_data_dir / "padding_mismatch_file_based_multiple_percent_t") + ) + make_padding_mismatch_group_based( + str(test_data_dir / "padding_mismatch_group_based_padded_outer.h5"), + "data/0001/step_1", + ) + make_padding_mismatch_group_based( + str(test_data_dir / "padding_mismatch_group_based_padded_inner.h5"), + "data/1/step_0001", + ) + make_padding_mismatch_file_based_root_group( + str(test_data_dir / "padding_mismatch_file_based_padded_filename"), + "data_0001.h5", + "data/1", + ) + make_padding_mismatch_file_based_root_group( + str(test_data_dir / "padding_mismatch_file_based_padded_group"), + "data_1.h5", + "data/0001", + ) make_file_based_series_with_other_files( str(test_data_dir / "file_based_series_with_other") ) diff --git a/tests/test_read.c b/tests/test_read.c index 3a8a783..6e195b5 100644 --- a/tests/test_read.c +++ b/tests/test_read.c @@ -967,6 +967,71 @@ void test_file_based_series_zero_padded(void) { pmd_close_series(series); } +/* Test: File-based series whose filename pads each %T to a different width + * File: tests/data/padding_mismatch_file_based_multiple_percent_t/data_0001_iter_1.h5 + * Tests: Inconsistent padding between %T in one filename is reported as a format error */ +void test_padding_mismatch_file_based_multiple_percent_t(void) { + pmd_series *series; + pmd_status result; + + result = pmd_open_series("tests/data/padding_mismatch_file_based_multiple_percent_t/data_%T_iter_%T.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + TEST_ASSERT_NULL(series); + + result = pmd_open_series("tests/data/padding_mismatch_file_based_multiple_percent_t/data_0001_iter_1.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + TEST_ASSERT_NULL(series); +} + +/* Test: Group-based series with %T in two groups padded to different widths + * Files: tests/data/padding_mismatch_group_based_padded_{outer,inner}.h5 + * Tests: /data/0001/step_1/ and /data/1/step_0001/ are reported as format errors */ +void test_padding_mismatch_group_based_multiple_percent_t(void) { + pmd_series *series; + pmd_status result; + + result = pmd_open_series("tests/data/padding_mismatch_group_based_padded_outer.h5", &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + TEST_ASSERT_NULL(series); + + result = pmd_open_series("tests/data/padding_mismatch_group_based_padded_inner.h5", &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + TEST_ASSERT_NULL(series); +} + +/* Test: File-based series whose filename and iteration group pad %T differently + * Files: tests/data/padding_mismatch_file_based_padded_filename/data_0001.h5 (group /data/1/) + * tests/data/padding_mismatch_file_based_padded_group/data_1.h5 (group /data/0001/) + * Tests: Opening by pattern or by name reports a format error in both directions */ +void test_padding_mismatch_file_based_root_group(void) { + pmd_series *series; + pmd_status result; + + /* Padded filename, unpadded group */ + result = pmd_open_series("tests/data/padding_mismatch_file_based_padded_filename/data_%T.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + TEST_ASSERT_NULL(series); + + result = pmd_open_series("tests/data/padding_mismatch_file_based_padded_filename/data_0001.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + TEST_ASSERT_NULL(series); + + /* Unpadded filename, padded group */ + result = pmd_open_series("tests/data/padding_mismatch_file_based_padded_group/data_%T.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + TEST_ASSERT_NULL(series); + + result = pmd_open_series("tests/data/padding_mismatch_file_based_padded_group/data_1.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + TEST_ASSERT_NULL(series); +} + /* Test: Reference dump produced by Bmad * File: tests/data/bmad-dump.h5 * Tests: Reading Bmad file with a single iteration and 1000 particles */ @@ -2821,6 +2886,9 @@ int main(void) { RUN_TEST(test_read_openpmd_constant); RUN_TEST(test_read_openpmd_dataset); RUN_TEST(test_file_based_series_zero_padded); + RUN_TEST(test_padding_mismatch_file_based_multiple_percent_t); + RUN_TEST(test_padding_mismatch_group_based_multiple_percent_t); + RUN_TEST(test_padding_mismatch_file_based_root_group); RUN_TEST(test_read_bmad_dump); /* Unit Conversion tests */ From 68e877bd1caa9681a1458f5fd764b20d722b54a0 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 11:19:05 -0700 Subject: [PATCH 14/22] move padding detection to single location in code --- parcel.h | 175 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 90 insertions(+), 85 deletions(-) diff --git a/parcel.h b/parcel.h index 172fae7..3d9b181 100644 --- a/parcel.h +++ b/parcel.h @@ -1589,9 +1589,10 @@ static herr_t first_iteration_padding_callback(hid_t loc_id, const char *name, * Scans the group holding the iterations (e.g. "/data" for a base path of "/data/%T/") and reports * the padding of the first matching name. Reports 0 when no name matches or the names are unpadded, * so a caller that has already detected a padding elsewhere can leave it untouched rather than - * clobbering it with a zero. + * clobbering it with a zero. GROUP_BASED series take their padding from here, and FILE_BASED series + * use it on the iteration groups inside one of their files. */ -static pmd_status detect_iteration_padding_in_file(hid_t file_id, const char *pattern, +static pmd_status detect_group_based_padding(hid_t file_id, const char *pattern, unsigned int *padding_out) { iteration_pattern pattern_info; iteration_padding_probe probe; @@ -1795,19 +1796,6 @@ static pmd_status read_series_metadata_from_file(hid_t file_id, pmd_series *seri } free_iteration_pattern(&pattern_check); - /* Detect zero padded iteration groups inside the file (e.g. "/data/00001/"). Only adopt a - * padding that was actually found, so an unpadded group never clears a padding already - * detected from the filenames on disk. */ - unsigned int detected_padding = 0; - status = detect_iteration_padding_in_file(file_id, series->base_path, &detected_padding); - if (status != PMD_SUCCESS) { - free(iter_encoding_str); - return status; - } - if (detected_padding != 0) { - series->iteration_padding = detected_padding; - } - /* Don't keep file open for fileBased */ H5Fclose(file_id); series->file_id = -1; @@ -1820,17 +1808,6 @@ static pmd_status read_series_metadata_from_file(hid_t file_id, pmd_series *seri return PMD_ERROR_FILE_FORMAT; } - /* Detect zero padded iteration groups (e.g. "/data/00001/" for a base path of "/data/%T/") */ - unsigned int detected_padding = 0; - status = detect_iteration_padding_in_file(file_id, series->base_path, &detected_padding); - if (status != PMD_SUCCESS) { - free(iter_encoding_str); - return status; - } - if (detected_padding != 0) { - series->iteration_padding = detected_padding; - } - /* For groupBased, keep file open */ series->file_id = file_id; } else { @@ -1932,16 +1909,13 @@ static pmd_status delete_matching_iteration_files(const char *pattern) { /** * Find the first existing file matching an iteration pattern, in directory order - * Sets *found_out to 1, *iteration_out to the iteration index and *padding_out to the zero padding - * of the matched name when a match exists on disk + * Sets *path_out to the path of the matched file (caller must free), or NULL when nothing matches on disk */ static pmd_status find_first_iteration_file(const char *filename, const iteration_pattern *pattern_info, - int *found_out, int64_t *iteration_out, - unsigned int *padding_out) { + char **path_out) { pmd_dirent *entry; - *found_out = 0; - *padding_out = 0; + *path_out = NULL; /* Open directory and search for matching files */ pmd_dir *dir = pmd_opendir(pattern_info->scan_parent); @@ -1970,20 +1944,84 @@ static pmd_status find_first_iteration_file(const char *filename, const iteratio /* Test for file existance */ FILE *test = fopen(full_path, "rb"); - free(full_path); if (test) { (void)fclose(test); - *found_out = 1; - *iteration_out = iteration; - *padding_out = padding; + *path_out = full_path; break; } + free(full_path); } pmd_closedir(dir); return PMD_SUCCESS; } +/** + * Detect the zero padding applied to %T by an existing FILE_BASED series + * + * The name of an existing file of the series (relative to directory) has to match iteration_format. + * Its padding is used unless the iteration groups inside that file are padded. + * + * @param first_file Path of an existing file of the series + * @param directory Directory holding the files of the series + * @param iteration_format Iteration format of the series, e.g. "data_%T.h5" + * @param base_path Base path holding the iteration groups, e.g. "/data/%T/" + * @param padding_out Output for the detected padding width + * @return PMD_SUCCESS or error code + */ +static pmd_status detect_file_based_padding(const char *first_file, const char *directory, + const char *iteration_format, const char *base_path, + unsigned int *padding_out) { + pmd_status status; + unsigned int group_padding = 0; + + if (!first_file || !directory || !iteration_format || !base_path || !padding_out) { + return PMD_ERROR_NULL_POINTER; + } + + *padding_out = 0; + + /* Match the file name against iteration_format to get its padding */ + const char *relative_path = path_after_directory(first_file, directory); + while (*relative_path == '/' || *relative_path == '\\') { + relative_path++; + } + char *relative_name = pmd_strdup(relative_path); + if (!relative_name) { + return PMD_ERROR_OUT_OF_MEMORY; + } + normalize_path_separators(relative_name); + + int64_t file_iteration; + unsigned int file_padding = 0; + status = extract_iteration_from_name(relative_name, iteration_format, &file_iteration, &file_padding); + if (status != PMD_SUCCESS) { + pmd_log(PMD_LOG_ERROR, "File name '%s' does not match iterationFormat '%s'", + relative_name, iteration_format); + free(relative_name); + return PMD_ERROR_FILE_FORMAT; + } + free(relative_name); + *padding_out = file_padding; + + /* Iteration groups inside the file (e.g. "/data/00001/") */ + hid_t first_file_id = H5Fopen(first_file, H5F_ACC_RDONLY, H5P_DEFAULT); + if (first_file_id < 0) { + pmd_log(PMD_LOG_ERROR, "Failed to open '%s' to detect its iteration groups", first_file); + return PMD_ERROR_HDF5; + } + status = detect_group_based_padding(first_file_id, base_path, &group_padding); + H5Fclose(first_file_id); + if (status != PMD_SUCCESS) { + return status; + } + if (group_padding != 0) { + *padding_out = group_padding; + } + + return PMD_SUCCESS; +} + pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_access_mode mode) { pmd_series *series = NULL; hid_t file_id = -1; @@ -1991,6 +2029,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac int is_write_mode = (mode != PMD_RDONLY); int file_exists = 0; char *iter_filename = NULL; + const char *first_file = NULL; iteration_pattern pattern_info; pattern_info.scan_parent = NULL; pattern_info.first_segment = NULL; @@ -2044,16 +2083,10 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac if (status != PMD_SUCCESS) goto cleanup; /* Find first file matching the pattern */ - int found = 0; - int64_t first_iteration = 0; - unsigned int first_padding = 0; - status = find_first_iteration_file(filename, &pattern_info, &found, &first_iteration, &first_padding); + status = find_first_iteration_file(filename, &pattern_info, &iter_filename); if (status != PMD_SUCCESS) goto cleanup; - /* Existing files decide the padding used to expand %T for the rest of this series */ - series->iteration_padding = first_padding; - - if (!found) { + if (!iter_filename) { /* No existing files found */ if (!is_write_mode) { status = PMD_ERROR_FILE_NOT_FOUND; @@ -2101,12 +2134,6 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac /* Don't create any files yet - will be created when iterations are added */ series->file_id = -1; } else { - iter_filename = replace_iteration(filename, first_iteration, series->iteration_padding); - if (!iter_filename) { - status = PMD_ERROR_OUT_OF_MEMORY; - goto cleanup; - } - // Open the HDF5 file file_id = H5Fopen(iter_filename, H5F_ACC_RDONLY, H5P_DEFAULT); if (file_id >= 0) { @@ -2115,6 +2142,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac if (status != PMD_SUCCESS) { goto cleanup; } + first_file = iter_filename; /* Close file since we will not store for file-based mode */ H5Fclose(file_id); @@ -2190,48 +2218,25 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac if (status != PMD_SUCCESS) { goto cleanup; } + first_file = filename; } /* For FILE_BASED series, set directory to parent of filename */ if (series->iteration_encoding == PMD_FILE_BASED) { series->directory = pmd_dirname(filename); + } + } - if (file_exists){ - /* Validate that iterationFormat matches the actual filename */ - /* Extract just the filename from the full path */ - const char *actual_basename = strrchr(filename, '/'); - if (!actual_basename) { - actual_basename = strrchr(filename, '\\'); - } - if (actual_basename) { - actual_basename++; /* Skip past the separator */ - } else { - actual_basename = filename; /* No path separator, use whole string */ - } - - /* Try to extract iteration from actual filename using the iterationFormat pattern */ - int64_t extracted_iteration; - unsigned int extracted_padding = 0; - iteration_pattern actual_pattern; - pmd_status match_status = parse_iteration_pattern(series->iteration_format, &actual_pattern); - if (match_status == PMD_SUCCESS) { - pmd_status extract_status = extract_iteration_from_name(actual_basename, - actual_pattern.first_segment, - &extracted_iteration, &extracted_padding); - free_iteration_pattern(&actual_pattern); - if (extract_status != PMD_SUCCESS) { - /* Filename doesn't match the pattern specified in iterationFormat */ - status = PMD_ERROR_FILE_FORMAT; - goto cleanup; - } - - /* A zero padded name (e.g. "data_0007.h5") sets the padding for this series */ - if (extracted_padding != 0) { - series->iteration_padding = extracted_padding; - } - } - } + /* Detect zero padded %T from the names of an existing series */ + if (first_file) { + if (series->iteration_encoding == PMD_GROUP_BASED) { + status = detect_group_based_padding(series->file_id, series->base_path, + &series->iteration_padding); + } else { + status = detect_file_based_padding(first_file, series->directory, series->iteration_format, + series->base_path, &series->iteration_padding); } + if (status != PMD_SUCCESS) goto cleanup; } /* Write root attributes if in write mode */ From 8b648b8ecb46332a9ab6ec71bcba06626e3f5bbd Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 11:23:59 -0700 Subject: [PATCH 15/22] report inconsistent padding --- parcel.h | 79 +++++++++++++++++++++++++++++++++++++----- tests/test_utilities.c | 4 +++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/parcel.h b/parcel.h index 3d9b181..601bb30 100644 --- a/parcel.h +++ b/parcel.h @@ -1563,6 +1563,7 @@ static pmd_status ensure_base_path_groups(hid_t file_id, const char *base_path) typedef struct { const char *first_segment; /* First path segment of the pattern, e.g. "%T" */ unsigned int padding; /* Zero padding width of the first matching name */ + pmd_status status; /* Error status from matching the names */ } iteration_padding_probe; /** @@ -1576,7 +1577,12 @@ static herr_t first_iteration_padding_callback(hid_t loc_id, const char *name, (void)loc_id; (void)info; - if (extract_iteration_from_name(name, probe->first_segment, &iteration, &probe->padding) != PMD_SUCCESS) { + pmd_status status = extract_iteration_from_name(name, probe->first_segment, &iteration, &probe->padding); + if (status == PMD_ERROR_FILE_FORMAT) { + probe->status = status; + return -1; /* Group name pads its %T inconsistently */ + } + if (status != PMD_SUCCESS) { return 0; /* Name doesn't match pattern, keep looking */ } @@ -1622,6 +1628,7 @@ static pmd_status detect_group_based_padding(hid_t file_id, const char *pattern, probe.first_segment = pattern_info.first_segment; probe.padding = 0; + probe.status = PMD_SUCCESS; herr_t iter_result = H5Literate(group_id, H5_INDEX_NAME, H5_ITER_INC, NULL, first_iteration_padding_callback, &probe); @@ -1629,6 +1636,9 @@ static pmd_status detect_group_based_padding(hid_t file_id, const char *pattern, H5Gclose(group_id); free_iteration_pattern(&pattern_info); + if (probe.status != PMD_SUCCESS) { + return probe.status; + } if (iter_result < 0) { return PMD_ERROR_HDF5; } @@ -1928,7 +1938,14 @@ static pmd_status find_first_iteration_file(const char *filename, const iteratio unsigned int padding; /* Try to extract iteration from name matching first segment pattern */ - if (extract_iteration_from_name(entry->d_name, pattern_info->first_segment, &iteration, &padding) != PMD_SUCCESS) { + pmd_status match_status = extract_iteration_from_name(entry->d_name, pattern_info->first_segment, + &iteration, &padding); + if (match_status == PMD_ERROR_FILE_FORMAT) { + /* File name pads its %T inconsistently */ + pmd_closedir(dir); + return match_status; + } + if (match_status != PMD_SUCCESS) { continue; } @@ -1995,6 +2012,11 @@ static pmd_status detect_file_based_padding(const char *first_file, const char * int64_t file_iteration; unsigned int file_padding = 0; status = extract_iteration_from_name(relative_name, iteration_format, &file_iteration, &file_padding); + if (status == PMD_ERROR_FILE_FORMAT) { + /* File name pads its %T inconsistently, already logged */ + free(relative_name); + return status; + } if (status != PMD_SUCCESS) { pmd_log(PMD_LOG_ERROR, "File name '%s' does not match iterationFormat '%s'", relative_name, iteration_format); @@ -2330,7 +2352,13 @@ static herr_t collect_iterations_callback(hid_t loc_id, const char *name, int64_t iteration; /* Try to extract iteration number from name matching first segment pattern */ - if (extract_iteration_from_name(name, collector->first_segment, &iteration, NULL) != PMD_SUCCESS) { + pmd_status match_status = extract_iteration_from_name(name, collector->first_segment, &iteration, NULL); + if (match_status == PMD_ERROR_FILE_FORMAT) { + /* Group name pads its %T inconsistently */ + collector->status = match_status; + return -1; + } + if (match_status != PMD_SUCCESS) { return 0; /* Name doesn't match pattern, skip */ } @@ -2449,7 +2477,14 @@ static pmd_status pmd_parse_iterations(pmd_series *series) { int64_t iteration; /* Try to extract iteration from name matching first segment pattern */ - if (extract_iteration_from_name(entry->d_name, pattern_info.first_segment, &iteration, NULL) != PMD_SUCCESS) { + pmd_status match_status = extract_iteration_from_name(entry->d_name, pattern_info.first_segment, + &iteration, NULL); + if (match_status == PMD_ERROR_FILE_FORMAT) { + /* File name pads its %T inconsistently */ + collector.status = match_status; + break; + } + if (match_status != PMD_SUCCESS) { continue; /* Name doesn't match, skip */ } @@ -2688,16 +2723,33 @@ static void free_iteration_pattern(iteration_pattern *info) { } } +/** + * Compare two digit strings by value, ignoring leading zeros + */ +static int digit_strings_equal_value(const char *a, size_t a_len, const char *b, size_t b_len) { + while (a_len > 1 && *a == '0') { + a++; + a_len--; + } + while (b_len > 1 && *b == '0') { + b++; + b_len--; + } + return a_len == b_len && strncmp(a, b, a_len) == 0; +} + /** * Extract iteration number from a name matching a pattern segment * * Matches names like "data_5" against patterns like "data_%T" and extracts 5. - * Handles multiple %T in the pattern (all must match the same number). + * Handles multiple %T in the pattern (all must match the same number, padded the same way). * * @param name Name to match (e.g., "data_5") * @param pattern Pattern to match against (e.g., "data_%T") * @param iteration_out Output for extracted iteration number - * @return PMD_SUCCESS if match found and iteration extracted, error otherwise + * @param padding_out Output for the zero padding width of the match, 0 when unpadded (may be NULL) + * @return PMD_SUCCESS if match found and iteration extracted, PMD_ERROR_FILE_FORMAT if the name + * matches but pads its %T to different widths, PMD_ERROR if the name doesn't match */ static pmd_status extract_iteration_from_name(const char *name, const char *pattern, int64_t *iteration_out, unsigned int *padding_out) { @@ -2705,6 +2757,7 @@ static pmd_status extract_iteration_from_name(const char *name, const char *patt const char *n = name; char matched_number[64] = {0}; int found_format_char = 0; + int padding_mismatch = 0; if (!name || !pattern || !iteration_out) { return PMD_ERROR_NULL_POINTER; @@ -2751,10 +2804,15 @@ static pmd_status extract_iteration_from_name(const char *name, const char *patt matched_number[digit_len] = '\0'; found_format_char = 1; } else { - /* Subsequent %T must match same digits */ + /* Subsequent %T must match the same digits, zero padding included */ if (strlen(matched_number) != digit_len || strncmp(matched_number, digit_start, digit_len) != 0) { - return PMD_ERROR; + if (!digit_strings_equal_value(matched_number, strlen(matched_number), + digit_start, digit_len)) { + return PMD_ERROR; + } + /* Same iteration, different zero padding - only an error if the rest matches */ + padding_mismatch = 1; } } @@ -2783,6 +2841,11 @@ static pmd_status extract_iteration_from_name(const char *name, const char *patt return PMD_ERROR; } + if (padding_mismatch) { + pmd_log(PMD_LOG_ERROR, "'%s' matches '%s' but pads its %%T to different widths", name, pattern); + return PMD_ERROR_FILE_FORMAT; + } + /* Report the zero padding width, if the name was wider than the bare number needs */ if (padding_out) { char unpadded[64]; diff --git a/tests/test_utilities.c b/tests/test_utilities.c index 87265f2..57e1f3a 100644 --- a/tests/test_utilities.c +++ b/tests/test_utilities.c @@ -138,6 +138,10 @@ void test_extract_iteration_from_name(void) { status = extract_iteration_from_name("data_5_step_6", "data_%T_step_%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR, status); + /* Multiple %T padded to different widths - should report a format error */ + status = extract_iteration_from_name("data_0005_step_5", "data_%T_step_%T", &iteration, NULL); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, status); + /* Consecutive %T patterns - ambiguous, should fail */ status = extract_iteration_from_name("data_123", "data_%T%T", &iteration, NULL); TEST_ASSERT_EQUAL_INT(PMD_ERROR, status); From 2f5ce697fff3d5f034eef28c6765914562d37a37 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 11:28:44 -0700 Subject: [PATCH 16/22] check group %T in file-based series --- parcel.h | 53 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/parcel.h b/parcel.h index 601bb30..b4893a4 100644 --- a/parcel.h +++ b/parcel.h @@ -1561,8 +1561,14 @@ static pmd_status ensure_base_path_groups(hid_t file_id, const char *base_path) } typedef struct { - const char *first_segment; /* First path segment of the pattern, e.g. "%T" */ + int found; /* Whether a name matching the pattern was found */ + int64_t iteration; /* Iteration index of the first matching name */ unsigned int padding; /* Zero padding width of the first matching name */ +} iteration_group_match; + +typedef struct { + const char *first_segment; /* First path segment of the pattern, e.g. "%T" */ + iteration_group_match match; /* Result for the first matching name */ pmd_status status; /* Error status from matching the names */ } iteration_padding_probe; @@ -1572,12 +1578,12 @@ typedef struct { static herr_t first_iteration_padding_callback(hid_t loc_id, const char *name, const H5L_info_t *info, void *op_data) { iteration_padding_probe *probe = (iteration_padding_probe *)op_data; - int64_t iteration; (void)loc_id; (void)info; - pmd_status status = extract_iteration_from_name(name, probe->first_segment, &iteration, &probe->padding); + pmd_status status = extract_iteration_from_name(name, probe->first_segment, &probe->match.iteration, + &probe->match.padding); if (status == PMD_ERROR_FILE_FORMAT) { probe->status = status; return -1; /* Group name pads its %T inconsistently */ @@ -1586,6 +1592,7 @@ static herr_t first_iteration_padding_callback(hid_t loc_id, const char *name, return 0; /* Name doesn't match pattern, keep looking */ } + probe->match.found = 1; return 1; /* Stop iterating, the first match decides the padding */ } @@ -1593,21 +1600,22 @@ static herr_t first_iteration_padding_callback(hid_t loc_id, const char *name, * Detect the zero padding applied to %T by the iteration groups of an open file * * Scans the group holding the iterations (e.g. "/data" for a base path of "/data/%T/") and reports - * the padding of the first matching name. Reports 0 when no name matches or the names are unpadded, - * so a caller that has already detected a padding elsewhere can leave it untouched rather than - * clobbering it with a zero. GROUP_BASED series take their padding from here, and FILE_BASED series - * use it on the iteration groups inside one of their files. + * the first matching name. The padding is 0 when no name matches or the names are unpadded, so + * match_out->found tells the two apart. GROUP_BASED series take their padding from here, and + * FILE_BASED series use it on the iteration groups inside one of their files. */ static pmd_status detect_group_based_padding(hid_t file_id, const char *pattern, - unsigned int *padding_out) { + iteration_group_match *match_out) { iteration_pattern pattern_info; iteration_padding_probe probe; - if (!pattern || !padding_out) { + if (!pattern || !match_out) { return PMD_ERROR_NULL_POINTER; } - *padding_out = 0; + match_out->found = 0; + match_out->iteration = 0; + match_out->padding = 0; pmd_status status = parse_iteration_pattern(pattern, &pattern_info); if (status != PMD_SUCCESS) { @@ -1627,7 +1635,7 @@ static pmd_status detect_group_based_padding(hid_t file_id, const char *pattern, } probe.first_segment = pattern_info.first_segment; - probe.padding = 0; + probe.match = *match_out; probe.status = PMD_SUCCESS; herr_t iter_result = H5Literate(group_id, H5_INDEX_NAME, H5_ITER_INC, NULL, @@ -1643,7 +1651,7 @@ static pmd_status detect_group_based_padding(hid_t file_id, const char *pattern, return PMD_ERROR_HDF5; } - *padding_out = probe.padding; + *match_out = probe.match; return PMD_SUCCESS; } @@ -1990,7 +1998,7 @@ static pmd_status detect_file_based_padding(const char *first_file, const char * const char *iteration_format, const char *base_path, unsigned int *padding_out) { pmd_status status; - unsigned int group_padding = 0; + iteration_group_match group_match; if (!first_file || !directory || !iteration_format || !base_path || !padding_out) { return PMD_ERROR_NULL_POINTER; @@ -2026,19 +2034,23 @@ static pmd_status detect_file_based_padding(const char *first_file, const char * free(relative_name); *padding_out = file_padding; - /* Iteration groups inside the file (e.g. "/data/00001/") */ + /* The iteration group inside the file (e.g. "/data/00001/") has to agree with the file name */ hid_t first_file_id = H5Fopen(first_file, H5F_ACC_RDONLY, H5P_DEFAULT); if (first_file_id < 0) { pmd_log(PMD_LOG_ERROR, "Failed to open '%s' to detect its iteration groups", first_file); return PMD_ERROR_HDF5; } - status = detect_group_based_padding(first_file_id, base_path, &group_padding); + status = detect_group_based_padding(first_file_id, base_path, &group_match); H5Fclose(first_file_id); if (status != PMD_SUCCESS) { return status; } - if (group_padding != 0) { - *padding_out = group_padding; + + if (group_match.found && group_match.iteration == file_iteration && + group_match.padding != file_padding) { + pmd_log(PMD_LOG_ERROR, "Iteration group of '%s' pads %%T to a different width than its file name", + first_file); + return PMD_ERROR_FILE_FORMAT; } return PMD_SUCCESS; @@ -2252,8 +2264,11 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac /* Detect zero padded %T from the names of an existing series */ if (first_file) { if (series->iteration_encoding == PMD_GROUP_BASED) { - status = detect_group_based_padding(series->file_id, series->base_path, - &series->iteration_padding); + iteration_group_match group_match; + status = detect_group_based_padding(series->file_id, series->base_path, &group_match); + if (status == PMD_SUCCESS) { + series->iteration_padding = group_match.padding; + } } else { status = detect_file_based_padding(first_file, series->directory, series->iteration_format, series->base_path, &series->iteration_padding); From ebae0c1a4fb36ee875a305e0b505e53b3585ba1b Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 11:33:19 -0700 Subject: [PATCH 17/22] add check across hdf5 groups --- parcel.h | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/parcel.h b/parcel.h index b4893a4..2e939c0 100644 --- a/parcel.h +++ b/parcel.h @@ -745,6 +745,7 @@ typedef struct { char *scan_parent; char *first_segment; const char *full_pattern; + const char *rest; /* Part of full_pattern after first_segment, e.g. "/step_%T/" */ } iteration_pattern; static pmd_status parse_iteration_pattern(const char *pattern, iteration_pattern *info); @@ -1566,8 +1567,138 @@ typedef struct { unsigned int padding; /* Zero padding width of the first matching name */ } iteration_group_match; +typedef struct { + const char *segment; /* Pattern for the group names at this level, e.g. "step_%T" */ + int64_t iteration; /* Iteration the enclosing groups were matched with */ + int mismatch; /* Set when a name holds that iteration under a different padding */ +} nested_padding_probe; + +/** + * Callback for H5Literate that looks for the iteration under a different zero padding + */ +static herr_t nested_iteration_padding_callback(hid_t loc_id, const char *name, + const H5L_info_t *info, void *op_data) { + nested_padding_probe *probe = (nested_padding_probe *)op_data; + int64_t iteration; + unsigned int padding; + + (void)loc_id; + (void)info; + + pmd_status status = extract_iteration_from_name(name, probe->segment, &iteration, &padding); + if (status == PMD_ERROR_FILE_FORMAT || (status == PMD_SUCCESS && iteration == probe->iteration)) { + /* The name expected for this padding is missing, so a match here is padded differently */ + probe->mismatch = 1; + return 1; + } + + return 0; /* Name doesn't match the iteration, keep looking */ +} + +/** + * Look for a zero padding mismatch in the groups below an iteration group + * + * Walks the pattern segments after the first %T segment (e.g. "/step_%T/" of "/data/%T/step_%T/") + * below the group name, expanding %T with padding. Reports PMD_ERROR_FILE_FORMAT when a group holds + * the same iteration under a different padding. Reports PMD_SUCCESS otherwise, whether or not the + * expected path resolves, since a missing path is not this function's concern. + * + * @param loc_id Group holding the iteration groups, e.g. "/data" for a base path of "/data/%T/" + * @param name Name of the matched iteration group, e.g. "0001" + * @param rest Part of the pattern after the first %T segment + * @param iteration Iteration index the group name matched + * @param padding Zero padding width the group name matched with + * @return PMD_SUCCESS or error code + */ +static pmd_status check_nested_iteration_padding(hid_t loc_id, const char *name, const char *rest, + int64_t iteration, unsigned int padding) { + char path[PMD_PATH_MAX]; + char segment[PMD_PATH_MAX]; + nested_padding_probe probe; + + if (!name || !rest) { + return PMD_ERROR_NULL_POINTER; + } + + int written = snprintf(path, sizeof(path), "%s", name); + if (written < 0 || (size_t)written >= sizeof(path)) { + return PMD_ERROR; + } + + const char *segment_start = rest; + while (*segment_start) { + /* Skip path separators between segments */ + if (*segment_start == '/' || *segment_start == '\\') { + segment_start++; + continue; + } + + const char *segment_end = segment_start; + while (*segment_end != '\0' && *segment_end != '/' && *segment_end != '\\') { + segment_end++; + } + + size_t segment_len = segment_end - segment_start; + if (segment_len >= sizeof(segment)) { + return PMD_ERROR; + } + memcpy(segment, segment_start, segment_len); + segment[segment_len] = '\0'; + segment_start = segment_end; + + /* Descend into the name this segment is expected to have */ + char *expected = replace_iteration(segment, iteration, padding); + if (!expected) { + return PMD_ERROR_OUT_OF_MEMORY; + } + char child_path[PMD_PATH_MAX]; + written = snprintf(child_path, sizeof(child_path), "%s/%s", path, expected); + free(expected); + if (written < 0 || (size_t)written >= sizeof(child_path)) { + return PMD_ERROR; + } + + if (H5Lexists(loc_id, child_path, H5P_DEFAULT) > 0) { + memcpy(path, child_path, strlen(child_path) + 1); + continue; + } + + /* Nothing more to compare for a segment without %T */ + if (!strstr(segment, "%T")) { + return PMD_SUCCESS; + } + + /* The expected name is missing - a sibling holding the same iteration is padded differently */ + hid_t group_id = H5Gopen(loc_id, path, H5P_DEFAULT); + if (group_id < 0) { + return PMD_ERROR_HDF5; + } + + probe.segment = segment; + probe.iteration = iteration; + probe.mismatch = 0; + + herr_t iter_result = H5Literate(group_id, H5_INDEX_NAME, H5_ITER_INC, NULL, + nested_iteration_padding_callback, &probe); + H5Gclose(group_id); + + if (probe.mismatch) { + pmd_log(PMD_LOG_ERROR, "Groups below '%s' pad %%T to a different width than '%s'", path, name); + return PMD_ERROR_FILE_FORMAT; + } + if (iter_result < 0) { + return PMD_ERROR_HDF5; + } + + return PMD_SUCCESS; + } + + return PMD_SUCCESS; +} + typedef struct { const char *first_segment; /* First path segment of the pattern, e.g. "%T" */ + const char *rest; /* Part of the pattern after first_segment, e.g. "/step_%T/" */ iteration_group_match match; /* Result for the first matching name */ pmd_status status; /* Error status from matching the names */ } iteration_padding_probe; @@ -1592,6 +1723,14 @@ static herr_t first_iteration_padding_callback(hid_t loc_id, const char *name, return 0; /* Name doesn't match pattern, keep looking */ } + /* The groups below the match have to use the same padding */ + status = check_nested_iteration_padding(loc_id, name, probe->rest, probe->match.iteration, + probe->match.padding); + if (status != PMD_SUCCESS) { + probe->status = status; + return -1; + } + probe->match.found = 1; return 1; /* Stop iterating, the first match decides the padding */ } @@ -1635,6 +1774,7 @@ static pmd_status detect_group_based_padding(hid_t file_id, const char *pattern, } probe.first_segment = pattern_info.first_segment; + probe.rest = pattern_info.rest; probe.match = *match_out; probe.status = PMD_SUCCESS; @@ -2068,6 +2208,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac pattern_info.scan_parent = NULL; pattern_info.first_segment = NULL; pattern_info.full_pattern = NULL; + pattern_info.rest = NULL; /* Validate input */ if (!filename || !series_out) { @@ -2353,6 +2494,7 @@ typedef struct { int capacity; const char *first_segment; /* Pattern for first segment (e.g., "data_%T") */ const char *full_pattern; /* Full pattern for validation (e.g., "/data/%T/step_%T/") */ + const char *rest; /* Part of full_pattern after first_segment (e.g., "/step_%T/") */ hid_t root_id; /* Root file ID for validating full paths */ unsigned int padding; /* Zero padding width applied when expanding %T */ pmd_status status; /* Error status from memory allocation */ @@ -2391,6 +2533,14 @@ static herr_t collect_iterations_callback(hid_t loc_id, const char *name, free(full_path); if (exists <= 0) { + /* The expected path is missing - the groups below may just be padded differently */ + pmd_status nested_status = check_nested_iteration_padding(loc_id, name, collector->rest, + iteration, collector->padding); + if (nested_status != PMD_SUCCESS) { + collector->status = nested_status; + return -1; + } + /* Path doesn't exist or error checking - skip this iteration */ return 0; } @@ -2423,6 +2573,7 @@ static pmd_status pmd_parse_iterations(pmd_series *series) { pattern_info.scan_parent = NULL; pattern_info.first_segment = NULL; pattern_info.full_pattern = NULL; + pattern_info.rest = NULL; /* Check for single-snapshot file (no %T in iteration_format) */ if (!strstr(series->iteration_format, "%T")) { @@ -2440,11 +2591,12 @@ static pmd_status pmd_parse_iterations(pmd_series *series) { if (status != PMD_SUCCESS) goto cleanup; /* Common collector for both GROUP_BASED and FILE_BASED */ - iteration_collector collector = {NULL, 0, 0, NULL, NULL, -1, 0, PMD_SUCCESS}; + iteration_collector collector = {NULL, 0, 0, NULL, NULL, NULL, -1, 0, PMD_SUCCESS}; /* Initialize collector with pattern info */ collector.first_segment = pattern_info.first_segment; collector.full_pattern = pattern_info.full_pattern; + collector.rest = pattern_info.rest; collector.padding = series->iteration_padding; if (series->iteration_encoding == PMD_GROUP_BASED) { @@ -2668,6 +2820,7 @@ static pmd_status parse_iteration_pattern(const char *pattern, iteration_pattern info->scan_parent = NULL; info->first_segment = NULL; info->full_pattern = pattern; + info->rest = pattern + strlen(pattern); /* Find first %T */ const char *first_format_char = strstr(pattern, "%T"); @@ -2721,6 +2874,7 @@ static pmd_status parse_iteration_pattern(const char *pattern, iteration_pattern } strncpy(info->first_segment, segment_start, segment_len); info->first_segment[segment_len] = '\0'; + info->rest = segment_end; return PMD_SUCCESS; } @@ -2735,6 +2889,7 @@ static void free_iteration_pattern(iteration_pattern *info) { info->scan_parent = NULL; info->first_segment = NULL; info->full_pattern = NULL; + info->rest = NULL; } } From f2b21cc52c0b3afb847161c0cfa89a2c798b243a Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 11:33:57 -0700 Subject: [PATCH 18/22] free memory --- tests/test_read.c | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_read.c b/tests/test_read.c index 6e195b5..e46cef3 100644 --- a/tests/test_read.c +++ b/tests/test_read.c @@ -1064,6 +1064,7 @@ void test_read_bmad_dump(void) { /* Clean up */ pmd_close_iteration(iter); + free(iterations); pmd_close_series(series); } From 28cca16b40e4c5dbe81c565f845796d685bd9904 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 12:03:52 -0700 Subject: [PATCH 19/22] add tests for padding when some filenames are ambiguous --- python/generate_test_data.py | 40 ++++++++++++++++++ tests/test_read.c | 81 ++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/python/generate_test_data.py b/python/generate_test_data.py index 84446e7..5f08ac0 100644 --- a/python/generate_test_data.py +++ b/python/generate_test_data.py @@ -1590,6 +1590,40 @@ def make_zero_padded_file_based_series(dirname: str): print(f"make_zero_padded_file_based_series: Created 3 files in {dirname}") +def make_partially_padded_file_based_series(dirname: str): + """File-based series where only some iterations are wide enough to show the padding""" + dir_path = Path(dirname) + dir_path.mkdir(parents=True, exist_ok=True) + + # Only data_0001.h5 shows the padding, the others are four digits on their own + for iteration in [1] + list(range(1000, 1010)): + fname = dir_path / f"data_{iteration:04d}.h5" + with h5py.File(fname, "w") as f: + write_openpmd_header( + f, base_path="/data/%T/", iteration_encoding="fileBased" + ) + f.attrs["iterationFormat"] = "data_%T.h5" + + iter_grp = f.create_group(f"data/{iteration:04d}") + write_iteration_attributes(iter_grp, time=iteration * 1.0e-15) + write_particle_group(iter_grp, "electron", 10) + + print(f"make_partially_padded_file_based_series: Created 11 files in {dirname}") + + +def make_partially_padded_group_based(fname: str): + """Group-based series where only one iteration group is wide enough to show the padding""" + with h5py.File(fname, "w") as f: + write_openpmd_header(f, base_path="/data/%T/", iteration_encoding="groupBased") + + for iteration in [1] + list(range(1000, 1010)): + iter_grp = f.create_group(f"data/{iteration:04d}") + write_iteration_attributes(iter_grp, time=iteration * 1.0e-15) + write_particle_group(iter_grp, "electron", 10) + + print(f"make_partially_padded_group_based: Created {fname}") + + def make_padding_mismatch_file_based_multiple_percent_t(dirname: str): """File-based series whose filename pads each %T to a different width""" dir_path = Path(dirname) @@ -2011,6 +2045,12 @@ def make_valid_partial_optional_fields(fname: str): make_zero_padded_file_based_series( str(test_data_dir / "file_based_series_zero_padded") ) + make_partially_padded_file_based_series( + str(test_data_dir / "file_based_series_partially_padded") + ) + make_partially_padded_group_based( + str(test_data_dir / "group_based_series_partially_padded.h5") + ) make_padding_mismatch_file_based_multiple_percent_t( str(test_data_dir / "padding_mismatch_file_based_multiple_percent_t") ) diff --git a/tests/test_read.c b/tests/test_read.c index e46cef3..15e418a 100644 --- a/tests/test_read.c +++ b/tests/test_read.c @@ -967,6 +967,85 @@ void test_file_based_series_zero_padded(void) { pmd_close_series(series); } +/* Test: File-based series where only one filename is narrow enough to show the padding + * Files: tests/data/file_based_series_partially_padded/data_{0001,1000..1009}.h5 + * Tests: The padded name decides the padding, whatever order the directory is scanned in */ +void test_file_based_series_partially_padded(void) { + pmd_series *series; + pmd_iteration *iter; + pmd_status result; + int64_t *iterations; + int num_iterations; + int64_t particle_count; + + result = pmd_open_series("tests/data/file_based_series_partially_padded/data_%T.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_NOT_NULL(series); + + /* Only data_0001.h5 is wider than its iteration needs */ + TEST_ASSERT_EQUAL_UINT(4, series->iteration_padding); + + result = pmd_list_iterations(series, &iterations, &num_iterations); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_INT(11, num_iterations); + TEST_ASSERT_EQUAL_INT64(1, iterations[0]); + TEST_ASSERT_EQUAL_INT64(1000, iterations[1]); + TEST_ASSERT_EQUAL_INT64(1009, iterations[10]); + + /* Iteration 1 only resolves as data_0001.h5 */ + result = pmd_open_iteration(series, 1, &iter); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + result = pmd_get_num_particles(iter, "electron", &particle_count); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_INT64(10, particle_count); + pmd_close_iteration(iter); + + result = pmd_open_iteration(series, 1005, &iter); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + pmd_close_iteration(iter); + + free(iterations); + pmd_close_series(series); +} + +/* Test: Group-based series where only one group is narrow enough to show the padding + * File: tests/data/group_based_series_partially_padded.h5 + * Tests: The padded group decides the padding, whatever order the groups are scanned in */ +void test_group_based_series_partially_padded(void) { + pmd_series *series; + pmd_iteration *iter; + pmd_status result; + int64_t *iterations; + int num_iterations; + int64_t particle_count; + + result = pmd_open_series("tests/data/group_based_series_partially_padded.h5", &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_NOT_NULL(series); + + /* Only /data/0001/ is wider than its iteration needs */ + TEST_ASSERT_EQUAL_UINT(4, series->iteration_padding); + + result = pmd_list_iterations(series, &iterations, &num_iterations); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_INT(11, num_iterations); + TEST_ASSERT_EQUAL_INT64(1, iterations[0]); + TEST_ASSERT_EQUAL_INT64(1000, iterations[1]); + TEST_ASSERT_EQUAL_INT64(1009, iterations[10]); + + /* Iteration 1 only resolves as /data/0001/ */ + result = pmd_open_iteration(series, 1, &iter); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + result = pmd_get_num_particles(iter, "electron", &particle_count); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_INT64(10, particle_count); + pmd_close_iteration(iter); + + free(iterations); + pmd_close_series(series); +} + /* Test: File-based series whose filename pads each %T to a different width * File: tests/data/padding_mismatch_file_based_multiple_percent_t/data_0001_iter_1.h5 * Tests: Inconsistent padding between %T in one filename is reported as a format error */ @@ -2887,6 +2966,8 @@ int main(void) { RUN_TEST(test_read_openpmd_constant); RUN_TEST(test_read_openpmd_dataset); RUN_TEST(test_file_based_series_zero_padded); + RUN_TEST(test_file_based_series_partially_padded); + RUN_TEST(test_group_based_series_partially_padded); RUN_TEST(test_padding_mismatch_file_based_multiple_percent_t); RUN_TEST(test_padding_mismatch_group_based_multiple_percent_t); RUN_TEST(test_padding_mismatch_file_based_root_group); From a5a988b21e54c1f0865d46eaf86201683efa5816 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 12:08:23 -0700 Subject: [PATCH 20/22] scan over all files to find any that have zero padding --- parcel.h | 113 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/parcel.h b/parcel.h index 2e939c0..df6a353 100644 --- a/parcel.h +++ b/parcel.h @@ -1699,22 +1699,24 @@ static pmd_status check_nested_iteration_padding(hid_t loc_id, const char *name, typedef struct { const char *first_segment; /* First path segment of the pattern, e.g. "%T" */ const char *rest; /* Part of the pattern after first_segment, e.g. "/step_%T/" */ - iteration_group_match match; /* Result for the first matching name */ + iteration_group_match match; /* Latest matching name, or the first one that is zero padded */ pmd_status status; /* Error status from matching the names */ } iteration_padding_probe; /** - * Callback for H5Literate that reports the padding of the first name matching the pattern + * Callback for H5Literate that reports a matching name, preferring one that shows the zero padding */ -static herr_t first_iteration_padding_callback(hid_t loc_id, const char *name, - const H5L_info_t *info, void *op_data) { +static herr_t maybe_padded_iteration_callback(hid_t loc_id, const char *name, + const H5L_info_t *info, void *op_data) { iteration_padding_probe *probe = (iteration_padding_probe *)op_data; + iteration_group_match match; (void)loc_id; (void)info; - pmd_status status = extract_iteration_from_name(name, probe->first_segment, &probe->match.iteration, - &probe->match.padding); + /* Match into a local, since a later name that doesn't match would clear the kept padding */ + pmd_status status = extract_iteration_from_name(name, probe->first_segment, &match.iteration, + &match.padding); if (status == PMD_ERROR_FILE_FORMAT) { probe->status = status; return -1; /* Group name pads its %T inconsistently */ @@ -1724,24 +1726,31 @@ static herr_t first_iteration_padding_callback(hid_t loc_id, const char *name, } /* The groups below the match have to use the same padding */ - status = check_nested_iteration_padding(loc_id, name, probe->rest, probe->match.iteration, - probe->match.padding); + status = check_nested_iteration_padding(loc_id, name, probe->rest, match.iteration, match.padding); if (status != PMD_SUCCESS) { probe->status = status; return -1; } - probe->match.found = 1; - return 1; /* Stop iterating, the first match decides the padding */ + /* Keep the latest match, and stop once one of them shows the padding */ + match.found = 1; + probe->match = match; + if (match.padding != 0) { + return 1; + } + + return 0; /* Keep looking for a name that is wider than its iteration needs */ } /** * Detect the zero padding applied to %T by the iteration groups of an open file * * Scans the group holding the iterations (e.g. "/data" for a base path of "/data/%T/") and reports - * the first matching name. The padding is 0 when no name matches or the names are unpadded, so - * match_out->found tells the two apart. GROUP_BASED series take their padding from here, and - * FILE_BASED series use it on the iteration groups inside one of their files. + * the first name that is wider than its iteration needs, since only such a name shows the padding. + * Falls back to the last matching name when none of them is padded, so the reported padding is 0 + * both when no name matches and when the names are unpadded, and match_out->found tells the two + * apart. GROUP_BASED series take their padding from here, and FILE_BASED series use it on the + * iteration groups inside one of their files. */ static pmd_status detect_group_based_padding(hid_t file_id, const char *pattern, iteration_group_match *match_out) { @@ -1779,7 +1788,7 @@ static pmd_status detect_group_based_padding(hid_t file_id, const char *pattern, probe.status = PMD_SUCCESS; herr_t iter_result = H5Literate(group_id, H5_INDEX_NAME, H5_ITER_INC, NULL, - first_iteration_padding_callback, &probe); + maybe_padded_iteration_callback, &probe); H5Gclose(group_id); free_iteration_pattern(&pattern_info); @@ -2066,11 +2075,15 @@ static pmd_status delete_matching_iteration_files(const char *pattern) { } /** - * Find the first existing file matching an iteration pattern, in directory order - * Sets *path_out to the path of the matched file (caller must free), or NULL when nothing matches on disk + * Find an existing file matching an iteration pattern, preferring one that reveals the zero padding + * + * Only a name that is wider than its iteration needs (e.g. "data_0001.h5") shows the padding of a + * series, so the scan keeps going until it finds one. Sets *path_out to that file, or to the last + * matching file in directory order when none of them is padded (caller must free). Sets *path_out to + * NULL when nothing matches on disk. */ -static pmd_status find_first_iteration_file(const char *filename, const iteration_pattern *pattern_info, - char **path_out) { +static pmd_status find_maybe_padded_iteration_file(const char *filename, const iteration_pattern *pattern_info, + char **path_out) { pmd_dirent *entry; *path_out = NULL; @@ -2090,6 +2103,8 @@ static pmd_status find_first_iteration_file(const char *filename, const iteratio &iteration, &padding); if (match_status == PMD_ERROR_FILE_FORMAT) { /* File name pads its %T inconsistently */ + free(*path_out); + *path_out = NULL; pmd_closedir(dir); return match_status; } @@ -2102,19 +2117,27 @@ static pmd_status find_first_iteration_file(const char *filename, const iteratio * name on disk rebuilds to a path that does not exist. */ char *full_path = replace_iteration(filename, iteration, padding); if (!full_path) { - pmd_log(PMD_LOG_ERROR, "find_first_iteration_file - out of memory constructing path."); + pmd_log(PMD_LOG_ERROR, "find_maybe_padded_iteration_file - out of memory constructing path."); + free(*path_out); + *path_out = NULL; pmd_closedir(dir); return PMD_ERROR_OUT_OF_MEMORY; } /* Test for file existance */ FILE *test = fopen(full_path, "rb"); - if (test) { - (void)fclose(test); - *path_out = full_path; + if (!test) { + free(full_path); + continue; + } + (void)fclose(test); + + /* Keep the latest match, and stop once one of them shows the padding */ + free(*path_out); + *path_out = full_path; + if (padding != 0) { break; } - free(full_path); } pmd_closedir(dir); @@ -2125,29 +2148,31 @@ static pmd_status find_first_iteration_file(const char *filename, const iteratio * Detect the zero padding applied to %T by an existing FILE_BASED series * * The name of an existing file of the series (relative to directory) has to match iteration_format. - * Its padding is used unless the iteration groups inside that file are padded. + * Its padding is the padding of the series, and the iteration group inside the file has to agree + * with it. * - * @param first_file Path of an existing file of the series + * @param maybe_padded_file Path of an existing file of the series, zero padded if any file is + * (see find_maybe_padded_iteration_file) * @param directory Directory holding the files of the series * @param iteration_format Iteration format of the series, e.g. "data_%T.h5" * @param base_path Base path holding the iteration groups, e.g. "/data/%T/" * @param padding_out Output for the detected padding width * @return PMD_SUCCESS or error code */ -static pmd_status detect_file_based_padding(const char *first_file, const char *directory, +static pmd_status detect_file_based_padding(const char *maybe_padded_file, const char *directory, const char *iteration_format, const char *base_path, unsigned int *padding_out) { pmd_status status; iteration_group_match group_match; - if (!first_file || !directory || !iteration_format || !base_path || !padding_out) { + if (!maybe_padded_file || !directory || !iteration_format || !base_path || !padding_out) { return PMD_ERROR_NULL_POINTER; } *padding_out = 0; /* Match the file name against iteration_format to get its padding */ - const char *relative_path = path_after_directory(first_file, directory); + const char *relative_path = path_after_directory(maybe_padded_file, directory); while (*relative_path == '/' || *relative_path == '\\') { relative_path++; } @@ -2175,13 +2200,13 @@ static pmd_status detect_file_based_padding(const char *first_file, const char * *padding_out = file_padding; /* The iteration group inside the file (e.g. "/data/00001/") has to agree with the file name */ - hid_t first_file_id = H5Fopen(first_file, H5F_ACC_RDONLY, H5P_DEFAULT); - if (first_file_id < 0) { - pmd_log(PMD_LOG_ERROR, "Failed to open '%s' to detect its iteration groups", first_file); + hid_t maybe_padded_file_id = H5Fopen(maybe_padded_file, H5F_ACC_RDONLY, H5P_DEFAULT); + if (maybe_padded_file_id < 0) { + pmd_log(PMD_LOG_ERROR, "Failed to open '%s' to detect its iteration groups", maybe_padded_file); return PMD_ERROR_HDF5; } - status = detect_group_based_padding(first_file_id, base_path, &group_match); - H5Fclose(first_file_id); + status = detect_group_based_padding(maybe_padded_file_id, base_path, &group_match); + H5Fclose(maybe_padded_file_id); if (status != PMD_SUCCESS) { return status; } @@ -2189,7 +2214,7 @@ static pmd_status detect_file_based_padding(const char *first_file, const char * if (group_match.found && group_match.iteration == file_iteration && group_match.padding != file_padding) { pmd_log(PMD_LOG_ERROR, "Iteration group of '%s' pads %%T to a different width than its file name", - first_file); + maybe_padded_file); return PMD_ERROR_FILE_FORMAT; } @@ -2202,8 +2227,8 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac pmd_status status = PMD_SUCCESS; int is_write_mode = (mode != PMD_RDONLY); int file_exists = 0; - char *iter_filename = NULL; - const char *first_file = NULL; + char *maybe_padded_iter_filename = NULL; + const char *maybe_padded_file = NULL; iteration_pattern pattern_info; pattern_info.scan_parent = NULL; pattern_info.first_segment = NULL; @@ -2258,10 +2283,10 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac if (status != PMD_SUCCESS) goto cleanup; /* Find first file matching the pattern */ - status = find_first_iteration_file(filename, &pattern_info, &iter_filename); + status = find_maybe_padded_iteration_file(filename, &pattern_info, &maybe_padded_iter_filename); if (status != PMD_SUCCESS) goto cleanup; - if (!iter_filename) { + if (!maybe_padded_iter_filename) { /* No existing files found */ if (!is_write_mode) { status = PMD_ERROR_FILE_NOT_FOUND; @@ -2310,14 +2335,14 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac series->file_id = -1; } else { // Open the HDF5 file - file_id = H5Fopen(iter_filename, H5F_ACC_RDONLY, H5P_DEFAULT); + file_id = H5Fopen(maybe_padded_iter_filename, H5F_ACC_RDONLY, H5P_DEFAULT); if (file_id >= 0) { /* Read metadata from the opened file */ status = read_series_metadata_from_file(file_id, series, filename); if (status != PMD_SUCCESS) { goto cleanup; } - first_file = iter_filename; + maybe_padded_file = maybe_padded_iter_filename; /* Close file since we will not store for file-based mode */ H5Fclose(file_id); @@ -2393,7 +2418,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac if (status != PMD_SUCCESS) { goto cleanup; } - first_file = filename; + maybe_padded_file = filename; } /* For FILE_BASED series, set directory to parent of filename */ @@ -2403,7 +2428,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac } /* Detect zero padded %T from the names of an existing series */ - if (first_file) { + if (maybe_padded_file) { if (series->iteration_encoding == PMD_GROUP_BASED) { iteration_group_match group_match; status = detect_group_based_padding(series->file_id, series->base_path, &group_match); @@ -2411,7 +2436,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac series->iteration_padding = group_match.padding; } } else { - status = detect_file_based_padding(first_file, series->directory, series->iteration_format, + status = detect_file_based_padding(maybe_padded_file, series->directory, series->iteration_format, series->base_path, &series->iteration_padding); } if (status != PMD_SUCCESS) goto cleanup; @@ -2431,7 +2456,7 @@ pmd_status pmd_open_series(const char *filename, pmd_series **series_out, pmd_ac pmd_close_series(series); series = NULL; } - free(iter_filename); + free(maybe_padded_iter_filename); free_iteration_pattern(&pattern_info); *series_out = series; return status; From acbb68407bb39ea29669beb2fa0524d1017757f8 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 12:24:46 -0700 Subject: [PATCH 21/22] add tests to catch inconsistent padding across iterations --- python/generate_test_data.py | 40 +++++++++++++++++++++++++++++++++ tests/test_read.c | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/python/generate_test_data.py b/python/generate_test_data.py index 5f08ac0..fa7c994 100644 --- a/python/generate_test_data.py +++ b/python/generate_test_data.py @@ -1624,6 +1624,40 @@ def make_partially_padded_group_based(fname: str): print(f"make_partially_padded_group_based: Created {fname}") +def make_mixed_padding_file_based_series(dirname: str): + """File-based series where one iteration is zero padded and another is not""" + dir_path = Path(dirname) + dir_path.mkdir(parents=True, exist_ok=True) + + # Each file agrees with itself, only the two names disagree with each other + for name in ["0001", "10"]: + fname = dir_path / f"data_{name}.h5" + with h5py.File(fname, "w") as f: + write_openpmd_header( + f, base_path="/data/%T/", iteration_encoding="fileBased" + ) + f.attrs["iterationFormat"] = "data_%T.h5" + + iter_grp = f.create_group(f"data/{name}") + write_iteration_attributes(iter_grp) + write_particle_group(iter_grp, "electron", 10) + + print(f"make_mixed_padding_file_based_series: Created 2 files in {dirname}") + + +def make_mixed_padding_group_based(fname: str): + """Group-based series where one iteration group is zero padded and another is not""" + with h5py.File(fname, "w") as f: + write_openpmd_header(f, base_path="/data/%T/", iteration_encoding="groupBased") + + for name in ["0001", "10"]: + iter_grp = f.create_group(f"data/{name}") + write_iteration_attributes(iter_grp) + write_particle_group(iter_grp, "electron", 10) + + print(f"make_mixed_padding_group_based: Created {fname}") + + def make_padding_mismatch_file_based_multiple_percent_t(dirname: str): """File-based series whose filename pads each %T to a different width""" dir_path = Path(dirname) @@ -2051,6 +2085,12 @@ def make_valid_partial_optional_fields(fname: str): make_partially_padded_group_based( str(test_data_dir / "group_based_series_partially_padded.h5") ) + make_mixed_padding_file_based_series( + str(test_data_dir / "file_based_series_mixed_padding") + ) + make_mixed_padding_group_based( + str(test_data_dir / "group_based_series_mixed_padding.h5") + ) make_padding_mismatch_file_based_multiple_percent_t( str(test_data_dir / "padding_mismatch_file_based_multiple_percent_t") ) diff --git a/tests/test_read.c b/tests/test_read.c index 15e418a..8a3c2fc 100644 --- a/tests/test_read.c +++ b/tests/test_read.c @@ -1046,6 +1046,47 @@ void test_group_based_series_partially_padded(void) { pmd_close_series(series); } +/* Test: File-based series where one iteration is padded and another is not + * Files: tests/data/file_based_series_mixed_padding/data_{0001,10}.h5 + * Tests: Listing reports names that are not padded like the rest of the series */ +void test_mixed_padding_file_based_series(void) { + pmd_series *series; + pmd_status result; + int64_t *iterations; + int num_iterations; + + result = pmd_open_series("tests/data/file_based_series_mixed_padding/data_%T.h5", + &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_UINT(4, series->iteration_padding); + + /* data_10.h5 would have to be data_0010.h5 to belong to this series */ + result = pmd_list_iterations(series, &iterations, &num_iterations); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + + pmd_close_series(series); +} + +/* Test: Group-based series where one iteration group is padded and another is not + * File: tests/data/group_based_series_mixed_padding.h5 + * Tests: Listing reports groups that are not padded like the rest of the series */ +void test_mixed_padding_group_based_series(void) { + pmd_series *series; + pmd_status result; + int64_t *iterations; + int num_iterations; + + result = pmd_open_series("tests/data/group_based_series_mixed_padding.h5", &series, PMD_RDONLY); + TEST_ASSERT_EQUAL_INT(PMD_SUCCESS, result); + TEST_ASSERT_EQUAL_UINT(4, series->iteration_padding); + + /* /data/10/ would have to be /data/0010/ to belong to this series */ + result = pmd_list_iterations(series, &iterations, &num_iterations); + TEST_ASSERT_EQUAL_INT(PMD_ERROR_FILE_FORMAT, result); + + pmd_close_series(series); +} + /* Test: File-based series whose filename pads each %T to a different width * File: tests/data/padding_mismatch_file_based_multiple_percent_t/data_0001_iter_1.h5 * Tests: Inconsistent padding between %T in one filename is reported as a format error */ @@ -2968,6 +3009,8 @@ int main(void) { RUN_TEST(test_file_based_series_zero_padded); RUN_TEST(test_file_based_series_partially_padded); RUN_TEST(test_group_based_series_partially_padded); + RUN_TEST(test_mixed_padding_file_based_series); + RUN_TEST(test_mixed_padding_group_based_series); RUN_TEST(test_padding_mismatch_file_based_multiple_percent_t); RUN_TEST(test_padding_mismatch_group_based_multiple_percent_t); RUN_TEST(test_padding_mismatch_file_based_root_group); From 6e3d7bfeb0b0715bb223041ae6d89b02602727e7 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 17 Sep 2026 12:27:35 -0700 Subject: [PATCH 22/22] compare rebuilt name to actual to detect inconsistent padding --- parcel.h | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/parcel.h b/parcel.h index df6a353..2f37f57 100644 --- a/parcel.h +++ b/parcel.h @@ -2544,6 +2544,23 @@ static herr_t collect_iterations_callback(hid_t loc_id, const char *name, return 0; /* Name doesn't match pattern, skip */ } + /* Every iteration of a series is padded the same way */ + char *expected_name = replace_iteration(collector->first_segment, iteration, collector->padding); + if (!expected_name) { + collector->status = PMD_ERROR_OUT_OF_MEMORY; + return -1; + } + int name_differs = strcmp(expected_name, name) != 0; + if (name_differs) { + pmd_log(PMD_LOG_ERROR, "Iteration group '%s' is not padded like the rest of the series (expected '%s')", + name, expected_name); + } + free(expected_name); + if (name_differs) { + collector->status = PMD_ERROR_FILE_FORMAT; + return -1; + } + /* Validate full path if pattern has additional components after first segment */ if (collector->full_pattern) { /* Replace all %T in full pattern with extracted iteration */ @@ -2680,6 +2697,24 @@ static pmd_status pmd_parse_iterations(pmd_series *series) { continue; /* Name doesn't match, skip */ } + /* Every iteration of a series is padded the same way */ + char *expected_name = replace_iteration(pattern_info.first_segment, iteration, + series->iteration_padding); + if (!expected_name) { + collector.status = PMD_ERROR_OUT_OF_MEMORY; + break; + } + int name_differs = strcmp(expected_name, entry->d_name) != 0; + if (name_differs) { + pmd_log(PMD_LOG_ERROR, "File name '%s' is not padded like the rest of the series (expected '%s')", + entry->d_name, expected_name); + } + free(expected_name); + if (name_differs) { + collector.status = PMD_ERROR_FILE_FORMAT; + break; + } + /* Validate full path if pattern has additional path components */ if (pattern_info.full_pattern && (strchr(pattern_info.full_pattern, '/') || strchr(pattern_info.full_pattern, '\\'))) { int sstatus;